Just-in-Time Compilation

An ahead-of-time compiler translates the whole program once, before it runs, knowing nothing about the inputs it will see. A just-in-time (JIT) compiler does the opposite: it starts by interpreting, and compiles code to native machine instructions while the program is already running — armed with something an AOT compiler can only dream of: live profile data about what this particular execution actually does. Which branch is taken 99% of the time? What concrete type flows through this polymorphic call? Which loop is hot? The JIT sees it happen and compiles for the observed reality, not the worst case.

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.

Hot-path detection: don't compile what you won't rerun

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%.

Tiered compilation: a ladder of effort

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.

Speculation, deoptimisation and OSR

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.

Inline caches: making dynamic dispatch cheap

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 T → method m" right there in the code. Next time, if the receiver is still T (a one-word guard), jump straight to m.

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.

Method JITs vs trace JITs

Two philosophies for choosing the unit of compilation:

AOT compilerJIT compiler
When it compilesbefore running, onceduring execution, on demand
Startup latencynone (already native)warm-up cost (interpret first)
Profile knowledgenone (static guesses / PGO from a prior run)this run's live profile
Speculationmust be conservativeaggressive, guarded, deoptimisable
Compile time counts asbuild time (free at runtime)runtime (competes with the program)
Peak on hot codegood, fixedcan exceed AOT (specialised to reality)
Memory / complexitylower runtime footprintcompiler + profiles resident in-process

Hotness and tiering, in code

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.

const TIERS = ["interpreter", "baseline", "optimising"] as const; type Tier = typeof TIERS[number]; class TierController { private count = new Map<string, number>(); private tier = new Map<string, number>(); // index into TIERS // thresholds to CLIMB to baseline (1) and to optimising (2) constructor(private readonly promote = [10, 100]) {} onCall(method: string): Tier { const n = (this.count.get(method) ?? 0) + 1; this.count.set(method, n); let t = this.tier.get(method) ?? 0; while (t < 2 && n >= this.promote[t]) { t++; console.log(` ${method} promoted to ${TIERS[t]} at call #${n}`); } this.tier.set(method, t); return TIERS[t]; } } const jit = new TierController(); // "hot" is called in a tight loop; "cold" only twice. for (let i = 0; i < 120; i++) jit.onCall("hot"); jit.onCall("cold"); jit.onCall("cold"); console.log("hot finishes in : " + jit.onCall("hot")); console.log("cold finishes in : " + jit.onCall("cold"));

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.