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.
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.
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.
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).
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.
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.
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
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 addUnused u = null;tomain: doesUnusedinitialise? (No: declaring a variable of a type is not an active use.) - 2
Watch the JIT work
Save the hot-loop example locally and run it with
-XX:+PrintCompilation, filtering for your class. FindMain::squareat tier 3 and tier 4, and the%OSR line formain.terminal$ java -XX:+PrintCompilation Main.java | grep Main::On Windows PowerShell use: java -XX:+PrintCompilation Main.java | Select-String Main:: - 3
Count the classes
Run
java -Xlog:class+load Mainand count the lines (| wc -lon macOS and Linux). Then run it again with-Xshare:off(no CDS archive) and compare how thesource:column changes fromshared objects filetojrt:/java.base.
Code & diagrams
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.
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 touchedMAX 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.
Expected output
MAX = 10
Made an array with 3 empty Animal slots
(Limits initialised)
BOXED = 20
(Animal initialised)
Created one AnimalA 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.
Expected output
First.count = 0
Second.count = 1square is called a million times, so the JIT compiles it. Run it locally with -Xint and with -XX:+PrintCompilation: the output line never changes.
Expected output
Total: 333332833333500000Real 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: nullBreak 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.
Break #2
Throw an exception from a static initialiser
Make loadMode() in the first example do throw new IllegalStateException("no config"); instead of returning.
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/invokestaticon 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 apublic static finalconstant 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
PrintCompilationflags:%OSR,ssynchronized,!has exception handlers,nnative wrapper;made not entrantmeans 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
Step 1, compile.
javacchecks your source and writes one.classfile 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 asjava/io/PrintStream.println. References between classes are symbolic (by name), not memory addresses; the JVM connects them later. - 2
Step 2, start the JVM and load classes.
java Mainstarts the JVM, which sets up memory (the heap for objects, a stack for each thread, metaspace for class information) and asks a class loader forMain. 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
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 asjava.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 replacejava.lang.Stringwith their own version. - 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
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 likestatic final int MAX = 10does not trigger it, becausejavaccopied the value 10 into the caller. - 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
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
Describe what happens, step by step, from typing java Main to the first line of output.
What are the three built-in class loaders, and what is parent delegation for?
When exactly does a class's static initialiser run? Give two things that do not trigger it.
What is tiered compilation, and what are C1 and C2?
What is deoptimisation, and why does the JVM need it?
Practice
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.
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?
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.javato 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.