Compilers, Interpreters and JITs

A compiler translates a program ahead of time into another language you later run. But translation is only one way to execute a program. You could instead read the source and carry out its meaning directly, never producing a target program at all — that is an interpreter. Or you could do a bit of both, translating to a compact bytecode and interpreting that on a virtual machine. Or — the cleverest option — you could interpret at first and then compile the hot parts to native code while the program is running. That last one is a just-in-time (JIT) compiler.

These are not four unrelated tools; they are points on a spectrum trading the same handful of quantities: startup latency, peak throughput, portability, and memory footprint. Understanding the spectrum is what lets you answer "why does Python start instantly but run slowly, while C is the reverse — and why does the JVM somehow get both?" The unifying idea underneath is the abstract machine.

The abstract machine, the idea underneath everything

Every language is defined against some abstract machine — an idealised computer whose instructions are the language's constructs. Executing a program means bridging the gap between that abstract machine and the real hardware. There are exactly two ways to bridge a gap, and every execution model is a mixture of them:

A pure compiler is all translation; a pure interpreter is all interpretation. Everything interesting — bytecode VMs, JITs — lives in between, translating part of the way (to a convenient intermediate abstract machine) and interpreting the rest, or deferring translation until run time when it knows more.

The four models, compared

Line them up against the quantities they trade. Read each row as "what do I pay, and what do I get?"

ModelWhen translatedStartupPeak speedPortabilityExamples
AOT compilerfully, before runningslow (build step)highlow (one binary per target)C, C++, Rust, Go
Pure interpreternever — walks source/ASTinstantlowhigh (run anywhere)classic shell, tiny BASIC
Bytecode + VMto bytecode ahead of time; VM interpretsfastmediumhigh (one bytecode, many VMs)CPython, early JVM, Lua
JIT compilerhot code, at run time, to nativefast, then warms upvery highhigh (VM ships per target)HotSpot JVM, V8, PyPy, .NET

The bytecode-plus-VM row is why "compiled vs interpreted" is a false binary: javac is a compiler (source → bytecode) and the JVM is an interpreter/JIT of that bytecode. Java is both. The interesting question is never "compiled or interpreted?" but "translated how far, and when?"

The startup / throughput trade, in numbers

The tension is concrete. AOT pays a big fixed cost up front and then runs each unit of work cheaply. Pure interpretation pays nothing up front but a lot per unit forever. A JIT starts like an interpreter and bends toward the compiled cost as hot code gets compiled — so for a short run the interpreter wins, and for a long run the JIT wins. Model it and watch the crossover.

// Toy cost model (arbitrary units): total time = startup + perWork x work. // A JIT approximated as: interpret until it warms up, then run compiled. function aot(work: number): number { return 1000 + 1.0 * work; } // big build, cheap work function interp(work: number): number { return 5 + 8.0 * work; } // no build, dear work function jit(work: number): number { const warm = 200; // work units spent interpreting + compiling if (work <= warm) return 10 + 8.0 * work; // still warming up (interpreting) return 10 + 8.0 * warm + 300 + 1.2 * (work - warm); // then compiled: cheap per unit } console.log("work | AOT | interp | JIT | winner"); for (const w of [10, 100, 500, 2000, 20000]) { const a = aot(w), i = interp(w), j = jit(w); const best = Math.min(a, i, j); const who = best === i ? "interp" : best === j ? "JIT" : "AOT"; console.log( `${String(w).padStart(5)} | ${a.toFixed(0).padStart(5)} | ${i.toFixed(0).padStart(6)} | ` + `${j.toFixed(0).padStart(5)} | ${who}`, ); }

Run it: for a ten-unit script the interpreter wins outright (no build cost), for a huge batch job the AOT binary wins, and in the broad middle — long-running servers, browsers — the JIT is best, because it amortises a small, targeted compilation over a lot of work. That middle is where most software actually lives, which is why JITs power the JVM, JavaScript engines and .NET.

How a real JIT decides what to compile

A production JIT such as HotSpot or V8 is profile-guided and tiered. It begins by interpreting, cheaply counting how often each method runs and each loop spins. When a counter crosses a threshold — the code is "hot" — it compiles that method to native code at a low optimisation tier, then, if it stays hot, recompiles it at a higher tier with aggressive optimisation. Crucially, it speculates using facts observed at run time ("this call site has only ever seen one type") and installs a cheap guard; if the guard ever fails, it deoptimises back to the interpreter and recompiles. This is the machinery that lets a dynamically-typed language reach speeds an AOT compiler, which cannot see run-time behaviour, often cannot match.

It sounds impossible — the AOT compiler has unlimited time and the whole program in front of it, while the JIT must work in milliseconds during execution. The JIT's advantage is information the AOT compiler can never have: what actually happens at run time. It sees that a supposedly polymorphic call is monomorphic in practice, that a branch is taken 99.9% of the time, that a final-in-effect value never changes, that a virtual dispatch always lands on one class. It then speculatively compiles as if those facts were guaranteed — inlining across the virtual call, straightening the hot path — guarded by a cheap check that bails out to the interpreter if a fact is ever violated. The AOT compiler, forced to be correct for all possible runs, must keep the general (slower) code. Profile-guided optimisation gives AOT a taste of this by feeding back a training run, but the JIT gets the real run, every time, and can re-specialise if behaviour shifts.

This slogan fails in every direction. A naïve AOT compiler with no optimiser can emit worse code than a finely-tuned interpreter's inner loop. A JIT is a compiler that produces native code yet begins by interpreting — is it "compiled" or "interpreted"? Java is compiled to bytecode and interpreted and JIT-compiled, all in one run. And the modern truth is that a top JIT frequently beats an AOT binary on long-running workloads by exploiting run-time profiles the AOT compiler could not see.

The honest framing replaces the binary with the four quantities: startup latency, peak throughput, portability and memory/warmup cost. AOT trades slow startup and poor portability for fast, predictable steady-state; interpretation trades peak speed for instant start and portability; JIT chases both peaks at the price of warmup and a heavier runtime. "Fast" and "slow" are properties of a workload on a model, never of the word "compiled".