The JIT Compiler
The JIT Compiler
The JVM starts by interpreting bytecode — executing one instruction at a time without compiling the whole program. This is flexible and fast to start, but it is significantly slower than natively compiled code for long-running workloads. The Just-In-Time (JIT) compiler bridges this gap: it observes which methods are executed most, compiles them to optimised native machine code on the fly, and replaces the interpreter path with the compiled version. Understanding how the JIT works lets you write code that the JIT can optimise aggressively and lets you diagnose the rare cases where JIT behaviour surprises you.
Interpretation vs. Compilation
When the JVM loads a class, methods start life as bytecode. The interpreter processes each bytecode opcode one by one. This is cheap to start (no compilation latency, small footprint) but CPU-intensive for hot paths because the interpretation overhead is paid on every single call.
The JIT compiler (in HotSpot: C1 and C2) watches method invocation and back-edge (loop iteration) counts. Once a counter passes a threshold it triggers compilation:
- C1 (client compiler) — fast, lightly optimised compilation. Used first (tiered compilation tier 1–3) to replace the interpreter quickly with code that still collects profiling data.
- C2 (server compiler) — slow, heavily optimised compilation. Fires at tier 4 for the hottest methods. Produces code that can rival hand-written C++.
Hot Spots — How the JVM Finds Them
The JVM uses two independent counters per method:
- Invocation counter — incremented every time the method is called.
- Back-edge counter — incremented on every loop iteration within the method (enables On-Stack Replacement).
When either counter crosses the -XX:CompileThreshold (default 10,000 for server JVM), the method is enqueued for JIT compilation. The counters decay over time so a method that was once hot and then goes idle can fall out of compiled form.
On-Stack Replacement (OSR) is the JIT mechanism that replaces a method while it is still executing. This matters for long loops: the JVM can compile and switch to the native version of a loop body mid-execution, without waiting for the next call.
Inlining — The Most Important JIT Optimisation
Method inlining replaces a call site with the body of the called method. It eliminates the call overhead (stack frame creation, argument passing, return), but its real power is that it exposes the inlined code to the surrounding context, enabling further optimisations like constant folding, dead code elimination, and loop unrolling that would otherwise be impossible across method boundaries.
The JIT inlines based on heuristics around bytecode size and call frequency. Key limits in HotSpot:
-XX:MaxInlineSize=35— methods at or below 35 bytecodes are inlined almost unconditionally if called frequently.-XX:FreqInlineSize=325— very hot methods up to 325 bytecodes may also be inlined.-XX:MaxRecursiveInlineLevel— controls recursive inlining depth.
Devirtualisation and Speculative Optimisations
Virtual dispatch (calling an interface or overridden method) prevents naive inlining because the JIT does not know at compile time which concrete class will be used. The JIT uses the profiling data collected during interpretation to make speculative assumptions:
- Monomorphic call site — only one concrete type observed. The JIT inlines that type's implementation and adds a guard. If the guard fails (a different type arrives) it falls back to the slow path.
- Bimorphic call site — two concrete types observed. The JIT emits an if/else over the two inlined bodies.
- Megamorphic call site — three or more types observed. The JIT gives up on inlining and uses a virtual dispatch table. This is a significant performance cliff.
Escape Analysis and Scalar Replacement
The JIT performs escape analysis to determine whether an object's reference can ever leave the current method or thread. If an object does not escape, the JIT can:
- Stack-allocate it — avoiding heap allocation and GC pressure entirely.
- Scalar replace it — decompose the object into its primitive fields and hold them in CPU registers, eliminating object overhead completely.
Observing JIT Behaviour
You can observe what the JIT is doing without a profiler:
Practical Takeaways
- Keep methods small to stay within inlining thresholds — good OOP design and JIT-friendliness align naturally.
- Prefer monomorphic or bimorphic call sites in hot loops; avoid storing many different concrete types behind the same interface variable inside a tight loop.
- Let the JVM warm up before measuring performance. Use JMH for any serious benchmarking.
- Trust the JIT first; reach for manual micro-optimisations (e.g. avoiding autoboxing) only after profiling confirms a real bottleneck.
- Use
-XX:+PrintCompilationduring testing to verify that your critical paths are being compiled at tier 4.
In the next lesson we will look at how to measure and benchmark performance correctly so that JIT warm-up, dead-code elimination, and other JIT effects do not produce misleading results.