Lesson 41 of 71
Safepoints, Warmup, and Deoptimization
A Java method starts in the interpreter and is compiled to machine code only after it has run often enough. Compiled code rests on assumptions that can later turn false, and then the JVM throws that code away (deoptimization). The JVM also needs points where threads can be stopped for GC and other work (safepoints). These three mechanisms explain slow first requests and many latency spikes in Java services.
| Term | Meaning |
|---|---|
| Interpreter | Executes bytecode one instruction at a time and collects profiles |
| C1 | Fast client compiler; compiles quickly, optimizes less, can add profiling |
| C2 | Server compiler; compiles slowly, uses profiles for aggressive optimizations |
| OSR | On-stack replacement: switching a running loop to compiled code |
| Safepoint | A state where every Java thread is stopped at a known point |
| Deoptimization | Discarding compiled code and continuing in the interpreter |
Examples run on JDK 25 on x64 with default flags. Compile with javac Main.java and run with java plus the flags in each lead-in. Program output is exact; JVM log output is a representative excerpt whose timestamps, ids, and sizes vary by JDK build and run.
Example 1: the tiers and -XX:+PrintCompilation
Tiered compilation (default since Java 8) uses five levels. HotSpot counts invocations and loop back-edges per method and moves hot methods up the levels.
| Level | Code | Profiling |
|---|---|---|
| 0 | Interpreter | Full |
| 1 | C1, full optimization | None (used for trivial methods) |
| 2 | C1, limited profiling | Invocation and back-edge counters |
| 3 | C1, full profiling | Counters, branch and type profiles |
| 4 | C2 | None; uses the profile gathered at level 3 |
The usual path is 0, 3, 4. With JDK 25 defaults a method is queued for level 3 after about 200 invocations (Tier3InvocationThreshold) and for level 4 after about 5,000 (Tier4InvocationThreshold); back-edges have their own thresholds, and the policy also looks at compile queue length. The tiers, profiles, and inlining decisions are covered in Tiered Compilation, Inlining, and Intrinsics; this lesson needs only the warmup basics. -XX:+PrintCompilation prints one line per compilation event. Run with -XX:+PrintCompilation.
public class Main {
static long square(long x) {
return x * x;
}
public static void main(String[] args) {
long sum = 0;
for (int round = 0; round < 100; round++) {
for (long i = 0; i < 1_000; i++) {
sum += square(i);
}
}
System.out.println("sum: " + sum);
}
}sum: 33283350000Excerpt of the JVM's output, interleaved with JDK startup methods:
48 23 3 Main::square (4 bytes)
49 24 % 3 Main::main @ 12 (52 bytes)
50 25 4 Main::square (4 bytes)
51 23 3 Main::square (4 bytes) made not entrant
53 26 % 4 Main::main @ 12 (52 bytes)Columns: milliseconds since start, compile id, attribute flags (% OSR, s synchronized, ! has exception handlers, b blocking, n native wrapper), tier, method, and bytecode size. "made not entrant" means new calls no longer enter that version. Here it is routine: the level 3 version of square was replaced by the level 4 version. -XX:TieredStopAtLevel=1 stops at C1 (faster startup, lower peak), and -Xint disables compilation entirely.
Example 2: inspecting the JIT from code
CompilationMXBean reports which compiler is running and the total time spent compiling. It returns null when the JVM runs with -Xint.
import java.lang.management.CompilationMXBean;
import java.lang.management.ManagementFactory;
public class Main {
public static void main(String[] args) {
CompilationMXBean jit = ManagementFactory.getCompilationMXBean();
if (jit == null) {
System.out.println("no JIT: interpreted only");
return;
}
System.out.println("compiler: " + jit.getName());
System.out.println("time monitoring: " + jit.isCompilationTimeMonitoringSupported());
}
}compiler: HotSpot 64-Bit Tiered Compilers
time monitoring: trueWith -Xint the program prints no JIT: interpreted only. Monitoring getTotalCompilationTime after a deployment shows when compilation activity settles, which is one signal that warmup is over. Compiler threads run in parallel with application threads, so compilation also competes for CPU during startup, which matters in containers with one or two CPUs.
Example 3: warmup and why first requests are slow
A new process runs every code path in the interpreter first, often 10 to 100 times slower than C2 code for hot loops. Classes also load and initialize on first use, invokedynamic call sites bootstrap on first execution, and caches and connection pools start empty. All of this lands on the first requests. A common fix is to run representative work before the instance reports ready.
import java.util.concurrent.atomic.AtomicBoolean;
public class Main {
static final AtomicBoolean ready = new AtomicBoolean(false);
static String price(String symbol, int qty) {
long cents = symbol.hashCode() % 1000 + 1000L;
return symbol + " x" + qty + " = " + (cents * qty) / 100.0;
}
static void warmUp(int iterations) {
long checksum = 0;
for (int i = 0; i < iterations; i++) {
checksum += price("WARM" + (i % 16), 1 + i % 10).length();
}
if (checksum == 42) System.out.println("unlikely"); // keep the work observable
}
public static void main(String[] args) {
System.out.println("ready before warmup: " + ready.get());
warmUp(20_000);
ready.set(true); // a readiness probe would now return 200
System.out.println("ready after warmup: " + ready.get());
System.out.println(price("AAA", 10));
}
}ready before warmup: false
ready after warmup: true
AAA x10 = 154.5"AAA".hashCode() is 64545, so the price is (545 + 1000) * 10 / 100.0. The warmup uses its result (checksum) so C2 cannot remove the loop as dead code. Warmup traffic must follow the same paths as real traffic: the profiles it leaves decide how C2 compiles, and code shaped by unrealistic inputs may deoptimize when real requests arrive (Example 5).
Example 4: on-stack replacement
A method entered once and looping for a long time never reaches an invocation threshold, but its loop back-edge counter does. HotSpot then compiles a version of the method whose entry point is the loop header and moves the running frame into it. This is OSR, shown with % and @ bci in -XX:+PrintCompilation.
public class Main {
public static void main(String[] args) {
long acc = 0;
for (int i = 0; i < 50_000_000; i++) {
acc += i % 7;
}
System.out.println("acc: " + acc);
}
}acc: 149999997Excerpt with -XX:+PrintCompilation:
41 12 % 3 Main::main @ 4 (31 bytes)
44 13 % 4 Main::main @ 4 (31 bytes)@ 4 is the bytecode index of the loop header. OSR code is a fallback: compiled code that is entered normally is usually better optimized. That is one reason a benchmark written as one long loop in main measures something different from a method that is called many times, and why JMH runs the measured code in a separate method.
Example 5: deoptimization from an uncommon trap
C2 compiles only the branches the profile saw. A branch never taken becomes an uncommon trap: a stub that, if reached, deoptimizes the method and continues in the interpreter. The method is recompiled later with the new profile.
public class Main {
static long apply(long balance, long delta) {
if (delta < 0 && balance + delta < 0) {
return -1; // rejected: never seen during warmup
}
return balance + delta;
}
public static void main(String[] args) {
long ok = 0;
for (int i = 0; i < 200_000; i++) {
if (apply(1_000, i % 100) >= 0) ok++;
}
long rejected = 0;
for (int i = 0; i < 1_000; i++) {
if (apply(10, -50) < 0) rejected++;
}
System.out.println("accepted: " + ok + ", rejected: " + rejected);
}
}accepted: 200000, rejected: 1000Excerpt with -XX:+PrintCompilation:
60 31 4 Main::apply (24 bytes)
75 31 4 Main::apply (24 bytes) made not entrant
81 35 4 Main::apply (24 bytes)Results are always correct; only speed changes. Other common triggers: a null check that was never null, a type check that saw one class (Example 6), an array index that was never out of bounds, and an int that never overflowed in an intrinsic. A service that deoptimizes repeatedly after deployment shows latency spikes that look like GC but are not; -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation writes a detailed XML log that names each trap reason.
Example 6: deoptimization from a class hierarchy change
When only one class implements an interface, class hierarchy analysis (CHA) lets C2 call that class's method directly and inline it. The compiled code records a dependency on that fact. Loading a second implementation breaks the dependency, and HotSpot deoptimizes every method that relied on it. Type profiles have the same effect at a single call site.
public class Main {
interface Shape { long area(); }
record Square(long side) implements Shape { public long area() { return side * side; } }
record Rect(long w, long h) implements Shape { public long area() { return w * h; } }
static long total(Shape[] shapes) {
long t = 0;
for (Shape s : shapes) t += s.area();
return t;
}
public static void main(String[] args) {
Shape[] squares = new Shape[1_000];
for (int i = 0; i < squares.length; i++) squares[i] = new Square(2);
long sum = 0;
for (int i = 0; i < 20_000; i++) sum += total(squares);
System.out.println("squares only: " + sum);
Shape[] mixed = new Shape[1_000];
for (int i = 0; i < mixed.length; i++) {
mixed[i] = (i % 2 == 0) ? new Square(2) : new Rect(2, 3); // loads Rect
}
System.out.println("mixed: " + total(mixed));
}
}squares only: 80000000
mixed: 5000Excerpt with -XX:+PrintCompilation:
112 61 4 Main::total (34 bytes)
240 61 4 Main::total (34 bytes) made not entrant
263 78 4 Main::total (34 bytes)How the number of receiver types at a call site affects inlining (monomorphic, bimorphic, megamorphic) is covered in Tiered Compilation, Inlining, and Intrinsics.
Example 7: safepoints, counted loops, and handshakes
Some operations need every Java thread stopped: most stop-the-world GC phases, some deoptimizations, class redefinition by agents, and some jcmd diagnostics. The VM thread requests a safepoint, and each thread stops when it next reaches a safepoint poll. Compiled code polls at method returns and on loop back-edges. Time-to-safepoint (TTSP) is the time from the request until the last thread stops; every other thread is already paused during that time.
C2 historically removed the poll from counted loops (an int index with a known bound), so one long counted loop could delay a safepoint for the whole JVM. Since JDK 10, loop strip mining splits such loops into an inner loop without a poll and an outer loop with one. -XX:+UseCountedLoopSafepoints (on by default) keeps the poll, and -XX:LoopStripMiningIter (default 1000 on JDK 25) sets the inner loop's iteration count. Loops with a long index were never treated as counted loops by older C2 versions, so they kept their polls. Run with -Xlog:safepoint and -Xmx64m.
import java.util.ArrayList;
import java.util.List;
public class Main {
static volatile Object sink;
public static void main(String[] args) throws InterruptedException {
Thread allocator = new Thread(() -> {
List<byte[]> keep = new ArrayList<>();
for (int i = 0; i < 2_000; i++) {
keep.add(new byte[64 * 1024]);
if (keep.size() > 200) keep.clear(); // forces young GCs
}
sink = keep;
});
allocator.start();
long acc = 0;
for (int i = 0; i < 1_000_000_000; i++) { // counted loop
acc += i & 1;
}
allocator.join();
System.out.println("acc: " + acc);
}
}acc: 500000000Excerpt of -Xlog:safepoint:
[0.412s][info][safepoint] Safepoint "G1CollectForAllocation", Time since last: 98213400 ns, Reaching safepoint: 41200 ns, Cleanup: 9800 ns, At safepoint: 2650100 ns, Total: 2701100 ns"Reaching safepoint" is TTSP. With strip mining it stays small; with -XX:-UseCountedLoopSafepoints a run of this program can show a TTSP close to the full loop time. Handshakes (JEP 312, Java 10) let the VM run an operation on one thread at a time instead of stopping all threads, at the same poll points. HotSpot uses them for per-thread work such as some deoptimization and stack walking. -Xlog:safepoint and JFR's jdk.SafepointBegin events are the usual starting points when a pause is longer than the GC log explains.
Example 8: startup with CDS, the AOT cache, and CRaC
Class data sharing (CDS) stores parsed and verified classes in an archive that the JVM maps at startup. The JDK ships a default archive for its own classes (on by default since Java 12). Application CDS adds application classes: -XX:ArchiveClassesAtExit=app.jsa (Java 13) records them on exit and -XX:SharedArchiveFile=app.jsa uses them.
The AOT cache extends this. Java 24 (JEP 483) caches classes in a loaded and linked state:
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -cp app.jar com.example.Main
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -cp app.jar
java -XX:AOTCache=app.aot -cp app.jar com.example.MainJava 25 adds a one-step form (JEP 514, -XX:AOTCacheOutput=app.aot on the training run) and stores method profiles from the training run in the cache (JEP 515), so the JIT can compile hot methods earlier. The cache needs the same JDK build and a compatible class path at run time. CRaC (Coordinated Restore at Checkpoint) is a separate OpenJDK project, shipped by some vendor builds and not part of mainline Java 25: it saves a warmed process image (-XX:CRaCCheckpointTo=dir) and restores it (-XX:CRaCRestoreFrom=dir), and code registers org.crac.Resource callbacks to close and reopen files and sockets. The program below models a training run that records which classes a run touches, which is what the record step does.
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
public class Main {
static final Set<String> touched = new LinkedHashSet<>();
static void handle(String request) {
touched.add("Router");
if (request.startsWith("GET")) touched.add("JsonWriter");
if (request.startsWith("POST")) touched.add("JsonReader");
touched.add("Metrics");
}
public static void main(String[] args) {
List<String> training = List.of("GET /a", "POST /b", "GET /c");
for (String r : training) handle(r);
System.out.println("classes to archive: " + touched);
System.out.println("first request would find them loaded: " + touched.contains("JsonReader"));
}
}classes to archive: [Router, JsonWriter, Metrics, JsonReader]
first request would find them loaded: trueThe training run must exercise the paths that production uses; a class the training run never touches is loaded the normal way at run time. Startup tools reduce class loading and early compilation cost; they do not replace warmup of data such as caches and connection pools.
Interview points
- Tiers: 0 interpreter, 1 to 3 C1 (with increasing profiling), 4 C2; common path 0, 3, 4.
- Slow first requests come from interpretation, class loading and initialization,
invokedynamicbootstrap, compilation competing for CPU, and cold caches. - OSR compiles a running loop and shows as
%with@ bciinPrintCompilation. - Deoptimization triggers: uncommon traps on unseen branches, nulls, or types; class hierarchy changes that break CHA dependencies.
- Safepoint polls sit at returns and loop back-edges; TTSP is the wait for the slowest thread; loop strip mining (JDK 10+) bounds it for counted loops.
- Handshakes (Java 10) stop one thread at a time.
- Startup: default CDS (Java 12+), AppCDS, AOT cache (Java 24 JEP 483, Java 25 JEPs 514 and 515), CRaC in some vendor builds.
Summary
- HotSpot profiles in the interpreter and C1, then compiles hot methods with C2 using those profiles.
- Compiled code depends on assumptions; when one fails the JVM deoptimizes and recompiles.
-XX:+PrintCompilation,-Xlog:safepoint, and JFR show compilation, deoptimization, and safepoint behavior.- Warm up with realistic traffic before reporting ready, and use CDS or the AOT cache to cut startup time.
