Introduction to JVM Method Profiling

Tue, 02 Dec 2025 10:09:00 +0000
Java, JVM, Java Internals

In my latest blog post, I mentioned that the size of the method compiled by the JIT compiler depends on the amount of profiling data included. But what does that actually mean? In this article, I will answer that question and also explain how the profiling mechanism works in general.

Experiment

Environment

I was conducting some research using a VM on GCP, specifically running Debian GNU/Linux 12 (bookworm) on an amd64 architecture. The Java version installed was Temurin-25+36 (build 25+36-LTS). You can find the complete source code on GitHub. To gain insight into the state of JVM internal objects residing in Metaspace, I utilized the jhsdb utility.

Run the program

The program I used for the experiment is basically a simple loop, as shown below:

static void main(String[] args) throws InterruptedException {
    var nrOfIterations = Integer.parseInt(args[0]);
    var sleepInMillis = Integer.parseInt(args[1]);
    var argumentHolder = new ArgumentsHolder(nrOfIterations, sleepInMillis);
    var sum = 0;
    for (int i = 1; i <= argumentHolder.getNrOfIterations(); i++) {
        IO.println("This is: " + i + " iteration");
        Thread.sleep(resolveSleepInMillis(i, argumentHolder.getSleepInMillis()));
        sum += resolveNumber(i);
    }
    IO.println("My fantastic sum is: " + sum);
}

The Thread.sleep() is included to allow me to stop the JVM at specific points.

I ran the program with the following command:

java -XX:+PrintCompilation -jar target/app.jar 100000 100

I included the PrintCompilation flag in the run command because it allows me to see when specific methods are compiled by the JIT compiler or decompiled back to interpreter mode.

To attach a command-line debugger to a running JVM, I first needed to know the process ID. Once I had the pid, I could run:

jhsdb clhsdb --pid <pid>

After the debugger was attached I could see the method details, but I needed to perform some initial steps first.

To find the class memory pointer using the class fully qualified name:

class org.zygiert.Main

This command returned the pointer to actual InstanceKlass object in Metaspace

With this information, I was able to run:

inspect <memory_address>

This command returned detailed information about the class. The most relevant part was a pointer to the methods array:

Array<Method*>* InstanceKlass::_methods: Array<Method*> @ 0x00007f307c400400

To inspect actual array content, I ran the following command (I used 5 here to view five memory words):

mem 0x00007f307c400400/5

The output looked as follows (the first line indicates the array size, followed by four consecutive pointers to the methods of this class):

0x00007f307c400400: 0x0000000000000004
0x00007f307c400408: 0x00007f307c400478
0x00007f307c400410: 0x00007f307c4005c0
0x00007f307c400418: 0x00007f307c400768
0x00007f307c400420: 0x00007f307c400698

With this information, I gained interesting insights into how the method metadata changed as the application ran. I used the inspect command, which I previously mentioned. Similar steps can be repeated to examine each class in the codebase. In my case, I was particularly interested in one more class.

Tier 0

The first stop of a program I created was at the 235th iteration. By knowing the memory addresses for each method, I was able to access the method metadata. For the main method, it looked as follows:

Type is Method (size of 88)
ConstMethod*...