JIT Compilation
The JVM starts by interpreting bytecode, then compiles the code paths that run most often into fast native machine code while the program is still running.
The Java Virtual Machine does not compile a whole program to native code before running it. It begins by interpreting compiled .class bytecode, watches which methods and loops run frequently ("hot" code), and compiles just those to optimized machine code on the fly — this is Just-In-Time (JIT) compilation. It is one reason long-running Java processes tend to speed up after a warm-up period.
Why Interpretation First
javac compiles Java source into platform-independent bytecode, not native machine code. When the JVM starts a program, its baseline execution strategy is to interpret that bytecode instruction by instruction. Interpretation starts fast (no compilation delay) but each execution of a bytecode instruction is comparatively slow, since the interpreter re-decodes and dispatches it every time.
This split — interpret first, compile the hot parts later — is a JVM implementation strategy. The Java Language Specification says nothing about interpretation or JIT compilation at all; it only specifies bytecode semantics and observable behavior.
Profiling and Detecting Hot Code
While interpreting, a typical HotSpot JVM keeps per-method and per-loop-backedge invocation counters. When a counter crosses an internal threshold, that method (or loop) is flagged as "hot" and handed to a background compiler thread for compilation — the running program is not paused waiting for compilation to finish.
This profiling also gathers runtime information the static compiler never had: which branch a conditional usually takes, which concrete class an interface call usually targets, typical array lengths, and so on. That profile data is what makes JIT-compiled code faster than a naive ahead-of-time translation of the same bytecode.
What JIT Compilation Produces
The JIT compiler translates a hot method's bytecode into native machine instructions for the host CPU, and installs that native version so future calls to the method run it directly instead of going through the interpreter. This happens transparently — from the Java source's point of view, behavior is identical; only speed changes.
sumSquares is called tens of thousands of times with a hot inner loop; a typical JVM will detect it as hot and JIT-compile it well before the run ends.
public class HotLoop {
static long sumSquares(int n) {
long total = 0;
for (int i = 0; i < n; i++) {
total += (long) i * i;
}
return total;
}
public static void main(String[] args) {
long acc = 0;
for (int iter = 0; iter < 50_000; iter++) {
acc += sumSquares(10_000); // called 50,000 times: becomes hot quickly
}
System.out.println(acc);
}
}Optimizations the Compiler Can Apply
Because JIT compilation happens with live profile data, it can apply optimizations a static compiler cannot safely make blind, including: inlining small hot methods directly into their caller to remove call overhead, eliminating array bounds checks it can prove are always satisfied, unrolling loops, removing dead code, and — notably — speculative optimizations based on "this call site has only ever seen class X" (monomorphic call-site inlining).
These are all JVM-implementation-dependent behaviors. Different JVMs (and different versions of the same JVM) may choose different thresholds, different optimizations, or none at all — none of this is guaranteed by the JLS.
Deoptimization
Speculative optimizations can turn out to be wrong once program behavior changes — for example, a call site the JIT assumed always targeted one concrete class starts seeing a second implementation. When an assumption a compiled method relied on is invalidated, the JVM performs deoptimization: it discards the optimized native code for that method and falls back to interpreting it (re-profiling from there, and potentially re-compiling later with updated assumptions).
Deoptimization is a correctness safety valve, not a bug: it lets the JIT compiler optimize aggressively and speculatively, because it always has a fallback path when a speculation turns out false.
Tiered Compilation
Modern HotSpot JVMs (the default in mainstream OpenJDK/Oracle JDK builds) use tiered compilation, combining multiple execution strategies: pure interpretation, a fast-but-lightly-optimizing C1 ("client") compiler that also gathers extra profiling data, and a slower-but-heavily-optimizing C2 ("server") compiler for code that stays hot long enough to be worth the deeper investment.
The exact tier count, thresholds, and even which compilers exist (C1/C2, or a JIT written in Java such as Graal in some builds) are JVM-implementation details, not part of the Java language.
Typical tiers (JVM-dependent, illustrative only):
Tier 0: interpreter
Tier 1: C1, no profiling (very hot, simple methods)
Tier 2-3: C1, with profiling instrumentation
Tier 4: C2, fully optimized, uses profile dataWarm-Up Effect
Because hot-code detection and compilation take time, a JVM process is typically slower during its first seconds (or longer, for large applications) than once its hot paths have been JIT-compiled. This "warm-up" period matters for short-lived processes (CLI tools, some serverless invocations) and for benchmarking, where measuring the first few iterations mixes interpreter and compiled-code performance together.
This is one motivation behind newer JVM startup technologies (e.g. Class Data Sharing, ahead-of-time compilation options in some distributions) — but those are separate, JVM-specific features layered on top of the interpret-then-JIT model, not a replacement for it in standard HotSpot.
Practical Usage and Benchmarking Implications
When you microbenchmark Java code, you must let the JIT warm up before measuring, or you are largely measuring the interpreter and compilation overhead, not steady-state performance. Tools like JMH (Java Microbenchmark Harness) exist specifically to run "warm-up" iterations before the measured iterations, and to guard against the JIT optimizing away dead code that doesn't affect any observable result.
Naive timing pitfall:
long start = System.nanoTime();
doWork(); // first call: interpreted, unoptimized
long elapsed = System.nanoTime() - start; // misleadingly slow
Better: run doWork() thousands of times first (warm-up),
then time a second batch of calls (steady state).Edge Cases
A method that is called only a handful of times may never become hot and may run interpreted for the entire program lifetime — this is fine and expected; JIT compilation targets the code that dominates total runtime, not every method.
Compilation is asynchronous and happens on separate compiler threads; a method may still run interpreted for a short window even after it crosses the hot threshold, until compilation finishes and the compiled version is installed.
Excess deoptimization (a hot call site that keeps alternating between multiple target classes, for example) can prevent a method from ever settling into fast optimized code — a phenomenon sometimes called "megamorphic" call-site behavior.
Mental Model
Picture the interpreter as a careful translator reading a script line by line and profiling which scenes get performed over and over. Once a scene has been performed enough times, a specialist "native compiler" backstage prepares a rehearsed, optimized version of just that scene, informed by what actually happened on stage — and swaps it in transparently. If circumstances change enough that the rehearsed version no longer fits, the JVM falls back to the careful line-by-line translator until it can prepare a new rehearsed version.
Quick Reference
Interpretation : slow per-op, zero compile delay, runs first
Profiling : counts invocations/loop backedges, records branch/type info
JIT compilation : hot method/loop -> native code, on a background thread
Optimization : inlining, bounds-check elimination, speculative devirtualization
Deoptimization : discard compiled code, fall back to interpreter, re-profile
Tiered compilation: interpreter -> C1 (fast) -> C2 (aggressive), HotSpot-specificKey Takeaways
The JVM interprets bytecode first and compiles only frequently executed ("hot") code to native machine code — this is the JIT strategy.
Profiling data gathered while interpreting lets the JIT apply optimizations, including speculative ones, that a purely static compiler could not safely make.
Deoptimization falls back to the interpreter when a speculative assumption is invalidated, keeping aggressive optimization safe.
Tiered compilation (interpreter, C1, C2) is a common HotSpot strategy, not a language guarantee.
Warm-up time matters for short-lived processes and for correct benchmarking.
long start = System.nanoTime(); result = compute(x); long elapsed = System.nanoTime() - start; // "benchmark" for (int i = 0; i < 20_000; i++) compute(x); // warm-up long start = System.nanoTime(); for (int i = 0; i < 20_000; i++) result = compute(x); long elapsed = System.nanoTime() - start;
A single-call timing mostly measures interpreter execution plus possible compilation overhead, not the JIT-compiled steady-state speed. Warm up before measuring, and prefer a proper harness like JMH.
// "Java always JIT-compiles method foo() after exactly 10,000 calls"
// Thresholds, tiers, and even whether a method gets compiled at all // are JVM-implementation details (HotSpot-specific and version-specific).
The JLS specifies no compilation thresholds or strategy at all; treating a specific HotSpot number as a language guarantee is incorrect and not portable across JVMs.
// Assuming compiled code can never get slower again once "optimized"
// Understand that deoptimization can revert a method to the interpreter // if a speculative assumption (e.g. a monomorphic call site) is violated.
JIT optimization is not a one-way ratchet; runtime behavior changes (like a new implementing class appearing at a call site) can trigger deoptimization and temporarily slower execution.
Why does the JVM interpret bytecode instead of compiling everything to native code up front?
Compiling everything up front costs startup time for code that may run rarely, and a static compiler lacks runtime profile data. Interpreting first lets the JVM start quickly and gather actual usage information, then spend compilation effort only where it pays off.
What makes code 'hot' from the JVM's perspective?
Code (typically a method or a loop) whose invocation or loop-backedge counters cross an internal, JVM-defined threshold, indicating it accounts for a significant share of execution time.
What is deoptimization and why is it necessary?
It is the JVM discarding previously JIT-compiled native code for a method and reverting to interpretation (with re-profiling), used when a speculative optimization's assumption (e.g. about which class a call site targets) is invalidated at runtime. It keeps speculative optimization safe.
What is tiered compilation?
A HotSpot strategy that layers multiple execution modes — interpreter, a fast lightly-optimizing compiler (C1) that also profiles, and a slower heavily-optimizing compiler (C2) — so code gets progressively more optimized the hotter it becomes, without paying C2's full compile cost on cold code.
Why does a Java program often run faster after it has been running for a while?
This is the warm-up effect: early execution is interpreted (and later, lightly compiled), while frequently executed paths get progressively JIT-compiled and optimized as the JVM gathers profile data, so steady-state performance improves over the process's lifetime.
Is JIT compilation guaranteed by the Java Language Specification?
No. The JLS defines bytecode and language semantics, not execution strategy. JIT compilation, tiering, thresholds, and optimizations are JVM implementation choices — different JVMs may behave differently while remaining fully spec-compliant.
Why is timing a single call to a method a poor way to benchmark it?
A single call is very likely still running interpreted (or freshly, lightly compiled), so the measurement reflects interpreter/compile overhead rather than the optimized steady-state performance most production code actually experiences.
Write a small program with a method that runs in a loop enough times to plausibly become JIT-hot, and explain (in comments) what a typical JVM would do to it over time.
public class WarmupDemo { static int square(int x) { return x * x; } public static void main(String[] args) { long sum = 0; // Called 1,000,000 times: interpreter runs it first, profiling counters // climb, a typical HotSpot JVM eventually JIT-compiles square() (likely // via C1 then C2 under tiered compilation), possibly inlining it into // the loop body entirely since it is small and monomorphic. for (int i = 0; i < 1_000_000; i++) { sum += square(i); } System.out.println(sum); } }
The loop calls square() a million times. A typical HotSpot JVM starts by interpreting it, profiles the invocation count, and once it crosses an internal threshold compiles it to native code (tiered: C1 first, then possibly C2), likely inlining the tiny method directly into the loop. None of this is guaranteed by the JLS — it's a JVM implementation detail.
If sumAreas has been called millions of times with only Circle instances and the JIT speculatively inlined Circle.area(), what happens when it is suddenly called with Square instances too?
The call site s.area() was monomorphic (always Circle) long enough that the JIT may have speculatively devirtualized/inlined Circle.area() directly. Once a Square instance reaches that call site, the assumption is violated, triggering deoptimization: the JVM falls back to interpreting (or re-compiling more generally, e.g. with an inline cache handling both types) that code path. The program still produces correct results — deoptimization exists precisely to keep speculative optimization safe — but that call briefly loses the earlier optimization.
Explain why a JMH benchmark includes explicit 'warm-up' iterations before its 'measurement' iterations, in terms of JIT compilation.
Warm-up iterations run the benchmarked code enough times to let the JVM's profiler flag it as hot and let the JIT compiler(s) compile and install optimized native code for it (and let deoptimizations from measurement-irrelevant one-time effects settle). Measurement iterations then run after the code has reached (or nearly reached) steady-state compiled performance, so the reported numbers reflect real-world optimized execution rather than interpreter/compilation overhead from a cold start.
interface Shape { double area(); }
class Circle implements Shape { public double area() { return 3.14; } }
class Square implements Shape { public double area() { return 4.0; } }
static double sumAreas(Shape[] shapes) {
double total = 0;
for (Shape s : shapes) total += s.area(); // called millions of times, always Circle
return total;
}
// Then later in the same run:
// sumAreas(new Shape[]{ new Square(), new Square() }); // a Square now appears