1
0
Files
zgc-jdk25-benchmarks/docs/01-generational-model.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

116 lines
5.9 KiB
Markdown

# 1. The generational model, and what JDK 25 actually changed
> Companion notes for [Generational ZGC on JDK 25: Benchmarks vs G1](https://ankurm.com/generational-zgc-jdk-25-vs-g1/).
> Everything here is verified against `java 25.0.3+9-LTS-195`.
## 1.1 The one-paragraph history
| Release | What happened |
|---|---|
| JDK 11 | ZGC arrives as an experimental, **non-generational** collector (JEP 333). One generation, whole-heap marking every cycle. |
| JDK 15 | ZGC becomes a production feature (JEP 377). |
| JDK 21 | **Generational ZGC** ships as an opt-in mode behind `-XX:+ZGenerational` (JEP 439). |
| JDK 23 | Generational becomes the **default** (JEP 474). `-XX:-ZGenerational` still gets you the old one. |
| JDK 24 | The non-generational mode is **removed** (JEP 490). `ZGenerational` becomes an obsolete flag. |
| JDK 25 | First **LTS** where ZGC is generational-only. This is the release most teams will actually adopt. |
Verify the removal yourself — the JVM says so out loud:
```console
$ java -XX:+UseZGC -XX:+ZGenerational -version
Java HotSpot(TM) 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
java version "25.0.3" 2026-04-21 LTS
```
That warning matters operationally: if your start-up scripts still carry `-XX:+ZGenerational` from a
JDK 21 experiment, JDK 25 will *not* fail fast. It warns and continues. Grep your deployment
manifests.
## 1.2 What "generational" buys, concretely
The weak generational hypothesis: most objects die young. A collector that exploits it can reclaim
the young generation without looking at the old one.
Non-generational ZGC could not do that. Every cycle marked the **entire** heap. With a 600 MB live
set and a 300 MB/s allocation rate, that means repeatedly tracing 600 MB of objects that you
already know are alive, in order to reclaim garbage that lives in a few megabytes of recently
allocated pages.
Generational ZGC splits this into:
- a **minor (young) collection** that marks and relocates only young pages, and
- a **major (old) collection** that additionally handles the old generation, run far less often.
You can see the split in the JMX bean names, which is the cheapest possible proof of what you are
running:
```console
collector : ZGC Minor Cycles + ZGC Minor Pauses + ZGC Major Cycles + ZGC Major Pauses
```
Four beans, not two. Under non-generational ZGC there was one cycle bean and one pause bean.
`env/Env.java` in this repository prints exactly this and derives `generational? yes`.
## 1.3 The cost of the split: you now need a write barrier
There is no free lunch. To collect young without scanning old, the collector must know about every
**old-to-young reference**. Those are created by ordinary application code:
```java
oldCache[i] = new Payload(); // an old object now points at a brand-new young object
```
If a young collection ignored `oldCache`, it would conclude `new Payload()` is unreachable and free
a live object.
So generational ZGC added a **store barrier** — code the JIT injects around reference stores, which
records the cross-generational edge. Non-generational ZGC had *no* store barrier at all; it only
had a load barrier. This is the single biggest structural change in JEP 439, and it is why
generational ZGC has slightly different throughput characteristics from the old one on
store-heavy code. See [02-barriers.md](02-barriers.md).
You can see the mechanism named in the VM's own diagnostic flags:
```console
$ java -XX:+UseZGC -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | findstr Z
bool ZBufferStoreBarriers = true {diagnostic} {default}
int ZTenuringThreshold = -1 {diagnostic} {default}
bool ZUseMediumPageSizeRange = true {diagnostic} {default}
```
- `ZBufferStoreBarriers` — the store barrier does not update a remembered set inline; it appends to
a per-thread buffer that is drained later. That is what keeps the barrier cheap.
- `ZTenuringThreshold = -1`**adaptive**. ZGC ages objects like any generational collector, but
unlike G1's `MaxTenuringThreshold` (default 15, a documented product flag), ZGC's is a diagnostic
flag that defaults to "let the collector decide". Pinning it is almost always a mistake; knowing
it exists is occasionally the answer to "why did my promotion rate change after an upgrade".
## 1.4 Where the generations physically live
ZGC does not have a contiguous eden / survivor / old layout the way Parallel or (loosely) G1 does.
Generation membership is a property of a **page**, and pages are 2 MB / medium / large units
scattered across the reserved address space. A young page whose objects survive enough cycles is
*promoted* by relocating its live objects into an old page.
This has a practical consequence people trip over: **there is no `-Xmn` for ZGC**. You cannot size
the young generation. ZGC decides how much of the heap is young dynamically, based on allocation
rate and how much headroom `SoftMaxHeapSize` leaves it. If you are used to tuning G1 with
`-XX:NewRatio` or `-XX:G1NewSizePercent`, that entire toolbox is gone. The ZGC equivalent of "give
the young generation more room" is "give the heap more room, or lower `SoftMaxHeapSize` so
collection starts earlier".
## 1.5 What this means for a migration
| If you relied on… | On generational ZGC you get… |
|---|---|
| `-Xmn`, `-XX:NewRatio`, `-XX:SurvivorRatio` | Nothing. Ignored or unavailable. Size the whole heap instead. |
| `-XX:MaxTenuringThreshold` | `ZTenuringThreshold`, diagnostic, adaptive by default. |
| `-XX:+ZGenerational` | An obsolete-flag warning. Remove it. |
| GC pause charts as your latency SLI | A misleading green dashboard. See [03-allocation-stalls.md](03-allocation-stalls.md). |
| `-XX:+UseStringDeduplication` | Still supported on ZGC (since JDK 18), not G1-only any more. |
---
Next: [02-barriers.md](02-barriers.md) — colored pointers, load barriers, store barriers and
remembered sets, with the microbenchmark that prices each one.