The JIT Compiler

Learn how HotSpot turns hot bytecode into machine code at run time, why services need warm-up, and how not to fool yourself with benchmarks.

Syntaxinterpreter -> C1 (quick compile, profiling) -> C2 (optimised, using the profile) java -XX:+PrintCompilation -jar app.jar

javac compiles your source to bytecode, not machine code. At run time HotSpot starts by interpreting that bytecode, counts how often each method and loop runs, and compiles the hot ones to native code while the program keeps going. This is just-in-time (JIT) compilation, and it is why a Java service gets faster during its first minutes.

Tiered compilation

  • Interpreter: runs everything at first and counts method calls and loop iterations to find the hot code.
  • C1: compiles warm methods quickly, with light optimisation, and records a profile: which branches run, which concrete types reach each call.
  • C2: recompiles the hottest methods using that profile, with aggressive optimisation.

C2's big wins come from inlining small methods into their callers, removing checks the profile shows are never needed and escape analysis, which can avoid allocating objects that never leave a method. Because these optimisations rest on assumptions, the JVM can deoptimise: if a new subclass is loaded or a branch that never ran suddenly runs, it throws away the compiled code and falls back to the interpreter until it recompiles.

Warm-up in production

A freshly started service is slow for its first few thousand requests while the JIT catches up, and the compiler threads compete with request threads for CPU. In practice:

  • Do not judge latency by the first requests after a deploy.
  • Give the JVM real CPU. In a container limited to one CPU, compilation and garbage collection share that core with your code.
  • Roll out gradually, or exercise the main code paths before the readiness check passes.
  • Java 25 can store method profiles from a training run in the AOT cache, so the JIT starts optimising sooner (see Startup and the AOT Cache).

Benchmarking without fooling yourself

Timing a loop with System.nanoTime() almost always measures the wrong thing. The first iterations run in the interpreter, a result that is never used can be removed entirely, and a constant input can be folded away at compile time. Use JMH, the OpenJDK benchmark harness, which handles warm-up, forks and consuming results for you. Better still, measure the real service under load with a profiler.

Flags worth knowing

java -XX:+PrintCompilation -jar app.jar   # log each method as it is compiled
java -XX:TieredStopAtLevel=1 -jar tool.jar # C1 only: quicker start, lower peak speed

Stopping at C1 suits short command-line tools, not long-running services. Leave the other JIT flags at their defaults unless a profiler shows a specific problem.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Exercise the main JSON endpoints a few thousand times during start-up so the first real customers after a deploy do not see slow responses.
  • Benchmark two serialisation approaches for an API with JMH instead of a hand-written nanoTime loop that the JIT can optimise away.
  • Run a short-lived database migration tool with -XX:TieredStopAtLevel=1 to cut the time it spends compiling code it runs only once.

More examples

Consume the result you are measuring

Once parsePort is inlined, the optimiser may drop any work whose result is unused and has no other effect, so a timer around the first loop can measure much less than a real call costs. Printing the folded result keeps the work alive; JMH does the same job with its Blackhole.

Example · java
static int parsePort(String value) {
    return Integer.parseInt(value.strip());
}

void main() {
    String[] inputs = {" 8080", "443 ", "9000"};

    // Wrong: the result is thrown away, so the JIT may delete the work.
    for (int i = 0; i < 100_000; i++) {
        parsePort(inputs[i % inputs.length]);
    }

    // Better: fold every result into a value that is printed.
    long sink = 0;
    for (int i = 0; i < 100_000; i++) {
        sink += parsePort(inputs[i % inputs.length]);
    }
    IO.println("sink " + sink);
}
// Output:
// sink 584102239

Warming up before reporting ready

Running the main code path a few thousand times before the readiness probe passes lets the JIT compile it before real users arrive. Use synthetic input that causes no side effects, such as database writes or outgoing emails.

Example · java
import java.util.concurrent.atomic.AtomicBoolean;

static final AtomicBoolean READY = new AtomicBoolean(false);

static String handle(String path) {
    return "{\"path\":\"" + path + "\",\"length\":" + path.length() + "}";
}

static int warmUp(int calls) {
    int bytes = 0;
    for (int i = 0; i < calls; i++) {
        bytes += handle("/orders/" + i).length();
    }
    return bytes;
}

void main() {
    IO.println("ready before warm-up: " + READY.get());
    int bytes = warmUp(5_000);
    READY.set(true); // the health check now reports ready
    IO.println("warm-up produced " + bytes + " bytes");
    IO.println("ready: " + READY.get());
    IO.println(handle("/orders/42"));
}
// Output:
// ready before warm-up: false
// warm-up produced 173880 bytes
// ready: true
// {"path":"/orders/42","length":10}

Many types at one call site

While only Square reaches the area() call, the JIT can inline Square.area behind a cheap type check; when Triangle and Circle arrive, the check fails, the JVM deoptimises and recompiles, and the results stay correct. A call site that sees more than two types becomes an ordinary virtual call, which matters only in hot loops a profiler has pointed to.

Example · java
interface Shape {
    double area();
}

record Circle(double r) implements Shape {
    public double area() { return Math.PI * r * r; }
}

record Square(double side) implements Shape {
    public double area() { return side * side; }
}

record Triangle(double base, double height) implements Shape {
    public double area() { return 0.5 * base * height; }
}

static double total(Shape[] shapes, int calls) {
    double sum = 0;
    for (int i = 0; i < calls; i++) {
        sum += shapes[i % shapes.length].area(); // the call site the JIT profiles
    }
    return sum;
}

void main() {
    // Phase 1: only Square reaches the call, so the JIT can inline Square.area.
    Shape[] squares = {new Square(2)};
    IO.println(String.format("%.1f", total(squares, 300_000)));

    // Phase 2: new types arrive; the JVM deoptimises and recompiles, results stay correct.
    Shape[] mixed = {new Square(2), new Triangle(4, 3), new Circle(1)};
    IO.println(String.format("%.1f", total(mixed, 300_000)));
}
// Output:
// 1200000.0
// 1314159.3

Discussion

  • Be the first to comment on this lesson.