1
0

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
This commit is contained in:
2026-07-31 08:47:16 +05:30
commit e189da79ba
45 changed files with 12572 additions and 0 deletions

View File

@@ -0,0 +1,161 @@
# 3. Allocation stalls — the ZGC failure mode your dashboard cannot see
> Runnable companion: [`tuning/.../AllocationStallDemo.java`](../tuning/src/com/ankurm/zgc/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:
```console
$ 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:
```java
recording.enable("jdk.ZAllocationStall").withoutThreshold();
```
or in a `.jfc` settings file:
```xml
<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:
```console
-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:
```console
$ 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](04-tuning-reference.md).