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:
161
docs/03-allocation-stalls.md
Normal file
161
docs/03-allocation-stalls.md
Normal 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 KB–4 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).
|
||||
Reference in New Issue
Block a user