Skip to content

JVM Safepoints Explained: What Stop-the-World Really Means

A GC log said 11.9 ms; my app froze for 1.5 s. How a JVM safepoint works, why one thread can stall them all, and what your collector changes.

jvm jvm-internals garbage-collection gc-tuning
Gopi Gorantala
Reading Progress

On This Page

11.9 ms in the GC log, 1,485 ms in reality

I used to read "stop-the-world" as "the garbage collector is running". Those are two different things, and the gap between them is where some of the nastiest pauses I've chased live.

Here are two lines from the same pause, same JVM, same run:

[5.044s] GC(2) Pause Full (System.gc()) 13M->3M(17M) 11.910ms
[5.044s] Safepoint "G1CollectFull", Reaching safepoint: 1485592621 ns, Cleanup: 20478 ns, At safepoint: 11952024 ns, Leaving safepoint: 26251 ns, Total: 1497591374 ns

(OpenJDK 21.0.12.1, Ubuntu 24.04, x86-64, 2 vCPU, G1 with -XX:-UseCountedLoopSafepoints, -Xlog:gc,safepoint. Lab numbers.)

The first line says the collection took 11.9 ms. The second says every Java thread in the process was frozen for 1,497 ms, and that 1,485 ms of it was spent getting to the point where the collection could start. The collector did its job in 12 ms. It waited one and a half seconds for permission.

That wait has a name, time-to-safepoint, and a JVM safepoint is the thing it's measuring. This article covers what a safepoint is, why one stubborn thread can hold up the entire process, which collectors protect you from that by default on JDK 21 (it surprised me, and it's not the one you'd guess), and the flags and logs that catch it.

In one paragraph

A safepoint is a moment when every Java thread has parked itself at a spot where the JVM knows exactly what it's holding: which stack slots are references, what state the code is in. Only then can the JVM do things that need a consistent picture of the world, like moving objects, tossing out compiled code, or dumping all the stacks. The JVM can't yank a running thread to a stop. It asks nicely, flips a flag, and each thread stops when it next looks at the flag. Time-to-safepoint is how long the slowest thread takes to look. Everything else in the pause waits on that thread.

So there are really three costs hiding inside one "pause": getting everyone stopped, doing the work, and letting everyone go. GC logs mostly show you the middle one.

Make the JVM show you

You need one JDK. I used OpenJDK 21.0.12.1 (Ubuntu package, build 21.0.12.1+1-1-24.04.4). Shenandoah is included in that build, which matters for step 6. Check yours with java -version and java -XX:+UnlockExperimentalVMOptions -XX:+UseShenandoahGC -version; if the second one errors, skip step 6.

The code. One thread runs a counted loop with a huge trip count and no method calls. The main thread asks for a GC and times how long the request takes. Save as TtspLab.java:

public class TtspLab {
    static volatile boolean stop;
    static long sink;

    static long spinInt(int n) {
        long s = 0;
        for (int i = 0; i < n; i++) { s += i ^ (s >>> 3); } // counted loop: int index, no calls, no safepoint poll unless C2 adds one
        return s;
    }

    public static void main(String[] a) throws Exception {
        Thread worker = new Thread(() -> {
            while (!stop) { sink += spinInt(Integer.MAX_VALUE); }
        }, "worker");
        worker.setDaemon(true);
        worker.start();
        Thread.sleep(3000);                 // give C2 time to compile spinInt
        long t0 = System.nanoTime();
        System.gc();                        // needs a safepoint
        System.out.println("System.gc() took " + (System.nanoTime() - t0) / 1_000_000 + " ms");
        stop = true;
    }
}

The steps.

Step 1 - default G1
$ java -XX:+UseG1GC -Xmx128m -Xlog:gc,safepoint:file=sp.log:uptime TtspLab.java
Expected: "System.gc() took" about 20 ms.  grep G1CollectFull sp.log shows Reaching safepoint around 150000 ns (0.15 ms).

Step 2 - take the loop polls away
$ java -XX:-UseCountedLoopSafepoints -XX:+UseG1GC -Xmx128m -Xlog:gc,safepoint:file=sp.log:uptime TtspLab.java
Expected: "System.gc() took" 1,400-1,700 ms. Reaching safepoint ~1.4-1.5 s. Pause Full still says ~12 ms.

Step 3 - make the JVM name the culprit
$ java -XX:-UseCountedLoopSafepoints -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=100 -Xmx128m TtspLab.java
Expected: warnings starting "SafepointSynchronize::begin: Timeout detected" followed by
  "Threads which did not reach the safepoint:" and a line for "worker".

Step 4 - same program, Parallel GC, default flags
$ java -XX:+UseParallelGC -Xmx128m TtspLab.java
Expected: about 1,400-1,700 ms. With no flags changed.

Step 5 - Serial and Parallel with the polls forced on
$ java -XX:+UseParallelGC -XX:+UseCountedLoopSafepoints -Xmx128m TtspLab.java
Expected: JVM prints "...LoopStripMiningIter must be at least 1 (a safepoint every 1 iteration): setting it to 1", then ~20 ms.

Step 6 - ZGC and Shenandoah, defaults
$ java -XX:+UseZGC -Xmx128m TtspLab.java
$ java -XX:+UseShenandoahGC -Xmx128m TtspLab.java
Expected: both around 15-30 ms.

(I ran every one of these. My numbers: G1 default 15-20 ms; G1 with -UseCountedLoopSafepoints 183 ms on the first, shorter-trip-count version and 1,500 ms once I raised the loop to Integer.MAX_VALUE; Parallel default 1,644 ms; Serial default 1,473 ms; ZGC 14 ms; Shenandoah 27 ms.)

Forcing levers. -Xmx128m is only there so System.gc() finishes in milliseconds. System.gc() is a handy way to demand a safepoint on a schedule; production safepoints come from real GCs and a dozen other sources. The giant trip count is the trick: a loop that runs for seconds with no calls is the worst case for the JVM's polling.

Confirmation signal. The number you're looking for is on the Reaching safepoint: field, not the Pause line. If your Reaching safepoint is a few hundred microseconds in step 2, C2 never compiled the loop. Raise the Thread.sleep to 6000 or check you're not running with -Xint.

Environment differences. Results depend on C2 having compiled spinInt. A busy laptop or a 1-vCPU container may compile later. I did not run this on 17 or 25.

Cleanup. Delete sp.log.

How a thread gets stopped, step by step

Here's the sequence, from the JVM's side. The thread that does the work is the VM thread, a single internal thread that runs VM operations: a GC, a class redefinition, a heap dump.

  1. Something wants a VM operation that needs a safepoint. It queues it.
  2. The VM thread arms the safepoint: it flips global state that says "everyone stop at the next poll".
  3. Each Java thread is somewhere in one of a few states, and each state has a different way of noticing:
    • Running interpreted code: it checks the flag between bytecodes.
    • Running compiled code: the compiler inserted polls at certain places, and a poll is a memory read from a special page. When the safepoint is armed, that page becomes unreadable, the read faults, and the fault handler parks the thread.
    • In native code (JNI, a blocking syscall): the VM doesn't wait. The thread is considered already safe, and it checks the flag when it tries to return to Java.
    • Already blocked (waiting on a lock, sleeping): safe by definition.
  4. The VM thread loops over the threads that haven't stopped yet. It backs off between passes, spinning briefly and then sleeping. This waiting is time-to-safepoint, the Reaching safepoint number in the log.
  5. When the last thread parks, the operation runs. This is the At safepoint number, and it's what the GC log reports.
  6. The VM thread disarms and everyone restarts (Leaving safepoint).

The important part is step 3, the compiled-code branch. Polls are placed on method returns and on loop back-edges. But a poll on every iteration of a tight loop is expensive, so C2 has long been willing to leave it out of a loop it can prove is counted: an int index with a known stride and a bound the loop doesn't change. That is a perfectly good bet until the loop runs for 1.5 seconds.

The fix that shipped is called loop strip mining. C2 splits the long loop into an outer loop with a poll and an inner loop without, so a poll happens every LoopStripMiningIter iterations (1,000 by default on JDK 21) instead of every iteration or never. Cheap, and bounded.

Inside HotSpot, and a default I didn't expect

My dead end here cost me an afternoon. I'd read the classic counted-loop warning in a handful of old posts, wrote TtspLab, ran it on JDK 21 with G1, and got a 20 ms System.gc(). I assumed the bug had been fixed everywhere and went looking for a different demo. I'd also gone as far as thinking my C2 wasn't compiling the loop.

It was compiling it. Run PrintFlagsFinal per collector and look at two flags:

G1          LoopStripMiningIter = 1000   UseCountedLoopSafepoints = true
Z           LoopStripMiningIter = 1000   UseCountedLoopSafepoints = true
Shenandoah  LoopStripMiningIter = 1000   UseCountedLoopSafepoints = true
Parallel    LoopStripMiningIter = 0      UseCountedLoopSafepoints = false
Serial      LoopStripMiningIter = 0      UseCountedLoopSafepoints = false

(OpenJDK 21.0.12.1, -XX:+PrintFlagsFinal, both flags are C2 product flags.)

The protection is a per-collector default. G1, ZGC and Shenandoah turn counted-loop polling on and strip-mine. Serial and Parallel don't. On Parallel GC with no tuning at all, the same program froze the process for 1,644 ms, and on Serial 1,473 ms. Add -XX:+UseCountedLoopSafepoints to Parallel and it drops to 23 ms, with a warning that it's setting LoopStripMiningIter to 1 (it also works the other way: ask for strip mining on Serial and the JVM prints "Disabling counted safepoints implies no loop strip mining" and sets it back to 0).

I don't have a source link for why the defaults are split this way. My guess is cost: Serial and Parallel are the throughput collectors and never pay for the poll unless you ask, whereas the low-latency collectors can't afford an unbounded TTSP. That's a guess. I checked JDK-8192977 on the bug tracker, which is a duplicate of JDK-8192908, "-XX:+UseCountedLoopSafepoints alone doesn't disable strip mining with G1", and it confirms the 1,000 default but doesn't explain the collector split. If you know the origin, I'd like to hear it.

A few more pieces from the source, src/hotspot/share/runtime/safepoint.cpp in openjdk/jdk. SafepointSynchronize::begin() arms the safepoint and calls synchronize_threads(), which is the loop in step 4. That loop calls a back_off() helper: fine-grained nanosleeps for roughly the first millisecond, then longer sleeps. A comment in arm_safepoint() enumerates the thread states from step 3 almost exactly as I listed them. If the wait exceeds SafepointTimeoutDelay, print_safepoint_timeout() produces the "Threads which did not reach the safepoint" report you saw in step 3. That report is the best free diagnostic the JVM gives you for this problem, and it's off by default.

What changed from the old days.

JDK 8      -XX:+PrintGCApplicationStoppedTime, -XX:+PrintSafepointStatistics (legacy flags)
JDK 9      unified logging: -Xlog:safepoint replaces the PrintSafepoint* family
JDK 10     JEP 312, thread-local handshakes: stop one thread without stopping all of them
JDK 10     C2 loop strip mining available (JDK-8186027)
JDK 21     what I measured above

I'm confident about JEP 312 (I read it: delivered in JDK 10) and about unified logging existing since 9. I did not confirm the exact release where strip mining became the default for G1, only that 21.0.12.1 has it on, so I'm not putting a version on that.

Handshakes. Before JDK 10 any VM operation that needed to look at one thread's stack had to stop everything. A handshake instead runs a callback on a chosen set of threads, even one, while everyone else keeps going. Run -Xlog:handshake*=debug and call Thread.getStackTrace() on another thread:

[1.053s] Handshake "GetStackTraceClosure", Targeted threads: 1, Executed by requesting thread: 0, Total completion time: 203074 ns

One thread, about 0.2 ms, no Safepoint line next to it. That's a handshake, and it didn't freeze the process. The JEP's motivation list includes revoking biased locks and cheaper stack sampling.

How people misread it

"The GC log says 12 ms, so GC isn't the problem." It might not be the collector. It might not be anything the GC log can see. Compare the log's pause against Total: in the safepoint line, or against the "application threads were stopped" figure. The gap is time-to-safepoint plus cleanup, and it's invisible if you only log gc.

"Stop-the-world" and "GC" as synonyms. Safepoints are used for much more. I saw one Cleanup safepoint land every second on a busy run and once on an idle one; the cadence matches GuaranteedSafepointInterval, a diagnostic flag that defaults to 1000 ms on 21. I haven't read the code that uses it, so I'm reporting a match, not a mechanism. The one I measured cost about 0.15 ms in total. Other triggers are deoptimization, class redefinition by agents and heap dumps. (I'll leave the exact list to the deoptimization article, which owns that mechanism.)

Blaming the loop only. A counted loop isn't the only way to stall a safepoint. A single System.arraycopy of a 1 GiB array held up System.gc() for 136 to 215 ms in my lab (G1, defaults, -Xmx3g, three attempts, Reaching safepoint at 189, 122 and 119 ms). A copy that big is one uninterruptible stretch of machine code from the VM's point of view, and the loop strip-mining trick doesn't apply, because it's not a loop the compiler can slice. JNI critical regions and big Arrays.fill calls are in the same family of suspects; I haven't reproduced those two, so I'm only naming them.

The flags everyone copies.

  • -XX:+DisableExplicitGC. It stops System.gc() from collecting. It doesn't remove one safepoint cause out of many, and if you rely on direct buffers, it can get you in trouble. Not a safepoint fix.
  • -XX:+PrintSafepointStatistics. It was the JDK 8 answer. On modern JDKs -Xlog:safepoint replaced it. I haven't tested the legacy flag on 21 and I'm not going to tell you what it does there.
  • -XX:GuaranteedSafepointInterval=0 to "stop idle safepoints". It's a diagnostic flag, and at the default I measured about a tenth of a millisecond per second. Leave it.

What to change, and what it costs

Order matters. Prove the cause first, then change the code, then the flags.

First, find out whether it's TTSP at all. With -Xlog:gc,safepoint on, a pause where Reaching safepoint dominates At safepoint is the signature. If you want the thread's name: temporarily add -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=<ms> (product flags, default off, default delay 10,000 ms on 21). The timeout prints a thread dump of the stragglers.

Then, in this order:

  1. Fix the code that doesn't poll. Break up the giant array copy into chunks. Add a method call, or a bounded outer loop, inside the long counted loop. Chunking works because a method boundary is a poll.
  2. Use a collector whose defaults protect you. On JDK 21 that means G1, ZGC or Shenandoah rather than Parallel or Serial, if you have long counted loops you can't edit. Cause before cure: the protection is the polls those collectors enable by default, not anything about the collector's algorithm.
  3. Turn polling on yourself with -XX:+UseCountedLoopSafepoints (C2 product flag; default false on Serial/Parallel, true on G1/Z/Shenandoah on 21.0.12.1). Cost: a poll in the loop, bounded by LoopStripMiningIter, which slows tight numeric code. In my lab the loop ran at the speed I needed, but I never benchmarked it, so test on your hot paths before you ship it.
Option Default (JDK 21, G1/Z/Shenandoah) Default (Serial/Parallel) Status What it costs
-XX:+UseCountedLoopSafepoints true false C2 product polls in counted loops
-XX:LoopStripMiningIter 1000 0 C2 product poll every N iterations; smaller = more overhead
-XX:+SafepointTimeout false false product diagnostics only, prints thread dumps
-XX:GuaranteedSafepointInterval 1000 ms 1000 ms diagnostic leave alone

What isn't a fix: raising -Xmx, tuning -XX:MaxGCPauseMillis, and swapping collectors blindly. None of them touch time-to-safepoint unless they change the poll default.

What to alert on

Leave this on in production. It's cheap, and it's the only way you'll catch TTSP:

-Xlog:gc,safepoint:file=gc.log:uptime,level,tags:filecount=5,filesize=20m

Then graph two numbers from the safepoint lines: Reaching safepoint, and Total minus At safepoint. I'd page on Reaching safepoint above 100 ms from a service with a 200 ms budget, and look at anything above 10 ms on a latency path; those are my thresholds, not measured production values.

Keep the timeout flags to staging.

Which JDK versions behave how. On 21.0.12.1 the polling default is per collector, as above. I did not run on 17, 22 or 25, so I can't tell you whether that default changed. The JDK page I fetched claims JDK 25 is current GA as of its last update (2025-12-04), which is clearly older than today, so I wouldn't trust it for "latest" either. When you move LTS to LTS, re-run TtspLab with your production collector flags and your real -XX: set, and compare Reaching safepoint. Two minutes, and it tells you whether a default flipped.

The short list

  • A safepoint is where threads stop because they notice a flag. The slowest thread to notice sets the pause.
  • A GC log's pause is the middle of three phases. Log safepoint next to gc or you can't see the first.
  • On JDK 21.0.12.1, G1, ZGC and Shenandoah poll in counted loops by default. Serial and Parallel don't, and the same program froze for about 1.5 s on them.
  • Big array copies and similar non-loop stalls exist. Counted loops aren't the only suspect.
  • -XX:+SafepointTimeout names the guilty thread. Use it once, in staging.

Quick answers

What is a safepoint in Java? A point where all Java threads are stopped in a state the JVM can reason about, so it can run an operation that needs a consistent view of the heap and stacks: a GC, a thread dump, a class redefinition. Threads stop themselves when they reach a poll.

What is time-to-safepoint? The time between the JVM requesting a safepoint and the last thread reaching it. In -Xlog:safepoint output it's the Reaching safepoint field.

Is stop-the-world the same as a GC pause? No. GC is the most common reason for a safepoint, but the pause also includes time to bring threads to a stop, and other operations use safepoints too. See the GC log field guide for reading the lines, and long GC pauses for a triage tree.

Do ZGC and Shenandoah have safepoints? Yes, they're still JVM threads that stop at polls. I didn't log their safepoints, so I can't tell you what each one stops for. What my lab showed is both finishing System.gc() in 14 and 27 ms with the long loop running. See ZGC and Shenandoah.

jvmjvm-internalsgarbage-collectiongc-tuning

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments