1
0
Files
zgc-jdk25-benchmarks/docs/03-allocation-stalls.md
Ankur e189da79ba Generational ZGC vs G1 on JDK 25: benchmarks, internals and captured results
Complete companion suite for the ankurm.com guide. Everything was compiled and
executed on java 25.0.3+9-LTS-195 (AMD Ryzen 5 5600U, Windows 11); every file
under results/ is unedited program output.

Code
  latency/    open-loop latency harness that measures from intended arrival, so
              coordinated omission is accounted for rather than hidden. Reports
              response time and service time side by side; the gap reached 2910x
              on G1.
  jmh/        four microbenchmarks isolating one mechanism each: TLAB allocation,
              the ZGC load barrier (with a primitive-load control), the write
              barrier (with null / old-to-old / primitive controls), and promotion
              pressure.
  tuning/     provokes ZGC allocation stalls and reads its own JFR recording back,
              grouped by page class. jdk.ZAllocationStall is enabled without a
              threshold, because the 10 ms default hides most stalls.
  internals/  ZGC page classes vs G1 humongous, read from the live VM via
              HotSpotDiagnosticMXBean and jdk.ZPageAllocation events.
  analysis/   unified GC log parser that keeps stop-the-world pauses and
              concurrent phases in separate buckets.
  env/        environment capture; proves generational mode from JMX bean names.

Docs
  Eight chapters covering the JEP 439/474/490 timeline, colored pointers and both
  barriers, allocation stalls, a full flag reference, logging and JFR, benchmark
  methodology, corner cases, and a decision procedure.

Headline result (60 s, 20k req/s, 2 GB heap, ~585 MB live)
  p50               ZGC 0.006 ms   G1 0.005 ms
  p99.9             ZGC 1.437 ms   G1 95.169 ms
  total STW time    ZGC 1.503 ms   G1 1,139.624 ms
2026-07-31 08:47:16 +05:30

6.4 KiB
Raw Permalink Blame History

3. Allocation stalls — the ZGC failure mode your dashboard cannot see

Runnable companion: tuning/.../AllocationStallDemo.java

3.1 The claim, and why it is misleading

Every ZGC article ends with the same chart: pause times, flat, under a millisecond, at any heap size. The chart is accurate. The implied conclusion — therefore ZGC has no latency problem — is not, because on ZGC, pauses are not where latency comes from.

ZGC's stop-the-world pauses are O(number of GC roots). They do not scale with heap size or live-set size, and they stay in the tens of microseconds. There are three of them per cycle: Pause Mark Start, Pause Mark End, Pause Relocate Start.

What actually hurts is different. When the application allocates faster than the collector can reclaim, a thread that requests memory and cannot be given a page is stalled until the collector frees one. That stall:

  • can last tens or hundreds of milliseconds,
  • blocks only the allocating thread, not the whole JVM,
  • and therefore never appears in a pause-time metric.

A ZGC service can report a p99.99 pause of 0.2 ms and a p99.99 request latency of 400 ms simultaneously. Both numbers are correct. If your only GC SLI is pause time, you will never find the second one.

3.2 Seeing it

AllocationStallDemo starts a JFR recording, deliberately drives a small heap into the ground, then reads its own recording back. Measured on this repository's reference machine (Ryzen 5 5600U, Windows 11, JDK 25.0.3), 512 MB heap, 15 seconds of allocation:

$ java -XX:+UseZGC -Xms512m -Xmx512m -Dseconds=15 \
       -cp out com.ankurm.zgc.tuning.AllocationStallDemo

young collections : 1,319
old collections   : 164
page allocations  : 30,768
ALLOCATION STALLS : 25,343

total time threads spent stalled : 100,989 ms
mean stall                       : 3.98 ms
longest stall                    : 49.01 ms

Read that again: 101 seconds of cumulative stalled thread time inside a 15-second run. Twelve allocator threads, each spending a large fraction of the run waiting for memory. The pause chart for this run is a flat line near zero.

3.3 The threshold that hides them

jdk.ZAllocationStall has a default JFR threshold of 10 ms. Anything shorter is not recorded. In the run above the mean stall was 3.98 ms — meaning a default JFR profile would have discarded the majority of them and reported a much healthier picture.

If you take one operational thing from this repository, take this:

recording.enable("jdk.ZAllocationStall").withoutThreshold();

or in a .jfc settings file:

<event name="jdk.ZAllocationStall">
  <setting name="enabled">true</setting>
  <setting name="threshold">0 ms</setting>
  <setting name="stackTrace">true</setting>
</event>

With stack traces on, you get the exact allocation site that stalled — which is usually far more actionable than any GC log line.

The same events are visible in the unified log without JFR:

-Xlog:gc+alloc=debug

produces Allocation Stall lines. The GcLogParser in this repository counts them.

3.4 The stalls have a shape

The demo groups stalls by ZGC page class, which turns "we are out of memory" into a diagnosis:

  • Small page stalls — ordinary allocation pressure. The fix is more heap, an earlier collection trigger, or allocating less.
  • Medium page stalls — objects in the 256 KB4 MB band. Often a buffer-pool or serialization layer. Frequently fixable in application code.
  • Large page stalls — one page per object. ZGC does not relocate large pages, so a heap with many long-lived large objects fragments in a way ZGC cannot compact away.

A run dominated by Large-page stalls has a completely different remedy from one dominated by Small, and no pause-time chart distinguishes them.

3.5 Mitigations, in the order you should try them

SoftMaxHeapSize — start collecting earlier

-XX:SoftMaxHeapSize=400m   # with -Xmx512m

ZGC's heuristic aims to keep the heap under SoftMaxHeapSize but is allowed to exceed it, up to -Xmx, rather than stall the application. Setting it below -Xmx therefore creates a deliberate reserve: normal operation targets the soft limit, and a spike can eat into the gap instead of hitting a wall. On JDK 25 it defaults to -Xmx, i.e. no reserve at all.

It is a manageable flag, so you can change it on a running JVM — genuinely useful during an incident:

$ jcmd <pid> VM.set_flag SoftMaxHeapSize 400m

ZAllocationSpikeTolerance — assume bigger spikes

-XX:ZAllocationSpikeTolerance=5     # default 2.0

ZGC predicts when to start a cycle from the observed allocation rate multiplied by this tolerance factor. The default of 2.0 assumes allocation can double. A bursty service — one that goes from idle to full throttle in a second — routinely violates that, and the collector starts a cycle too late. Raising the tolerance makes ZGC start earlier and more often: you pay CPU to buy headroom.

-XX:ZCollectionIntervalMinor / Major — collect on a clock

Both default to -1.0 (disabled). Setting ZCollectionIntervalMinor=1 forces a young collection every second regardless of allocation rate. This is a blunt instrument, but it is the right one for a service with a very spiky duty cycle where the rate-based heuristic keeps being surprised.

More heap

The honest answer, when you can afford it. ZGC trades memory for latency by design; a ZGC heap sized like a G1 heap is a ZGC heap that stalls.

3.6 What the same pressure looks like on G1

Run the identical demo under -XX:+UseG1GC and there are no allocation stalls, because G1 does not have that mechanism. Instead you get to-space exhaustion (an evacuation failure) followed by a Full GC — a genuine, whole-application, stop-the-world compaction that will show up in your pause chart as a multi-hundred-millisecond spike.

So the failure modes are mirror images:

ZGC G1
Symptom under memory pressure allocation stalls evacuation failure → Full GC
Affects the allocating thread(s) every thread
Visible in pause metrics no yes
Typical magnitude here 4 ms mean, 49 ms max, thousands of them hundreds of ms, few of them

G1's failure is louder and easier to find. ZGC's is quieter and easier to live with — provided you are actually measuring it.


Next: 04-tuning-reference.md.