JIT Optimizations in Java

Naveen AnamaneniUpdated · 5 min read

The C2 JIT compiler doesn't just translate bytecode into machine code; it rewrites hot code to run faster, using information gathered while the program ran. Its key techniques are inlining (copying small methods into their callers), escape analysis (avoiding heap allocation for objects that never leave a method), loop optimizations such as unrolling, and speculative optimizations based on what the profile has seen so far. When an assumption turns out to be wrong, the JVM deoptimizes: it throws the compiled code away and goes back to the interpreter. In this lesson, you will learn these optimizations and see two of them measured.

The Main Optimizations

OptimizationWhat it does
InliningReplaces a call with the body of the called method, removing call overhead and enabling further optimizations
Escape analysisDetects objects that never escape a method or thread, then removes their heap allocation (scalar replacement) or their locks
Loop unrollingRepeats the loop body several times per iteration to reduce loop-control work
Loop-invariant code motionMoves calculations that don't change inside a loop to before the loop
Range-check eliminationRemoves array bounds checks the compiler can prove are unnecessary
Dead code eliminationRemoves code whose results are never used
Speculative devirtualizationTurns an interface or virtual call into a direct call if only one type has been seen
IntrinsicsReplaces known JDK methods (such as Math.max, System.arraycopy) with special CPU instructions

Inlining in a Real Log

For the Hot program from "Tiered Compilation in Java", the diagnostic option -XX:+PrintInlining shows the compiler's decisions:

Terminal
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining Hot
Terminal output
                              @ 16   Hot::square (4 bytes)   inline
                              @ 16   Hot::square (4 bytes)   inline
                              @ 16   Hot::square (4 bytes)   inline (hot)

Explanation: The call to square at bytecode position 16 of main was inlined in each compilation, the last time by C2 because it is hot. After inlining, the loop contains x * x directly, with no method call at all. Small methods such as getters and setters cost nothing for this reason.

Escape Analysis, Measured with JMH

This JMH benchmark (see "Benchmarking with JMH") creates a Point in two ways: one that stays inside the method, and one that escapes to a Blackhole, which the compiler must treat as "used somewhere else".

EscapeBench.java
package bench;
 
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
 
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 3, time = 1)
@Fork(1)
@State(Scope.Thread)
public class EscapeBench {
    record Point(int x, int y) { }
 
    int a = 3, b = 4;
 
    @Benchmark
    public int pointStaysLocal() {
        Point p = new Point(a, b);
        return p.x() * p.x() + p.y() * p.y();
    }
 
    @Benchmark
    public void pointEscapes(Blackhole bh) {
        bh.consume(new Point(a, b));
    }
}

Run with JMH's GC profiler (-prof gc) on JDK 25, the key lines were:

Terminal output
Benchmark                                       Mode  Cnt      Score     Error   Units
EscapeBench.pointEscapes                        avgt    3      2.054 ±   0.050   ns/op
EscapeBench.pointEscapes:gc.alloc.rate.norm     avgt    3     24.000 ±   0.001    B/op
EscapeBench.pointStaysLocal                     avgt    3      0.373 ±   0.062   ns/op
EscapeBench.pointStaysLocal:gc.alloc.rate.norm  avgt    3     ≈ 10⁻⁶              B/op

Explanation: When the Point escapes, every call allocates a 24-byte object on the heap. When it stays local, escape analysis proves no other code can see it, so C2 replaces the object with its two int fields kept in registers: zero bytes allocated and more than five times faster. This is why creating small temporary objects in hot code is often free.

Deoptimization

C2 often speculates, for example "this interface call always goes to ArrayList" or "this branch is never taken". It inserts a cheap check, and if the check ever fails (an uncommon trap), the JVM deoptimizes: it switches that method back to the interpreter and may compile it again later with the new information. In the -XX:+PrintCompilation log, this appears as made not entrant: uncommon trap.

Common Mistakes

Avoiding small methods "for speed". Hot small methods are inlined.

Benchmarking with System.nanoTime around a loop. Dead code elimination can remove the work; use JMH with Blackhole.

Assuming every new costs a heap allocation. Escape analysis can remove it.

Interview Corner

What optimizations does the JIT compiler perform?

HotSpot's C2 compiler uses runtime profiling to optimize hot code: method inlining; escape analysis with scalar replacement and lock elision for objects that don't escape; loop optimizations such as unrolling, loop-invariant code motion and range-check elimination; dead code elimination; intrinsics for common JDK methods; and speculative optimizations such as devirtualizing call sites that have seen only one receiver type. When a speculation fails, guards trigger deoptimization back to the interpreter, and the method may be recompiled.

Practice

  1. Easy: Run a small program with -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining and find a method marked inline (hot).
  2. Harder: Run EscapeBench with -jvmArgs -XX:-DoEscapeAnalysis and compare the allocation rate of pointStaysLocal.

Quick check: JIT optimizations

4 questions
1What does inlining do?
2What can escape analysis remove?
3What is deoptimization?
4In the benchmark, how many bytes per call did pointStaysLocal allocate?

Key Takeaways

Was this tutorial helpful?

Lesson 449 of 482