The Long Journey - How Bytecode Gets Interpreted by the Machine

Thu, 06 Nov 2025 09:42:00 +0000
Java, JVM, Java Internals

I used to hear in the past that the JIT (Just In Time) compiler can compile bytecode on multiple levels. However, I have never thought about what it means in practice. In the middle of October, I was attending a conference where I had the pleasure of listening to a talk about ‘lock-free programming'. During this talk, the speakers briefly mentioned the JVM internals. This inspired me to dig deeper into this topic. As one of the best ways to check if you understand any topic well is to try to explain it to others, I decided to write this article.

Tiered compilation

One of the main differences between Java and languages like C++ is how they are compiled. A C++ compiler translates code directly into machine code that the CPU can execute. In contrast, Java compiles its code into an intermediate form known as bytecode. This bytecode cannot be executed directly by the CPU. It requires additional processing. The JVM serves as an application that translates this bytecode into machine code, enabling it to be executed by the CPU.

It's important to note that the code we write in any JVM-compatible language serves as a recipe for how we want our program to execute.

The JVM can compile our bytecode at five different levels. I will illustrate this with an image:

I will take a very simple method as an example to illustrate the difference between each level.

Bytecode (.class)

This is the definition of a method in Java:

private static int resolveNumber(int i) {
    if (i < 20000) return i;
    else return i * 2;
}

After compilation, we can examine the generated bytecode using the following command:

javap -c -p target/classes/org/zygiert/Main.class

javap is a Java Class File Disassembler that comes with JDK.

The -c option is used to print disassembled code.

The -v option allows to display private methods as well (this code piece is a fragment of Main class).

The produced bytecode is a set of operation codes for the JVM that specify the operations to be executed. Below is the bytecode representation of the method:

         0: iload_0
         1: sipush        20000
         4: if_icmpge     9
         7: iload_0
         8: ireturn
         9: iload_0
        10: iconst_2
        11: imul
        12: ireturn

Here's what actually happens:

1. iload_0 loads an integer from a local variable to the stack.

2. sipush pushes the short integer 20000 onto the stack.

3. if_icmpge compares the two integer values. If the first integer is greater than or equal to 20000, it jumps to the instruction at byte offset 9. If not, it continues to the next instruction.

If the integer is less than 20000:

iload_0 loads parameter again onto the stack.

ireturn returns the integer from the stack.

If the integer is greater than or equal to 20000:

iload_0 parameter is loaded again onto the stack.

iconst_2 pushes the constant integer 2 onto the stack.

imul multiplies the two topmost integers on the stack, leaving the result on the stack.

ireturn returns the resulting integer from the stack....