JVM Scope

G1 versus ZGC: What the Pause Numbers Hide

Sub-millisecond pauses are real, and they are not the whole cost. What the two collectors actually trade, and which numbers to compare instead.

5 min read
G1 versus ZGC: What the Pause Numbers Hide — Memory article cover

Every comparison of these two collectors opens with the same chart: G1’s pauses measured in tens of milliseconds, ZGC’s in hundreds of microseconds, the line flat across heap sizes. The chart is accurate. It is also the least interesting thing about the choice, because it shows the cost that was moved and not the cost that was paid.

Two different bargains

G1 divides the heap into equal-sized regions and collects them in stop-the-world evacuation pauses. It maintains per-region remembered sets so it can collect one region without scanning the whole heap, and it uses a pause-time goal — -XX:MaxGCPauseMillis, default 200ms — to decide how many regions to include in the next collection set. Miss the goal, collect fewer regions next time.

The bargain is that most of the work happens with the application stopped, where it is cheap to do. No concurrent-access barriers on reads, no coordination with mutating threads during evacuation. G1’s steady-state throughput is excellent, and its overhead is concentrated into windows you can see.

ZGC does nearly everything concurrently. It stores metadata in unused bits of the object pointer — coloured pointers — and installs a load barrier on every reference read. When the application loads a reference whose colour is stale, the barrier fixes it on the spot: remapping it to the object’s new location, marking it, or both. The generational mode adds store barriers to track references between the young and old generations. Because relocation is repaired lazily by the threads that touch the references, the collector never needs to stop the world to move objects. Pauses become a few fixed operations that do not scale with heap size or live set.

The bargain is that the work did not disappear. It runs on the same CPUs as your application, and every reference load carries a barrier.

The cost that moved

Three consequences follow, and none appear in a pause histogram.

Throughput. The load barrier is cheap but not free, and concurrent marking and relocation consume CPU that would otherwise run application code. On a machine that is already CPU-saturated, moving GC work off the pause and into concurrent threads does not reduce it — it converts a visible stop into a general slowdown.

Footprint. A concurrent collector must tolerate everything the application allocates while a cycle runs. That headroom is not optional; it is the mechanism. ZGC also carries per-object metadata costs that G1 does not. A heap sized for G1 will usually need to grow before ZGC behaves well in it.

Allocation stalls. This is the one that matters. If the allocation rate outpaces the rate at which the concurrent cycle can free memory, a thread requesting memory has nowhere to put the object and blocks until the collector catches up. The pause histogram stays flat — this is not a GC pause — while the application’s p99.9 latency climbs into the seconds. It is the ZGC failure mode, and it is invisible to anyone measuring only pauses.

The equivalent on G1 is the full GC: a fallback, single-threaded in older releases and parallel since JDK 10, that appears when evacuation fails because there is no free region to copy into. Both failure modes have the same underlying cause — the collector lost the race — and both are the thing to alert on.

Reading the logs

Unified logging gives both collectors the same interface:

java -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20M

For G1, the lines to watch are the pause records and their causes:

[3.214s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->98M(2048M) 14.203ms
[9.881s][info][gc] GC(31) Pause Young (Mixed) (G1 Evacuation Pause) 1204M->402M(2048M) 42.117ms
[9.923s][info][gc] GC(32) Pause Young (Normal) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 1988M->1902M(2048M) 214.556ms

The suffix on that third line is the finding. Current JDKs append (Evacuation Failure: Allocation) — or Pinned, or both — to the pause name; older releases logged the same condition as a separate To-space exhausted line, which is the phrase most search results still show. Either way it means the collector had nowhere to copy into. Humongous allocations — objects larger than half a region, which get their own contiguous run of regions — are a common reason a heap with plenty of free space cannot find room. Raising -XX:G1HeapRegionSize is the direct fix; allocating smaller arrays is the better one.

For ZGC, look for the stall:

[42.118s][info][gc] Allocation Stall (worker-3) 128.442ms

Any occurrence of that line means the collector is behind. The response is more heap, more concurrent GC threads (-XX:ConcGCThreads), or a lower allocation rate — in roughly that order of ease, and the reverse order of effectiveness.

Also worth setting on ZGC: -XX:SoftMaxHeapSize, which tells the collector to try to stay under a soft limit while leaving the hard -Xmx available as emergency headroom. It converts “we sized the heap for the worst case and now it always uses that much” into a workable steady state.

What to compare instead

The measurement that answers the actual question is application response time under production-shaped load, at the percentiles you care about, with everything else held constant.

Compare p99 and p99.9 request latency, not GC pause percentiles. Compare CPU utilisation at the same throughput. Compare resident set size, not configured heap size. Run long enough to include at least several full concurrent cycles, and load the system hard enough that allocation rate is realistic — the difference between these collectors is invisible on an idle service and stark on a saturated one.

Generational ZGC, the default since JDK 23 and the only mode left after JDK 24 removed the non-generational one, narrows the throughput gap considerably by collecting young objects separately — which is where most garbage is. It does not remove the trade: the barriers are still there, the headroom is still required, and the stall is still the number to watch.

Neither collector is a default that survives contact with a specific workload. What survives is the habit of measuring the thing your users experience, rather than the thing the collector reports about itself.

Frequently asked

Is ZGC always the right choice for a latency-sensitive service?
It is the right default when tail latency matters more than throughput and the heap is large. On a small heap with modest allocation rates, G1 often delivers similar application latency with less CPU and a smaller footprint.
How much extra heap does ZGC need?
There is no single number, but planning for meaningfully more headroom than the equivalent G1 configuration is realistic. Collection is concurrent, so the heap must absorb everything allocated while a cycle is running.
Share

Related articles

Arrow keys to move, Enter to open.