> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# JVM Safepoints Explained: What Stop-the-World Really Means
- URL: https://www.ggorantala.dev/jvm-safepoints-stop-the-world/
- Published: 2026-11-06T11:26:26.000Z
- Updated: 2026-11-06T11:26:26.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: jvm, jvm-internals, garbage-collection, gc-tuning

## 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:

```log
[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`:

```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.**

```log
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:

```log
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.**

```log
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:

```log
[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](https://ggorantala.dev/jvm-deoptimization/?ref=ggorantala.dev), 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:

```bash
-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](https://ggorantala.dev/java-gc-logs-xlog/?ref=ggorantala.dev) for reading the lines, and [long GC pauses](https://ggorantala.dev/long-gc-pauses-java/?ref=ggorantala.dev) 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](https://ggorantala.dev/zgc-colored-pointers/?ref=ggorantala.dev) and [Shenandoah](https://ggorantala.dev/shenandoah-gc-explained/?ref=ggorantala.dev).