Understanding Garbage Collection Pauses: Why Stop-the-World Still Happens and What the JVM Is Actually Doing
A conceptual guide to safepoints, GC roots, and why even modern collectors like ZGC and Shenandoah cannot always avoid pausing your application.
“Stop-the-world” sounds like a relic from an older generation of garbage collectors, something G1, ZGC, and Shenandoah were supposed to have solved for good. And in a very real sense, they have — modern collectors have pushed pause times down from seconds to single-digit milliseconds or less. But the phrase “pause-free” that shows up in marketing slides is doing a lot of quiet rounding. Every collector, no matter how advanced, still needs to stop your application threads occasionally. Understanding why reveals a lot about how the JVM actually manages memory underneath the abstraction.
What a GC Pause Actually Is
A garbage collection pause is not the JVM being slow. It’s the JVM deliberately freezing every application thread so it can inspect or move memory without anything changing underneath it mid-operation. Think of it like needing to relabel every box in a moving truck: you can’t safely do that while someone else is still loading and unloading boxes at the same time. For at least some part of the process, everyone has to stop moving boxes.
The length of that freeze is what most tuning conversations obsess over, but the more interesting question is why the freeze has to happen at all, even in collectors specifically engineered to avoid it.
Safepoints: The Real Reason Threads Stop
A safepoint is a point in a thread’s execution where its internal state is fully known and consistent — every register, every stack reference, every object pointer is exactly where the JVM expects it to be. The JVM cannot simply pause a thread at an arbitrary instruction, because the thread might be mid-way through updating a pointer, leaving memory in a state that’s unsafe to inspect.
Instead, the JVM waits until every thread reaches one of these well-defined checkpoints — typically at method return points, loop back-edges, and allocation sites — and only then does the actual pause begin. This is why a tight loop with no method calls can occasionally delay a GC pause longer than expected: the thread simply hasn’t reached a safepoint yet. This mechanism is part of why even a collector designed to do almost everything concurrently still needs a brief, coordinated moment where all threads agree to stop.
GC Roots: What the Collector Must Find Before It Can Move Anything
Garbage collection works by tracing reachability, not by counting references directly. It starts from a set of GC roots — local variables on the stack, static fields, active JNI references, and a handful of other well-known anchor points — and walks outward through every reference it finds. Anything reachable from a root survives; anything unreachable is garbage.
Root scanning has to happen while the world is at least briefly frozen, because the set of roots can change from one instruction to the next as a thread pushes and pops stack frames. Even collectors that perform the bulk of their marking and compaction concurrently with your application still need a short stop-the-world phase just to take an accurate snapshot of the roots before releasing the threads to run alongside the collector again.
Why Even ZGC and Shenandoah Aren’t Pause-Free
Both ZGC and Shenandoah were built specifically to decouple pause time from heap size, using concurrent marking and concurrent compaction so that most of the expensive work happens while your application keeps running. This is a genuine engineering achievement, and it’s why both collectors advertise pause times in the low single-digit milliseconds even on multi-terabyte heaps.
What neither collector eliminates is the short root-scanning pause described above, along with brief pauses to flip internal bookkeeping state between GC phases. The difference from older collectors isn’t zero pauses — it’s that the remaining pauses no longer scale with heap size, so a 4GB heap and a 400GB heap see roughly the same tiny pause instead of a proportionally larger one.
| Collector | Compaction style | Pause scales with heap size? |
|---|---|---|
| Serial | Fully stop-the-world | Yes |
| Parallel | Fully stop-the-world, multi-threaded | Yes |
| G1 | Mostly concurrent marking, stop-the-world evacuation | Partially (pause-target based) |
| ZGC | Concurrent marking and relocation | No |
| Shenandoah | Concurrent marking and compaction | No |

Comparing the Modern Low-Pause Collectors

G1 remains the JDK’s default for good reason: it balances throughput and pause time reasonably well for most general-purpose workloads and has years of production hardening behind it. ZGC and Shenandoah pull ahead specifically when heap sizes grow large and pause consistency matters more than raw throughput, such as latency-sensitive trading systems or large in-memory caches.
A Practical Checklist for Diagnosing Pause Problems
When a pause shows up somewhere it shouldn’t, check:
- Is this actually a GC pause, or a safepoint delay caused by a thread taking too long to reach a safepoint (a tight loop with no back-edges, for example)?
- Does the pause scale with heap size? If so, you’re likely still bound by a collector whose compaction isn’t fully concurrent.
- Are GC logs enabled with enough detail to distinguish root-scanning pauses from full evacuation pauses?
- Has the allocation rate spiked, forcing more frequent collection cycles regardless of collector choice?
- Would switching to a concurrent collector actually help, or is the real issue application-level allocation pressure that no collector can fully hide?
What We Learned
Stop-the-world pauses persist in every JVM garbage collector, including ZGC and Shenandoah, because safepoints and GC root scanning both require a brief, consistent snapshot of every thread’s state that simply cannot be taken while those threads keep running. What has genuinely changed is that modern collectors decoupled pause length from heap size, turning pauses that once scaled with gigabytes of live data into a near-constant, low-millisecond cost regardless of how large the heap grows. Understanding safepoints and GC roots reframes the tuning conversation: the goal was never to eliminate pauses entirely, but to make them small and predictable enough that they stop being the bottleneck.

