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

106
docs/02-barriers.md Normal file
View File

@@ -0,0 +1,106 @@
# 2. Colored pointers, load barriers, store barriers, remembered sets
> Runnable companions: [`jmh/.../LoadBarrierBench.java`](../jmh/src/main/java/com/ankurm/zgc/jmh/LoadBarrierBench.java),
> [`jmh/.../StoreBarrierBench.java`](../jmh/src/main/java/com/ankurm/zgc/jmh/StoreBarrierBench.java)
This is the chapter that explains *why* the numbers look the way they do. Every throughput
difference between ZGC and G1 traces back to one of the mechanisms below.
## 2.1 Colored pointers
A 64-bit JVM does not need 64 bits to address the heap. ZGC uses the spare high bits of every heap
reference as metadata — the "color". The colors encode facts like *has this pointer been marked in
the current young cycle?* and *has the object it points at been relocated?*
The consequence is that **the pointer, not a side table, carries the collector's state**. Two
objects can hold references to the same object with different colors, and each will be fixed up
independently the first time it is read.
This is the trick that lets ZGC relocate objects concurrently. Traditional compacting collectors
must stop the world to fix up every reference to a moved object. ZGC does not: it moves the object,
records a forwarding entry, and lets each stale reference repair itself on next read.
> **Multi-mapping vs. multi-mapping-free.** Older ZGC mapped the same physical heap at several
> virtual addresses so that colored pointers could be dereferenced directly. Modern ZGC (from JDK
> 22's "colored roots"/load-barrier rework onward) masks the color in the barrier instead. Practical
> effect: on Linux, `RSS` no longer looks like 3x the heap in tooling that misreads multi-mapped
> regions. If you have old runbooks explaining that "ZGC reports 3x memory, ignore it", they are
> obsolete.
## 2.2 The load barrier
Every time your code reads a **reference field**, the JIT emits a short sequence: test the color
bits, and on the slow path fix the pointer. Primitive loads (`int`, `long`, elements of `int[]`) are
untouched — a common misconception is that ZGC taxes all memory access. It does not. It taxes
reference loads.
`LoadBarrierBench` prices this by comparing two walks over the same number of elements:
| Benchmark | What it does | Barrier? |
|---|---|---|
| `chaseReferences` | walks a shuffled 1M-node linked structure, one reference load per step | yes, one per step |
| `sumPrimitives` | sums an `int[]` of the same length | none |
| `readSameReferenceRepeatedly` | reads one already-good reference 1024 times | yes, but always fast path |
The third one is the interesting control: it measures the **floor** of the barrier — what you pay
when there is nothing to fix. If `readSameReferenceRepeatedly` is close between ZGC and G1, the
fast path is nearly free and any gap in `chaseReferences` is the slow path plus cache effects.
Two things move these numbers a lot, and both are worth measuring on your own workload:
- **Whether a GC cycle is in progress.** The barrier does strictly more work during marking and
relocation. Force cycles with `-XX:ZCollectionInterval=1` to see the sustained-load case rather
than the idle-heap case.
- **Whether the walk is cache-resident.** A sequential walk over a small structure is dominated by
the prefetcher and hides the barrier entirely. That is why the benchmark shuffles the node order —
a "random" pointer chase that is secretly sequential measures the memory subsystem, not the GC.
## 2.3 The store barrier and remembered sets
Generational collection requires knowing about old-to-young references (see
[01-generational-model.md](01-generational-model.md) §1.3). The two collectors solve it differently
and the difference is measurable.
**G1: card table + concurrent refinement.**
The heap is divided into 512-byte *cards*. A reference store dirties the card containing the
modified field. Concurrent refinement threads later scan dirty cards and update per-region
*remembered sets* (which regions point into me?). G1's barrier is a two-part affair — a pre-write
barrier for SATB marking and a post-write barrier for card marking — and has historically been the
more expensive of the two collectors' barriers. JDK 24 landed *late barrier expansion* for G1
(JDK-8342382), which defers expanding the barrier into IR until late in C2 so the optimiser can see
through more of the surrounding code; JDK 25 continued reducing remembered-set memory.
**ZGC: store barrier + per-page remembered sets.**
Generational ZGC's store barrier appends to a thread-local buffer (`ZBufferStoreBarriers=true`),
which is drained into per-page remembered-set bitmaps. The bitmaps are **double-buffered**: one is
being written by the application while the other is consumed by the collector, which is how ZGC
avoids a stop-the-world handoff.
`StoreBarrierBench` separates "the barrier code ran" from "the barrier had work to do":
| Benchmark | Store | Creates old→young edge? |
|---|---|---|
| `storeOldToYoung` | `oldArray[i] = new Object()` | yes |
| `storeNullIntoOld` | `oldArray[i] = null` | no — barrier runs, records nothing |
| `storeOldToOld` | `oldArray[i] = oldArray` | no |
| `storePrimitiveIntoOld` | `oldIntArray[i] = i` | not a reference store; no barrier at all |
The benchmark walks slots with a stride of 4099 rather than sequentially. Sequential stores hit the
same card / same page repeatedly, and both collectors have an "already dirty" fast path that would
hide most of the cost. This is the single most common way a store-barrier microbenchmark
accidentally measures nothing.
## 2.4 Why this shows up as a throughput gap, not a latency gap
Barriers cost throughput: a few extra instructions on a very hot path. They do **not** cost latency,
because they are spread evenly across execution rather than concentrated into a pause.
That is the whole trade in one sentence: **ZGC converts a rare, large, correlated cost (a
stop-the-world pause) into a constant, small, uncorrelated cost (barrier work on every reference
access).** Whether that is a good deal depends entirely on whether your SLO is expressed as a mean
or as a tail percentile.
---
Next: [03-allocation-stalls.md](03-allocation-stalls.md) — the ZGC failure mode that pause charts
cannot see.