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.
interpreter -> C1 (quick compile, profiling) -> C2 (optimised, using the profile)
java -XX:+PrintCompilation -jar app.jarjavac 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 speedStopping 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
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.
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 584102239Warming 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.
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.
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