Just-In-Time Compilation
Consider a Python or JavaScript program running inside a browser. It starts as bytecode fed to an interpreter. Yet within milliseconds the hottest loops execute at speeds close to hand-written C. No human recompiled anything. The runtime itself watched the program run. It noticed which functions dominate execution time. It translated exactly those functions into machine code. Meanwhile, the rest of the program stayed interpreted. This is Just-In-Time (JIT) compilation. It is the single biggest reason dynamic languages became viable for production systems. Examples include V8 powering Chrome and Node.js. Another is the HotSpot JVM running most enterprise backends. The core tension JIT compilation resolves is between compile time and run time. A pure interpreter pays no upfront compilation cost. But it re-decodes and re-dispatches every bytecode instruction on every execution. This is brutally slow for loops executed millions of times. A pure ahead-of-time (AOT) compiler pays compilation cost once. But it must optimize blind, without knowing which branches are hot. It also lacks knowledge of what types actually flow through a polymorphic call site. Nor does it know which loops run once versus a billion times. JIT compilation sidesteps this by deferring optimization until the program reveals its own behavior. It then compiles only the parts worth compiling. It uses exactly the information observed. This matters far beyond academic curiosity. Modern software increasingly targets languages with late-bound types, dynamic dispatch, and reflection. Examples include Python, JavaScript, Ruby, Java, C#, and .NET's CLR. None of these are amenable to naive static compilation. Doing so would mean giving up flexibility or leaving enormous performance on the table. JIT compilation lets these languages have both. They gain interpretive flexibility during cold, rarely-run code. They also gain near-native speed for the 10% of code that runs 90% of the time. This is a rule of thumb that consistently shows up in profiling real workloads. This article builds the mechanism from the ground up. It covers how a runtime detects "hot" code via profiling counters. It explains how compilation is organized into tiers of increasing optimization effort. It shows how speculative optimizations let the compiler assume facts it cannot statically prove. These include inlining, type specialization, and guard-based deoptimization. It also covers what happens when those assumptions fail. We connect this to prerequisites: bytecode interpretation, SSA form, and loop optimizations. We show precisely where each is used inside a real tiered JIT pipeline.
- JIT compilation defers optimization to run time, using observed program behavior instead of static guesses.
- Runtimes profile bytecode execution with counters to find "hot" methods and loops worth compiling.
- Tiered compilation trades compile speed for code quality across multiple tiers (interpreter → baseline JIT → optimizing JIT).
- Speculative optimizations (inlining, type specialization) assume common-case facts and insert cheap guards to check them.
- On-Stack Replacement (OSR) lets a running interpreted loop be swapped mid-execution for compiled code.
- Deoptimization safely falls back to the interpreter when a speculative assumption is violated.
A JIT compiler trades a small, adaptive compilation cost at run time for machine-code speed on exactly the code paths that profiling proves are hot, using speculative assumptions it can safely undo.
The idea
Just-In-Time compilation is the run-time translation of interpreted bytecode (or an intermediate representation) into native machine code, performed selectively and adaptively based on observed execution behavior, rather than exhaustively ahead of time. The defining property is adaptivity: the compiler's decisions — what to compile, how aggressively to optimize, what to inline, what types to specialize for — are driven by profiling data gathered while the program itself is running. Three ideas make this tractable. First, hot-spot detection: most programs obey a heavy-tailed execution distribution where a small fraction of code (loops, frequently-called methods) accounts for almost all execution time; compiling everything wastes effort compiling code that runs once. Second, tiering: rather than one compiler, runtimes chain multiple execution strategies — an interpreter, a fast/unoptimized JIT ("baseline" or "C1"-style compiler), and a slow/highly optimizing JIT ("C2"/"TurboFan"/"Ignition+TurboFan"-style) — promoting a method upward as evidence accumulates that the extra compile cost will pay off. Third, speculation with deoptimization: an optimizing JIT can assume facts that are usually true (a call site always sees one concrete type, an array bound is never violated, a branch is never taken) and compile fast code under that assumption, guarded by a cheap runtime check; if the assumption ever fails, execution safely reverts — deoptimizes — to unoptimized code or the interpreter. These mechanisms sit directly on top of the Interpreters versus Compilers distinction: a JIT is not "a compiler instead of an interpreter" but a system that starts as an interpreter and escalates to compilation only where profiling justifies it. The optimizing tier typically compiles through an SSA-based intermediate representation — see Static Single Assignment (SSA) Form — because SSA's single-definition property makes type specialization, constant propagation, and inlining decisions compositional and easy to verify. Loop-heavy hot methods are where classical Loop Optimizations and Loop-Invariant Code Motion pay off most, since loops are exactly the code shape that hot-spot detection is built to find.
How it works
A tiered JIT runtime is best understood as a state machine attached to every method (or loop), driven by counters, with compilation happening on background threads so the interpreter never stalls waiting for a compile to finish.
Step 1 — Interpretation with counting. Every method begins execution in the interpreter (or the lowest tier). The interpreter increments an invocation counter each time the method is called, and a separate back-edge counter each time a loop's back edge is taken (loops can become hot without the enclosing method being called often).
Step 2 — Threshold crossing triggers compilation. When a counter crosses a threshold, the runtime enqueues a compilation request for that method at the next tier, but keeps interpreting it in the meantime — compilation happens asynchronously.
Step 3 — Baseline (Tier 1) compilation. A fast, mostly non-optimizing compiler produces machine code quickly (compiling in milliseconds), often doing little more than templated code generation with type feedback recording (inline caches). This code is not fast in the "optimizing compiler" sense, but it is far faster than tree-walking or bytecode dispatch.
Step 4 — Profiling continues in Tier 1. The baseline-compiled code itself gathers richer profile data: which concrete types appear at polymorphic call sites, which branches are taken, which methods called from here are themselves hot. This is stored as type feedback / inline caches.
Step 5 — Promotion to the optimizing tier. When a method's counters cross a second, higher threshold, the accumulated profile is handed to the optimizing compiler, which builds an SSA-form IR, and applies aggressive, speculative optimizations: type specialization, inlining guided by observed call targets, loop-invariant code motion, bounds-check elimination, escape analysis.
Step 6 — Guards and speculation. Every speculative optimization inserts a cheap runtime guard: e.g., "check that the object at this call site still has hidden-class C" before running specialized code that assumes it. If a guard fails, control transfers to a deoptimization stub.
Step 7 — On-Stack Replacement (OSR). A long-running loop inside an interpreted method may become hot before the method itself is called again — waiting for the next call to install compiled code would waste the rest of this execution. OSR compiles just that loop and transplants the interpreter's live state (locals, stack) into the equivalent compiled-code frame while the loop is running, resuming execution in native code without restarting the method.
Step 8 — Deoptimization. If a guard fails (a call site starts seeing a second type, an assumed-non-null value is null, an inlined method is overridden by newly loaded code), the runtime cannot simply "jump back" — the optimized frame's layout differs from the interpreter's. Instead it reconstructs an equivalent interpreter frame from safepoint metadata recorded at compile time, discards the optimized code (or marks it for recompilation with updated assumptions), and resumes in the interpreter or a lower tier. ``` // Simplified tiered JIT driver, one instance per compiled method M state(M) = INTERPRETED invocationCount(M) = 0 backEdgeCount(M) = 0 function onMethodEntry(M): if state(M) == INTERPRETED: invocationCount(M) += 1 if invocationCount(M) >= T1_THRESHOLD: enqueueCompilation(M, tier = BASELINE) // async, background thread interpretBytecode(M) function onBackEdge(M, loopHeader): backEdgeCount(M, loopHeader) += 1 if backEdgeCount(M, loopHeader) >= OSR_THRESHOLD and state(M) == INTERPRETED: code = compileLoopForOSR(M, loopHeader, profile(M)) // compile just this loop frame = transplantInterpreterFrame(currentFrame(), code) transferControl(frame) // resume in native code, mid-loop function onBaselineExecute(M): invocationCount(M) += 1 recordTypeFeedback(M) // inline-cache style: which types/targets seen if invocationCount(M) >= T2_THRESHOLD: enqueueCompilation(M, tier = OPTIMIZING) function optimizingCompile(M, profile): ir = buildSSA(bytecodeOf(M)) ir = specializeTypes(ir, profile.observedTypes) // insert type guards ir = inlineHotCallees(ir, profile.callTargets) // insert inlining guards ir = loopInvariantCodeMotion(ir) ir = boundsCheckElimination(ir) ir = escapeAnalysis(ir) // stack-allocate non-escaping objects code = codegen(ir, recordSafepoints = true) // safepoints enable deopt installCode(M, code) state(M) = OPTIMIZED function onGuardFailure(M, pc, liveState): frame = reconstructInterpreterFrame(M, pc, liveState, safepointMap(M, pc)) invalidate(compiledCodeOf(M)) updateProfile(M, observedFailureReason) state(M) = BASELINE // or INTERPRETED, depending on policy resumeInterpreter(frame) ```
Complexity & correctness
Compilation cost per method. Baseline compilation is designed to run in time roughly linear in bytecode size, for bytecode instructions, with a small constant factor, because it performs no global data-flow analysis — it emits templated machine code per bytecode with light peephole cleanup. Optimizing compilation is asymptotically more expensive: building SSA form and running classical data-flow optimizations (LICM, global value numbering, escape analysis) typically costs to in practice depending on the analyses used (dominator-tree construction for SSA is near-linear via the Lengauer–Tarjan algorithm; some optimizations like alias analysis can be superlinear). This cost is paid once per hot method, amortized over however many times that method subsequently executes — the entire economic argument for JIT compilation rests on this amortization.
Why tiering is asymptotically the right trade. Suppose a method runs times. Pure interpretation costs where is the (large) per-execution interpretive overhead. Compiling immediately costs where but is large for an optimizing compiler. Tiering approximates the decision "compile only if is large enough that " without knowing in advance, by using observed counts as an online estimator of . The threshold-crossing rule is a simple but effective proxy: a method that has already run times is, empirically (heavy-tailed execution distributions), overwhelmingly likely to run many more times, so the expected future benefit exceeds the compile cost. This is a heuristic, not a proof of optimality — pathological programs that run exactly times and then stop pay pure overhead — but it is correct in expectation for realistic workloads, which is the standard the whole field is judged against.
Correctness of speculative optimization. The crux is that speculation must never produce a wrong answer, only occasionally a slower one. This is guaranteed structurally: every optimization that relies on an unproven fact (a type, a non-null check, a lack of aliasing, a monomorphic call target) is compiled as
placed before any code that depends on , and the compiler records enough metadata at that program point (a safepoint: mapping from optimized-code registers/stack slots back to interpreter locals) to reconstruct an equivalent unoptimized state. The invariant maintained is: at every guard, the reconstructed interpreter state is semantically equivalent to what the interpreter's state would have been had it executed up to that point — this is what makes deoptimization observably transparent; the program's semantics under a JIT are identical to the program's semantics under pure interpretation, only the performance differs. Proving this invariant is exactly why compiler writers build optimizing tiers on SSA form (per Static Single Assignment (SSA) Form): SSA's explicit def-use chains make it tractable to compute, for any program point, which interpreter variable each optimized value corresponds to.
On-Stack Replacement correctness. OSR is trickier because it swaps execution strategy mid-loop, not at a method boundary. Correctness requires the compiled loop code to accept an entry state matching any valid loop-header state reachable from the interpreter (not just from the loop's natural start), which is why OSR-compiled code is generated with a dedicated OSR entry point that reads all live locals from the interpreter frame layout, rather than assuming values live in specific registers already.
Honest tradeoffs. JIT compilation trades peak-throughput determinism for adaptivity: warm-up time (the interval before hot methods are optimized) is real overhead absent from AOT-compiled code, which is why short-lived scripts and CLI tools often prefer interpreters or AOT compilation (see Interpreters versus Compilers). Speculative deoptimization also means worst-case latency is not fully predictable — a guard failure at an inopportune moment causes a stall while reconstructing frames and possibly recompiling, which matters for hard real-time systems (this is a major reason JVMs are avoided in latency-critical embedded control loops). Memory overhead is also nontrivial: profiling counters, inline-cache metadata, and multiple compiled versions of hot methods all consume space the interpreter alone would not need.
Worked example
Consider this JavaScript-like hot loop, executed inside a V8-style engine: ``` function sum(arr) { let total = 0; for (let i = 0; i < arr.length; i++) { total += arr[i]; } return total; } // called in a loop 10,000,000 times with arr = [Int32, Int32, ...] ``` Trace through the tiers:
| Stage | Counter state | Runtime action | Assumption made |
|---|---|---|---|
| Calls 1–999 | invocationCount < T1 (say T1=1000) | Interpreted directly from bytecode | none — pure interpretation |
| Call 1000 | invocationCount = T1 | Enqueue baseline compile of `sum`; keep interpreting meanwhile | none yet |
| Calls 1000–9999 | baseline code installed after ~1 more call | Runs templated machine code; records that `arr[i]` always sees packed Int32 arrays, `+=` always sees numbers | type feedback recorded, no guards inserted yet |
| Call ~10,000 | invocationCount = T2 (say T2=10,000) | Enqueue optimizing compile using recorded feedback | speculate: `arr` is always a packed Int32 array, `total` is always a number (never overflows to a heap-allocated BigInt) |
| Optimizing compile | — | Build SSA IR for `sum`; insert one guard: "is `arr`'s hidden class == PackedInt32Array?"; if so, skip all generic property-lookup machinery and generate a raw indexed load + integer add; apply LICM to hoist `arr.length` bound check where safe; run bounds-check elimination since `i` is proven `< arr.length` by the loop condition | guard checked once per call, not once per iteration |
| Calls 10,000–10,000,000 | OPTIMIZED | Runs at near-C speed: raw array load, integer add, no property dispatch, no per-element type checks | assumption holds — no deopt |
| Hypothetical call 10,000,001 with `arr = ["a","b","c"]` (strings) | — | Guard "hidden class == PackedInt32Array?" fails | deoptimize: reconstruct interpreter frame for `sum` at the loop header, discard/invalidate optimized code, resume in baseline or interpreter, and update the type feedback to include strings (so a future recompile might generate a polymorphic version, or simply stay deoptimized if this is rare) |
The key lesson: the optimizing compiler never proved `arr` is always `Int32` — it observed this in 9,000 prior calls and bet on it, protected by a single guard so cheap it is invisible next to the speedup from eliminating property lookups and bounds checks on every element. If the bet is wrong even once, correctness is preserved by falling back, not by crashing or returning a wrong sum.
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Megamorphic call sites | A call site sees many different types across executions (e.g., a callback invoked with heterogeneous objects) | Inline caches thrash, no stable type feedback forms, the optimizing compiler either refuses to specialize or specializes wrongly and deoptimizes repeatedly |
| Deoptimization loops ("bailout thrashing") | A guard fails just often enough to keep triggering recompilation but not often enough to make the runtime give up speculating | Method oscillates between tiers, spending more time recompiling than executing |
| Warm-up illusion in benchmarks | Microbenchmarks measure a JIT'd language after the JIT has already optimized the hot loop, ignoring interpreter/baseline time | Benchmarks overstate real-world throughput for short-lived processes (CLI tools, serverless cold starts) |
| Ignoring OSR for loop-only hotness | Assuming invocation count alone predicts hotness | A method called only once but containing a billion-iteration loop never gets compiled without back-edge counting and OSR |
| Treating safepoints as free | Assuming metadata for deoptimization costs nothing | Safepoint maps and guard checks add code size and a small constant overhead even on the fast path; excessive speculation bloats compiled code |
| Confusing "JIT" with "always faster" | Assuming JIT-compiled code always beats AOT or interpreted code | For short-lived scripts, warm-up cost can dominate; AOT or an interpreter alone may win end-to-end |
Pioneers & history
The idea of compiling at run time rather than ahead of time predates modern dynamic languages by decades, originating in work on adaptive execution of Smalltalk and Self, then reaching mass deployment through the Java Virtual Machine's HotSpot compiler and later through JavaScript engines competing for browser performance.
Origins · 1 minUrs Hölzle and how this was discovered
Where this matters
Tiered JIT compilation with speculative optimization is the execution engine behind essentially every mainstream managed-language runtime in production today. The HotSpot JVM (Java, Kotlin, Scala) uses exactly this client/server (C1/C2) tiered scheme, with C2 performing SSA-based optimization and OSR for loop-heavy numerical code. V8 (Chrome, Node.js, Electron apps) uses Ignition as its interpreter/baseline tier and TurboFan as its SSA-based optimizing compiler, with hidden classes standing in for the "type" concept in inline caches. .NET's CLR uses RyuJIT with tiered compilation (a quick Tier 0 pass followed by a fully-optimizing Tier 1). PyPy applies tracing-JIT variants of these ideas to Python, and LuaJIT does the same for Lua. Mobile platforms use JIT-adjacent hybrid strategies: Android's ART historically combined AOT compilation at install time with a JIT for methods whose behavior AOT profiling could not predict, later feeding JIT-collected profiles back into subsequent AOT recompilation. Databases and query engines (e.g., PostgreSQL's JIT via LLVM, Apache Spark's Tungsten) JIT-compile query plans once the query shape and data statistics are known, applying the same speculate-and-guard philosophy to SQL execution rather than general-purpose bytecode.
Ask the tutor: How would you extend a tiered JIT's threshold logic to avoid bailout thrashing on a megamorphic call site?
Why not just compile everything ahead of time and skip interpretation entirely?
AOT compilation lacks run-time profile information: it cannot know which call sites are monomorphic, which branches are hot, or which loops run once versus a billion times. It must either optimize conservatively for all possible behaviors, or use static heuristics that are frequently wrong for dynamic languages with polymorphic dispatch. JIT compilation defers these decisions until the program has revealed its actual behavior, enabling speculative optimizations (like assuming a call site is monomorphic) that AOT compilers cannot safely make without run-time guards and a mechanism to undo them. The cost is warm-up time and run-time overhead for profiling, which AOT avoids. Many production systems (like Android ART or GraalVM Native Image) blend both: AOT-compile a stable baseline, then JIT-optimize based on observed profiles, or feed prior JIT profiles back into an AOT recompilation step.
What exactly is an inline cache, and how does it relate to type feedback?
An inline cache is a piece of code attached to a call site or property-access site that remembers the last-seen type (or "hidden class") and the corresponding method/offset resolved for it. On the next execution, the cache checks whether the same type appears again; if so, it reuses the cached resolution instead of doing a full method/property lookup. Monomorphic sites (always the same type) get a single fast check; polymorphic sites (a handful of types) chain a few checks; megamorphic sites (many types) fall back to a generic, slower lookup. Inline caches are also the mechanism by which the optimizing compiler learns which types actually flow through a call site, since the cache's history is exactly the type feedback fed into speculative type specialization.
Does deoptimization mean the JIT compiler made a mistake?
No — deoptimization is a designed, expected part of correct operation, not an error condition. Speculative optimizations are explicitly built on assumptions that are usually true but not provably true; the guard-and-deopt mechanism is how the compiler stays sound while still exploiting common-case behavior. A single deoptimization is cheap and harmless. The genuine problem is repeated deoptimization of the same code (bailout thrashing), which indicates the runtime speculated on an assumption that does not actually hold consistently, and production JITs specifically detect and avoid re-speculating on patterns that have already failed multiple times.
How does tiered compilation avoid wasting time optimizing code that only runs a few times?
By gating each successive tier behind a higher counter threshold, so a method must prove increasing "hotness" before receiving increasingly expensive treatment. A method called twice never crosses even the first threshold and stays interpreted, costing nothing beyond normal interpretive overhead. A method called ten thousand times crosses into the optimizing tier, where the (larger) compilation cost is amortized across its remaining executions. This threshold scheme is a heuristic estimator of future call counts based on past call counts, which works well because real-world execution profiles are heavy-tailed: past hotness is a strong predictor of continued hotness.
What is the relationship between JIT compilation and loop-invariant code motion?
Loop-invariant code motion (LICM) is one of the classical optimizations an optimizing JIT tier applies once it has built an SSA-form representation of a hot method, exactly as it would in a static optimizing compiler. The JIT-specific twist is that LICM can be combined with speculation: if profiling shows a loop's array is always the same concrete type, the compiler can hoist a type/bounds check for the whole array reference out of the loop body entirely, guarded once before the loop rather than checked on every iteration. This turns a per-iteration guard into a per-invocation guard, which is why hot numeric loops in JIT'd languages can approach the speed of statically compiled code despite the language being dynamically typed.
Why do short-lived programs (like CLI scripts) often perform worse under a JIT-based runtime than under a purely interpreted or AOT-compiled one?
A JIT's speedup is amortized over many executions of the same hot code; if the process exits before any method crosses the promotion thresholds, all the profiling and interpretive overhead is paid with none of the compiled-code speedup ever recovered. This "warm-up cost" is real wall-clock time spent interpreting, profiling, and possibly compiling code that then executes only a handful of times. This is precisely why some runtimes (Android ART's AOT mode, GraalVM Native Image) offer ahead-of-time compilation paths specifically for latency-sensitive startup scenarios, trading away the adaptivity benefits of a JIT for predictable, immediate native-speed execution from the very first instruction.
How does On-Stack Replacement differ from ordinary tiered promotion at a method call?
Ordinary tiered promotion happens at a method's entry point: the next time the method is called, the runtime uses the newly compiled version instead of the old one, which is straightforward because a fresh call starts with a fresh, well-defined frame. OSR instead swaps execution strategy while a method is already running, mid-loop, transplanting the interpreter's current locals and program counter into an equivalent point in freshly compiled native code. This requires the compiler to generate a special OSR entry point that can accept state from arbitrary loop-header configurations, not just from a clean method start, making OSR compilation and correctness verification meaningfully harder than ordinary tier promotion.