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

5.9 KiB

1. The generational model, and what JDK 25 actually changed

Companion notes for Generational ZGC on JDK 25: Benchmarks 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:

$ 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:

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:

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.

You can see the mechanism named in the VM's own diagnostic flags:

$ 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 = -1adaptive. 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.
-XX:+UseStringDeduplication Still supported on ZGC (since JDK 18), not G1-only any more.

Next: 02-barriers.md — colored pointers, load barriers, store barriers and remembered sets, with the microbenchmark that prices each one.