An
This is the engine behind Java's HotSpot, JavaScript's V8, .NET's CLR, LuaJIT and PyPy. It is why a dynamically-typed language, seemingly doomed to slow interpretation, can approach the speed of C on hot code. The whole design is a bet: spend compilation effort only where it pays off, speculate on what you have observed, and keep an escape hatch for when the speculation turns out wrong.
Compiling costs time now to save time later — so it only pays for code that runs many times. A JIT therefore begins every method in a cheap bytecode interpreter and attaches counters: increment on each method entry and each loop back-edge. When a counter crosses a threshold, that method or loop is declared hot and handed to the compiler. The 90/10 rule — most time in a small fraction of the code — is what makes this profitable: you interpret the cold 90% of methods essentially for free and lavish optimisation on the hot 10%.
A single "interpret vs compile" switch is too blunt. Modern engines use tiered compilation: several execution modes of increasing quality and increasing compile cost, and code climbs the ladder as it proves itself hot. HotSpot and V8 both do this.
The ladder balances two opposing costs. Jumping straight to the optimising tier would stall startup on code that turns out cold; staying in the interpreter forever would leave peak performance on the table. Tiering gives fast startup and high peak throughput by moving each piece of code only as far up the ladder as its hotness justifies.
The optimising tier's power comes from profile-guided speculation: it compiles code
that is correct only if an assumption observed so far continues to hold. "This variable has
always been an integer, so compile integer arithmetic." "This call site has only ever targeted
Cat.speak, so inline it." These assumptions let it generate code as tight as a statically
typed language's — but they might be wrong on a future iteration.
So every speculation is protected by a cheap guard: a quick check (is it still an int?
is the receiver still a Cat?). If a guard fails, the engine performs
deoptimisation — it discards the optimised code and reconstructs the exact interpreter
state (all the locals and the operand stack) at that program point, then resumes in the interpreter as
if the fast code had never run. The dual manoeuvre is on-stack replacement (OSR):
swapping a running activation from one tier to another mid-execution — crucial for a long-running
loop that becomes hot while it is still spinning, since you cannot wait for it to return to upgrade it.
In a dynamic language, x.foo() must look up which foo to call based on
x's runtime type — a hash-map lookup on every call, murderous in a loop. The classic cure
is the inline cache (IC): cache the result of the lookup at the call site. The
first time, do the full lookup and remember "type
Inline caches are also a profile source: a call site that stayed monomorphic is a golden signal to the optimising tier that it can safely inline that one target under a type guard.
Two philosophies for choosing the unit of compilation:
| AOT compiler | JIT compiler | |
|---|---|---|
| When it compiles | before running, once | during execution, on demand |
| Startup latency | none (already native) | warm-up cost (interpret first) |
| Profile knowledge | none (static guesses / PGO from a prior run) | this run's live profile |
| Speculation | must be conservative | aggressive, guarded, deoptimisable |
| Compile time counts as | build time (free at runtime) | runtime (competes with the program) |
| Peak on hot code | good, fixed | can exceed AOT (specialised to reality) |
| Memory / complexity | lower runtime footprint | compiler + profiles resident in-process |
A toy tier controller: run methods through counters and promote them across tiers as they heat up. It models the essential policy — cheap first, expensive only when earned.
hot climbs to the optimising tier once its counter passes 100; cold never
leaves the interpreter — precisely the effort-where-it-pays discipline every real JIT enforces.
HotSpot (Java) pioneered the tiered method-JIT with two optimising compilers, C1 (baseline) and C2 (aggressive), plus a modern rewrite, Graal. V8 (Chrome/Node) runs the Ignition interpreter, a fast baseline compiler (Sparkplug), and the optimising TurboFan, deoptimising on "hidden class" (shape) guard failures — its inline caches key on object shapes. PyPy takes the trace-JIT road with a twist: it is a meta-tracing JIT that traces the interpreter loop, so you get a JIT for a language almost for free by writing its interpreter in RPython. Three different bets — method vs trace, one optimiser vs several — all converging on the same core idea: interpret, profile, speculate, guard, deoptimise.
The cardinal sin of JIT design is to speculate without a guard or without a working
deoptimisation path. If you compile "assume x is an int" and never check, then the day
x arrives as a string you don't get a slowdown — you get a wrong answer or a crash.
Every assumption must have (a) a cheap runtime guard, and (b) a correct deopt that rebuilds the exact
interpreter state so execution can resume as though the optimised code never existed. The other trap is
forgetting that JITs pay a warm-up cost: short-lived programs and benchmarks that don't
run long enough may finish before the optimising tier ever kicks in, so a JIT can look slower
than an interpreter on a cold, brief workload. Peak throughput and startup latency are in genuine
tension — that tension is the whole reason tiering exists.