JIT behaviour explains why Java gets faster after startup, why naive benchmarks lie, and which code shapes run fast.
HotSpot starts by interpreting bytecode while collecting a profile: how often each method and loop runs, which branches are taken, which concrete types appear at each call site. Hot code is then compiled to native machine code by the JIT (just-in-time compiler). With tiered compilation (the default):
- C1 compiles quickly with light optimisation, so code speeds up soon after startup.
- C2 later recompiles the hottest methods with aggressive, profile-guided optimisation.
Key optimisations:
- Inlining — copying a callee's body into the caller. It is the gateway to every other optimisation. Small methods and getters cost nothing once inlined, so write clear small methods.
- Devirtualization — if a call site has only ever seen one or two receiver types (monomorphic or bimorphic), C2 inlines them behind a cheap type check. Megamorphic sites (many types) stay as slower virtual calls.
- Escape analysis — if an object never escapes a method, C2 can eliminate its allocation and keep fields in registers.
- Loop optimisations: range-check elimination, unrolling and auto-vectorisation.
These optimisations are speculative. If an assumption breaks — a new subclass gets loaded, or a never-taken branch is taken — the JVM deoptimizes: it discards the compiled code, falls back to the interpreter and recompiles later.
Consequences: a JVM application needs warm-up before reaching peak speed, so measure after warm-up and use JMH for microbenchmarks, which also defeats dead-code elimination. -XX:+PrintCompilation shows methods as they are compiled, and JFR records compilation and deoptimization events. For startup-sensitive workloads, CDS archives, Project Leyden work and GraalVM native image move work ahead of time.
Example
// Run with: java -XX:+PrintCompilation Hot.java | grep -E "Hot::|made not entrant"
public class Hot {
interface Shape { double area(); }
record Sq(double s) implements Shape { public double area() { return s * s; } }
record Circ(double r) implements Shape { public double area() { return Math.PI * r * r; } }
static double total(Shape[] shapes) {
double sum = 0;
for (Shape s : shapes) sum += s.area(); // call site profiled for receiver types
return sum;
}
public static void main(String[] args) {
Shape[] squares = new Shape[1_000];
for (int i = 0; i < squares.length; i++) squares[i] = new Sq(i % 10);
for (int round = 0; round < 20_000; round++) total(squares); // monomorphic: inlined by C2
Shape[] mixed = {new Sq(1), new Circ(1)};
// A new receiver type at a speculated call site can trigger deoptimization
// ("made not entrant") followed by recompilation as a bimorphic site.
for (int round = 0; round < 20_000; round++) total(mixed);
System.out.println(total(mixed)); // 4.141592653589793
}
}Remember
- Code runs interpreted first; hot methods are compiled by C1, then C2 using profiles
- Inlining enables everything else; small methods are effectively free
- Speculative optimisations can be undone (deoptimization) — always benchmark after warm-up
Watch out for
- Timing a loop once with
System.nanoTime()and drawing conclusions — you measure the interpreter and the JIT, not your code.