Command Palette

Search for a command to run...

PHASE 0Beginner ~33 min· topic 8 of 8

Topic 0.8

How Your Code Really Runs: Compile, Load, Interpret, JIT

In one line

javac turns source into bytecode; the java launcher starts a JVM that loads classes only when first needed, verifies and links them, runs their static initialisation, then interprets the bytecode while counting what runs most. Hot methods are compiled to optimised machine code by the JIT compilers (C1, then C2), and the JVM can throw that code away and fall back if its guesses turn out wrong.

Think of it like this

A tour guide translating for visitors. At first the guide translates every sentence live as it is spoken: slow, but it starts at once (the interpreter). The guide notices that some phrases come up again and again, so writes good translations of those on cards to read out instantly (the JIT compiler compiling hot code). Sometimes a card was written for a situation that changes ("the museum is open"), so the guide tears it up and goes back to translating live (deoptimisation).

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Class loader
The part of the JVM that finds a class's bytes (in a file, a jar or the JDK) and turns them into a class the JVM can use.
Lazy loading
Loading a class only when the program first needs it, not at start-up.
Verification
The JVM's safety check on bytecode before it runs: correct types, no stack overflow, valid jumps.
Static initialisation
Running a class's static field assignments and static { } blocks, once, the first time the class is actively used.
Interpreter
The part of the JVM that runs bytecode one instruction at a time. It starts instantly but is slow.
JIT compiler
Just-In-Time compiler: turns frequently run bytecode into machine code while the program runs. HotSpot has two, C1 and C2.
Hot code
Methods or loops that run very often. The JVM finds them by counting, and compiles them first.
Deoptimisation
Throwing away compiled code whose assumptions became false, and continuing in the interpreter.
Warm-up
The first period after start, while code is still interpreted or lightly compiled, so the program is slower.
Code cache
The memory area where the JVM keeps machine code produced by the JIT compilers.

Step by step

01The whole journey on one page

From typing javac to seeing output, your code passes through two programs and five stages. The diagram is the map for this topic; each step below zooms into one box.

The whole journey on one pagediagram
Rendering diagram…

02Inside the class file

javap -c shows the bytecode. In main, iconst_2 and istore_1 put 2 into local variable slot 1 (a); invokestatic #7 calls add; getstatic #13 fetches System.out; invokevirtual #23 calls println. The #7, #13, #23 are indexes into the constant pool, where the names are stored as text.

Nothing in the file is a memory address. java/io/PrintStream.println is just a name until the JVM resolves it at run time. That is what lets a class compiled years ago run against a newer JDK.

terminal
$ javac Main.java
javap -c Main
── expected output ──
public static void main(java.lang.String[]);
Code:
0: iconst_2
1: istore_1
2: iconst_3
3: istore_2
4: iload_1
5: iload_2
6: invokestatic #7 // Method add:(II)I
9: istore_3
10: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokedynamic #19, 0 // InvokeDynamic #0:makeConcatWithConstants:(I)Ljava/lang/String;
19: invokevirtual #23 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
22: return

03Watching classes load

-Xlog:class+load makes the JVM print each class as it loads. Hello World loads around 450 to 600 classes on JDK 21 (the exact number depends on the version and platform). Most say source: shared objects file: they come from the CDS archive, a pre-parsed snapshot of common JDK classes mapped straight into memory, which saves a lot of start-up time.

Your own class appears near the end with source: file:/...: the application class loader read it from the class path.

terminal
$ java -Xlog:class+load Main
── expected output ──
[0.030s][info][class,load] java.lang.Object source: shared objects file
[0.030s][info][class,load] java.io.Serializable source: shared objects file
[0.030s][info][class,load] java.lang.Comparable source: shared objects file
...
[0.180s][info][class,load] Main source: file:/C:/work/java-course/
...

04Three class loaders and parent delegation

When the application loader is asked for a class, it first asks its parent (platform), which first asks its parent (bootstrap). Only if the parents can't find it does the child load it. So java.lang.String always comes from the bootstrap loader, whatever is on your class path.

A class's identity is its name plus its loader. Application servers and plugin systems use separate loaders to keep apps apart, which is also the source of famous puzzles like ClassCastException: Foo cannot be cast to Foo (Topic 14.1).

Three class loaders and parent delegationdiagram
Rendering diagram…

05Linking: verify, prepare, resolve

Verify: since Java 7, class files carry a StackMapTable that describes the types on the stack at branch points, so the verifier can check a method in a single pass. A class that fails gets VerifyError and never runs.

Prepare: static fields are created with default values. This leads to a famous puzzle, shown in the runnable examples: a static field's initial value can be overwritten by an initialiser that runs after a constructor already changed it. Resolve: the first time an instruction like invokevirtual #23 runs, the JVM looks up the name, checks access, and caches the direct reference so later runs are fast.

06Initialisation runs once, on first active use

Static initialisation is lazy and happens exactly once. Creating an object, calling a static method, or reading or writing a non-constant static field triggers it. Creating an array of a class does not, and neither does reading a compile-time constant (a static final primitive or String set to a constant expression), because javac copied that value into the calling code.

The JVM holds an initialisation lock, so if two threads use the class at the same moment, one runs the initialiser and the other waits. Java's "holder class" pattern for lazy singletons relies on exactly this (see the System Design course's Singleton pattern).

07Interpreter first, then the JIT tiers

HotSpot's tiered compilation uses five levels. 0: interpreter, with counters. 1: C1 code without profiling, for trivial methods like getters. 2: C1 with light profiling. 3: C1 with full profiling (the usual first step). 4: C2, fully optimised, using the profile. On JDK 21 the defaults move a method to level 3 after about 200 calls and to level 4 after about 5,000 (more or less, depending on loop counts and how busy the compiler threads are).

-XX:+PrintCompilation shows it happening. Below, square reaches tier 3, then tier 4, and the tier-3 version is then "made not entrant" (retired). % marks OSR: main has one long loop, so the JVM compiles it and jumps into the compiled loop at bytecode 4 without waiting for main to be called again.

terminal
$ java -XX:+PrintCompilation Main
── expected output ──
81 65 3 Main::square (4 bytes)
81 66 4 Main::square (4 bytes)
82 65 3 Main::square (4 bytes) made not entrant
83 67 % 3 Main::main @ 4 (37 bytes)
84 68 3 Main::main (37 bytes)
84 69 % 4 Main::main @ 4 (37 bytes)
Total: 333332833333500000
Columns: milliseconds since start, compile id, flags (% = OSR), tier, method, bytecode size. Lines for JDK classes are left out.

08How much the JIT matters

Run the same hot loop three ways. -Xint forces the interpreter only. -XX:TieredStopAtLevel=1 stops at C1. The default goes all the way to C2. The answer is identical every time, because compiled code must behave exactly like the bytecode. Only the speed changes.

On one laptop, a 200-million-iteration loop took about 5,700 ms interpreted, 390 ms with C1 only, and 260 ms with C2. Your numbers will differ, but the pattern of roughly 20 times between interpreter and JIT is typical.

terminal
$ java -Xint Main
java -XX:TieredStopAtLevel=1 Main
java Main
── expected output ──
Total: 66566700000000 in 5694 ms
Total: 66566700000000 in 391 ms
Total: 66566700000000 in 260 ms

09What C2 does with the profile

Inlining: copy a small method's body into its caller, removing the call. This is the most important optimisation, because it exposes everything else. Escape analysis: if an object never leaves a method, C2 can skip allocating it and keep its fields in CPU registers. Loop optimisations: unrolling, removing array bounds checks it can prove unnecessary. Intrinsics: hand-written machine code for methods like Math.sqrt or System.arraycopy.

Speculation: if a call site has only ever seen one class, C2 calls it directly and inlines it, protected by a cheap check. If the check fails, the method is deoptimised and recompiled later with the new information. Compiled code lives in the code cache; if that fills up, the JIT stops compiling and performance quietly drops (Topic 14.4).

Try it yourself

  1. 1

    Predict the initialisation order

    In the first example, move line 3 (Mode is) above line 2. Predict where (Config is being initialised) will now appear, then run it. Then add Unused u = null; to main: does Unused initialise? (No: declaring a variable of a type is not an active use.)

  2. 2

    Watch the JIT work

    Save the hot-loop example locally and run it with -XX:+PrintCompilation, filtering for your class. Find Main::square at tier 3 and tier 4, and the % OSR line for main.

    terminal
    $ java -XX:+PrintCompilation Main.java | grep Main::
    On Windows PowerShell use: java -XX:+PrintCompilation Main.java | Select-String Main::
  3. 3

    Count the classes

    Run java -Xlog:class+load Main and count the lines (| wc -l on macOS and Linux). Then run it again with -Xshare:off (no CDS archive) and compare how the source: column changes from shared objects file to jrt:/java.base.

Code & diagrams

Classes initialise lazily, on first use New tab

Config initialises in the middle of line 3, when its static field is first read. Unused is never touched, so its static block never runs.

Sign in to run this example in your browser.

Expected output

1. main has started
2. About to read Config.mode
  (Config is being initialised)
3. Mode is fast
4. Mode again: fast
5. Unused was never touched
What does and doesn't trigger initialisation New tab

MAX is a compile-time constant, so javac copies 10 into Main and Limits stays uninitialised. An array of Animal doesn't create any Animal. BOXED and new Animal() do trigger initialisation.

Sign in to run this example in your browser.

Expected output

MAX = 10
Made an array with 3 empty Animal slots
  (Limits initialised)
BOXED = 20
  (Animal initialised)
Created one Animal
Preparation, then initialisation in textual order New tab

A classic interview puzzle. Preparation sets count to 0. In First, the constructor runs first (count becomes 1), then the line `count = 0` resets it. In Second the order is swapped.

Sign in to run this example in your browser.

Expected output

First.count = 0
Second.count = 1
A hot loop: same answer, interpreted or compiled New tab

square is called a million times, so the JIT compiles it. Run it locally with -Xint and with -XX:+PrintCompilation: the output line never changes.

Sign in to run this example in your browser.

Expected output

Total: 333332833333500000
Who loaded what (JDK 17 and later)java

Real output on a standard JDK. The bootstrap loader is written in C++ inside the JVM and has no Java object, so it appears as null.

public class Main {
    public static void main(String[] args) {
        ClassLoader app = Main.class.getClassLoader();
        System.out.println("Main loaded by: " + app.getName());
        System.out.println("Its parent: " + app.getParent().getName());
        System.out.println("java.sql.Date loaded by: " + java.sql.Date.class.getClassLoader().getName());
        System.out.println("String loaded by: " + String.class.getClassLoader());
    }
}
// Main loaded by: app
// Its parent: platform
// java.sql.Date loaded by: platform
// String loaded by: null

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Delete a class file the program needs later

Compile the first example with javac Main.java, delete Config.class, then run java Main.

terminal
$ java Main
── what you'll see ──
1. main has started
2. About to read Config.mode
Exception in thread "main" java.lang.NoClassDefFoundError: Config
at Main.main(Main.java:20)
Caused by: java.lang.ClassNotFoundException: Config
...

Break #2

Throw an exception from a static initialiser

Make loadMode() in the first example do throw new IllegalStateException("no config"); instead of returning.

terminal
$ java Main.java
── what you'll see ──
1. main has started
2. About to read Config.mode
(Config is being initialised)
Exception in thread "main" java.lang.ExceptionInInitializerError
at Main.main(Main.java:20)
Caused by: java.lang.IllegalStateException: no config
at Config.loadMode(Main.java:6)
...

Myth vs fact

Myth

Java is interpreted, so it's slow.

Fact

Bytecode is interpreted only until it gets hot. Hot code runs as optimised machine code, often using run-time information a C compiler never has. Java's real costs are start-up and warm-up time, and memory.

Myth

All classes are loaded when the program starts.

Fact

Classes load lazily, on first need. A class that is never used is never loaded or initialised, which the examples above show.

Myth

Once a method is compiled to machine code, it stays compiled.

Fact

Compiled code can be thrown away (deoptimised) when its assumptions break, when a better version is ready, or when the code cache is cleaned. A method can be compiled several times in one run.

Myth

Timing a loop once tells you how fast Java code is.

Fact

The first runs are interpreted or lightly compiled, and the JIT may even remove work whose result is unused. Reliable measurements need warm-up and a harness like JMH (Topic 14.4).

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Initialisation triggers are listed in JVMS §5.5: new, getstatic/putstatic/invokestatic on non-constant members, some reflection calls, initialising a subclass (superclasses first; superinterfaces only if they declare default methods), and the main class at start-up. Constant variables (JLS §4.12.4) are inlined at compile time, which is also why changing a public static final constant in a library requires recompiling its users.

  • ▸

    Tiered thresholds on JDK 21 (java -XX:+PrintFlagsFinal -version): Tier3InvocationThreshold=200, Tier3CompileThreshold=2000, Tier4InvocationThreshold=5000, Tier4CompileThreshold=15000. The real policy also scales them with the length of the compile queues. -XX:TieredStopAtLevel=1 (C1 only) is a common trick for faster start-up in dev tools and short-lived CLIs.

  • ▸

    Reading the PrintCompilation flags: % OSR, s synchronized, ! has exception handlers, n native wrapper; made not entrant means new calls can't enter that version (replaced or deoptimised), made zombie (older JDKs) that it can be freed. For details use -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining, or JFR's compilation events (Topic 14.5).

  • ▸

    Start-up work is moving earlier: AppCDS (-XX:ArchiveClassesAtExit) archives your own classes too; JDK 24 added the AOT cache (JEP 483) that stores loaded and linked classes from a training run, and JDK 25 added simpler AOT command-line options (JEP 514) and AOT method profiles (JEP 515) so the JIT warms up faster. GraalVM Native Image goes further and compiles everything ahead of time, trading peak optimisation and dynamic features for instant start.

Remember this

  1. 1

    Step 1, compile. javac checks your source and writes one .class file per class. A class file contains the bytecode for each method plus a constant pool: a table of names and values the bytecode refers to, such as java/io/PrintStream.println. References between classes are symbolic (by name), not memory addresses; the JVM connects them later.

  2. 2

    Step 2, start the JVM and load classes. java Main starts the JVM, which sets up memory (the heap for objects, a stack for each thread, metaspace for class information) and asks a class loader for Main. Classes are loaded lazily: only when the program first needs them. Even Hello World loads several hundred JDK classes, mostly from a pre-built CDS archive (class data sharing) so start-up stays fast.

  3. 3

    Java has three built-in class loaders. The bootstrap loader (inside the JVM) loads core classes like java.lang.String. The platform loader loads the rest of the Java SE library, such as java.sql. The application loader loads your classes from the class path. Each loader first asks its parent before loading a class itself (parent delegation), so nobody can replace java.lang.String with their own version.

  4. 4

    Step 3, link. Verification checks the bytecode is safe: types match, the operand stack never overflows, jumps land on real instructions. This is why a damaged or malicious class file can't crash the JVM. Preparation creates the class's static fields and sets them to default values (0, false, null). Resolution replaces symbolic names with direct references, usually lazily, the first time each one is used.

  5. 5

    Step 4, initialise. The first time a class is actively used (an object is created, a static method is called, or a non-constant static field is read), the JVM runs its static initialisation: static field assignments and static { } blocks, top to bottom, exactly once, and safely even if several threads race to it. Reading a compile-time constant like static final int MAX = 10 does not trigger it, because javac copied the value 10 into the caller.

  6. 6

    Step 5, execute: interpreter, then JIT. HotSpot starts by interpreting bytecode, one instruction at a time, while counting method calls and loop iterations. Methods that get hot are compiled by C1 (a fast compiler that adds profiling), and the hottest by C2 (a slower, heavily optimising compiler) using the profile: which branches are taken, which classes actually appear. This is tiered compilation. A long-running loop can even be swapped to compiled code mid-loop (on-stack replacement, OSR).

  7. 7

    C2 makes speculative optimisations: "only one class has ever been seen here, so call it directly and inline it". If a different class shows up later, a guard fails, the JVM deoptimises (drops back to the interpreter for that method) and may recompile. This is why Java programs have a warm-up period, why measuring speed needs care (use JMH, Topic 14.4), and why long-running Java can be as fast as C++.

Explain it without notes

01

Describe what happens, step by step, from typing java Main to the first line of output.

02

What are the three built-in class loaders, and what is parent delegation for?

03

When exactly does a class's static initialiser run? Give two things that do not trigger it.

04

What is tiered compilation, and what are C1 and C2?

05

What is deoptimisation, and why does the JVM need it?

Practice

01

Write a program with a class Logger whose static block prints Logger ready, and a static method log(String). Call log twice from main and check Logger ready prints only once.

02

Predict, then verify: a class Holder has static final String NAME = "box"; and a static block printing Holder init. main prints Holder.NAME. What is printed?

03

Write a program that sums the cubes of 1 to 1,000 with a helper method cube(long n), and prints the total. Then explain why the result is the same with -Xint.

Trade-offs

  • ↔

    Interpret-then-JIT gives fast start and high peak speed, but costs warm-up time and CPU for compiling. Ahead-of-time compilation (GraalVM Native Image) starts instantly with low memory, but loses profile-guided optimisation and restricts reflection and dynamic class loading.

  • ↔

    Lazy class loading keeps start-up fast and memory low, but moves failures (a missing class, a failing static initialiser) to the moment of first use, which may be hours into a run.

  • ↔

    Speculative optimisation makes common cases very fast, but a program whose behaviour changes (new classes appear, a branch starts being taken) pays for deoptimisation and recompilation.

Done when you can

  • Done when you can draw the path from Main.java to machine code: compile, load, link, initialise, interpret, JIT.

  • Done when you can name the three class loaders and explain parent delegation.

  • Done when you can say when static initialisation runs, and predict the output of the initialisation examples.

  • Done when you can explain tiered compilation, C1, C2, OSR and deoptimisation.

  • Done when you can explain why Java has warm-up and why results never depend on the JIT.