JB Header

How Java Code Runs: Bytecode, Class Loader and JIT

Introduction When you type java MyProgram, a lot happens before your first line of code runs - and while it runs. This tutorial gives an overview of how Java code runs: what bytecode is, how the class loader finds and loads classes, and how the JIT compiler makes Java programs fast. We will start with the journey of a program from source code to running code. We will then look inside a .class file at its bytecode. Next we will see the class loaders at work, and when exactly a class is loaded and initialized. Lastly, we will watch the JIT compiler speed up a method while a program runs.

(Note - This tutorial builds on JDK vs JRE vs JVMClick to read tutorial: JDK vs JRE vs JVM - Differences Explained with Examples, which introduces the parts of the JVM.) From source code to running code A Java program goes through these stages -
  1. Compile - javac checks the source code and translates each class into bytecode, saved in a .class file. Bytecode is a compact set of instructions for the JVM, not for any real processor.
  2. Load - when the program runs, the JVM's class loaders find the .class files the program needs and read them into memory, one class at a time, as they are needed.
  3. Link - the JVM verifies that the bytecode is valid and safe, prepares memory for the class's static variables, and resolves references to other classes.
  4. Initialize - the JVM runs the class's static initialization code, giving static variables their starting values.
  5. Execute - the interpreter starts running the bytecode instruction by instruction. Methods which run often are compiled to native machine code by the JIT compiler, and from then on run at full speed. Meanwhile the garbage collector frees the memory of objects which are no longer used.
Let us look at each of these in action, using the program below.
The program used in this tutorial
//Calculator.java
public class Calculator {
  static int sumUpTo(int n) {
    int sum = 0;
    for (int i = 1; i <= n; i++) {
      sum += i;
    }
    return sum;
  }

  public static void main(String[] args) {
    System.out.println("Sum of 1 to 100 = " + sumUpTo(100));
  }
}
 OUTPUT of the above code
Sum of 1 to 100 = 5050
Looking at the bytecode The JDK's javap tool disassembles a .class file. Let us look at the bytecode of Calculator, and in particular its sumUpTo() method -
javap -c Calculator
 OUTPUT of the above command
Compiled from "Calculator.java"
public class Calculator {
  public Calculator();
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return

  static int sumUpTo(int);
    Code:
         0: iconst_0
         1: istore_1
         2: iconst_1
         3: istore_2
         4: iload_2
         5: iload_0
         6: if_icmpgt     19
         9: iload_1
        10: iload_2
        11: iadd
        12: istore_1
        13: iinc          2, 1
        16: goto          4
        19: iload_1
        20: ireturn

  public static void main(java.lang.String[]);
    Code:
         0: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: bipush        100
         5: invokestatic  #13                 // Method sumUpTo:(I)I
         8: invokedynamic #19,  0             // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
        13: invokevirtual #23                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        16: return
}
Explanation of the bytecode
  • The JVM is a stack machine. Instructions push values onto an operand stack, and other instructions take them off to work with them. iconst_0 pushes the int value 0, and istore_1 stores it in local variable 1 (sum).
  • The for loop becomes a comparison and jumps - if_icmpgt 19 jumps out of the loop to instruction 19 when i is greater than n, and goto 4 jumps back to the start of the loop.
  • iadd adds two ints, iinc 2, 1 adds 1 to local variable 2 (i++), and ireturn returns an int from the method.
  • The same bytecode runs on Windows, macOS and Linux. It is the JVM on each machine which turns it into instructions for the real processor.
Class loaders Classes are loaded by class loaders. Java has three built-in class loaders, arranged in a hierarchy -
  • Bootstrap class loader: Built into the JVM. Loads the core classes of Java, such as java.lang.String and java.lang.System.
  • Platform class loader: Loads the other standard library modules, such as java.sql.
  • Application (system) class loader: Loads your own classes, and the libraries on the classpath.
When a class is needed, a class loader first asks its parent to load it, and only loads the class itself if the parent cannot. This parent delegation means the core Java classes are always loaded by the bootstrap loader, so a program cannot replace java.lang.String with its own version. Every class can tell us which class loader loaded it -
Finding the class loader of a class
//WhoLoadedMe.java
public class WhoLoadedMe {
  public static void main(String[] args) {
    System.out.println("WhoLoadedMe    : " + WhoLoadedMe.class.getClassLoader().getName());
    System.out.println("java.sql.Date  : " + java.sql.Date.class.getClassLoader().getName());
    System.out.println("java.lang.String: " + String.class.getClassLoader());
  }
}
 OUTPUT of the above code
WhoLoadedMe    : app
java.sql.Date  : platform
java.lang.String: null
Explanation of the code
  • WhoLoadedMe, our own class, was loaded by the app (application) class loader, and java.sql.Date by the platform class loader.
  • String's class loader is printed as null. The bootstrap class loader is part of the JVM itself, not a Java object, so Java represents it as null.
Classes are loaded only when needed The JVM does not load every class at startup. It loads and initializes a class the first time the program actually uses it. A static initializer block - code in static { ... } - runs when its class is initialized, so it shows us exactly when that happens.
Showing when a class is initialized
//LazyLoading.java
public class LazyLoading {
  static class Helper {
    static {
      System.out.println("  -> Helper class initialized");
    }
    static void help() {
      System.out.println("  -> Helper.help() called");
    }
  }

  public static void main(String[] args) {
    System.out.println("main() started");
    System.out.println("About to use Helper for the first time");
    Helper.help();
    System.out.println("Using Helper again");
    Helper.help();
  }
}
 OUTPUT of the above code
main() started
About to use Helper for the first time
  -> Helper class initialized
  -> Helper.help() called
Using Helper again
  -> Helper.help() called
The Helper class was initialized only when Helper.help() was first called - after main() had started - and only once. Loading classes on demand keeps startup fast, as a program only pays for the classes it uses. The interpreter and the JIT compiler The JVM starts by interpreting bytecode, which lets a program start running immediately. Interpreting is slower than running native code, however, so the JVM counts how often each method runs. When a method becomes hot, the Just-In-Time (JIT) compiler compiles it to optimized native machine code, while the program keeps running. HotSpot, the standard JVM, has two JIT compilers - a quick one (C1) used first, and a highly optimizing one (C2) for the hottest code.

The program below times the same work in ten rounds. Watch how much faster the later rounds are, once the JIT compiler has compiled sumUpTo().
Watching the JIT compiler speed up a method
//JitWarmup.java
public class JitWarmup {
  static long sumUpTo(int n) {
    long sum = 0;
    for (int i = 1; i <= n; i++) {
      sum += i % 7;
    }
    return sum;
  }

  public static void main(String[] args) {
    for (int round = 1; round <= 10; round++) {
      long start = System.nanoTime();
      long result = 0;
      for (int k = 0; k < 200; k++) {
        result += sumUpTo(10_000);
      }
      long micros = (System.nanoTime() - start) / 1_000;
      System.out.println("Round " + round + ": " + micros + " microseconds (result " + result + ")");
    }
  }
}
 OUTPUT of the above code (timings vary from run to run)
Round 1: 8668 microseconds (result 5999600)
Round 2: 1683 microseconds (result 5999600)
Round 3: 1651 microseconds (result 5999600)
Round 4: 1557 microseconds (result 5999600)
Round 5: 1555 microseconds (result 5999600)
Round 6: 1559 microseconds (result 5999600)
Round 7: 1557 microseconds (result 5999600)
Round 8: 1557 microseconds (result 5999600)
Round 9: 1557 microseconds (result 5999600)
Round 10: 1557 microseconds (result 5999600)
Explanation of the code
  • Each round calls sumUpTo(10_000) 200 times and measures the time taken with System.nanoTime().
  • The first round runs mostly in the interpreter, so it is the slowest. As sumUpTo() gets hot, the JIT compiler compiles it, first with C1 and then with C2, and later rounds run many times faster.
  • This is why Java programs "warm up", and why you should never judge Java's performance by timing code just once.
Summary In this tutorial we first followed a Java program through its stages - compile, load, link, initialize and execute. We then used javap to look at bytecode, and saw that the JVM is a stack machine. Next we met the bootstrap, platform and application class loaders and the parent delegation model, and saw that classes are loaded and initialized only when first used. Lastly, we saw how the interpreter and the JIT compiler work together, and watched a method get much faster as the JIT compiled it. In the next tutorial, we will try out Java code interactively with JShellClick to read tutorial: JShell Tutorial: Java's Interactive REPL with Examples.