Skip to main content

Generational ZGC on JDK 25: Benchmarks vs G1 (Beginner to Advanced)

A beginner-to-advanced guide to Generational ZGC on JDK 25 — the first LTS where it is the only ZGC. Colored pointers, load and store barriers, and a measured comparison against G1: ZGC’s p99.9 of 1.4 ms vs G1’s 95.2 ms, 1.5 ms of total freeze time vs 1.14 s, plus the failure modes and throughput costs nobody charts — allocation stalls, disabled compressed oops, and the promotion crossover where ZGC wins throughput too.

Every Java service has a number it is judged on that is not the average. It is the p99, or the p99.9, or the one customer whose checkout took four seconds. And for a very long time, the honest answer to “why did that request take four seconds?” was: because the garbage collector stopped your application, and that request was unlucky enough to arrive during the stop. JDK 25 is the first long-term-support release in which the collector built specifically to eliminate that answer — ZGC — ships in its finished form. Generational mode is no longer an experiment behind a flag, or a default with a fallback. It is the only ZGC there is. That makes JDK 25 the release where a lot of teams will actually evaluate it for the first time. This post is that evaluation, done carefully. It starts from what a garbage collection pause physically is, builds up through colored pointers, load barriers and the store barrier that generational mode required, and ends at the measurements — including the ones that are unflattering to ZGC, because a comparison that only reports the flattering ones is marketing. Every number below was produced on a live JDK 25.0.3, and the complete, runnable suite that produced them is in the companion repository.
Status (July 2026): Generational ZGC arrived as an opt-in mode in JDK 21 (JEP 439), became the default in JDK 23 (JEP 474), and the non-generational mode was removed in JDK 24 (JEP 490). JDK 25 is the first LTS release carrying that final state. Everything here was verified against java 25.0.3+9-LTS-195.

What a GC pause actually costs, and why the usual chart lies about it

Start with the mechanical picture, because everything else follows from it. A tracing garbage collector has to answer one question: which objects are still reachable? To answer it, it walks the object graph. The problem is that your application is simultaneously changing that graph. The oldest and simplest solution is to stop the application entirely while the collector works. That is a stop-the-world pause: every application thread is frozen, at a safepoint, until the collector says otherwise. G1 — the default collector since JDK 9 — does most of its marking concurrently, but still does its evacuation (copying live objects out of a set of regions) in a stop-the-world pause. The pause is proportional to how much live data has to be copied. Give G1 a bigger heap with more live objects in it, and the pauses get longer. ZGC takes the opposite position: do essentially everything concurrently, including moving objects. Its stop-the-world pauses are proportional only to the number of GC roots — thread stacks, static fields — which does not grow with heap size. In practice they stay in the tens of microseconds whether the heap is 2 GB or 2 TB. Here is where the standard presentation of this goes wrong. Almost every ZGC article ends with a chart of pause times, flat and near zero, and lets you draw the conclusion that ZGC has therefore removed the latency problem. The chart is accurate. The conclusion is not, and the second half of this post is about why.
G1 — rare, large, correlated cost every thread frozen for the width of each red block — measured here: max 104.4 ms ZGC — constant, small, uncorrelated cost blue tint = barrier work on every reference access; three hairline pauses per cycle measured here: 106 pauses totalling 1.5 ms across 60 seconds, longest 0.054 ms

How ZGC moves objects while you are still using them

The mechanism is worth understanding, because every throughput difference in the benchmarks later traces back to it. A 64-bit JVM does not need all 64 bits to address the heap. ZGC uses the spare high bits of every heap reference as metadata — the pointer's color. The color encodes facts like has this reference been marked in the current cycle? and has the object it points at been relocated? The state therefore lives in the pointer, not in a side table. And that is what makes concurrent relocation possible. A traditional compacting collector must stop the world to fix up every reference to an object it moved. ZGC does not: it moves the object, records a forwarding entry, and lets each stale reference repair itself the first time it is read. The repair happens in the load barrier — a short instruction sequence the JIT injects at every read of a reference field.
One 64-bit heap reference colour bits object address marked? relocated? which cycle? load barrier: test the colour fast path — colour is good pointer used as-is, a few instructions slow path — stale pointer follow forwarding entry, self-heal the field, then continue
Two things follow that people routinely get wrong:
  • Only reference loads are taxed. Reading an int, a long, or an element of an int[] costs nothing extra. ZGC does not tax all memory access — and there is a benchmark below that proves it to four significant figures.
  • The tax is permanent. It is paid whether or not a collection is in progress — less when the heap is clean, more during marking and relocation, but never zero.

Why generational mode needed a second barrier

Non-generational ZGC marked the entire heap on every cycle. With a 600 MB live set, that means repeatedly tracing 600 MB of objects you already know are alive, in order to reclaim garbage sitting in a few recently allocated pages. The weak generational hypothesis says most objects die young, so a collector should be able to reclaim the young generation without looking at the old one. But there is an obstruction: an old object may hold the only reference to a young object.
oldCache[i] = new Payload();   // an OLD object now points at a brand-new YOUNG object
If a young collection ignored oldCache, it would free a live object. So every generational collector intercepts reference stores and records the ones that create an old-to-young edge. G1 dirties a 512-byte card. Generational ZGC — which previously had no store barrier at all — had to grow one, feeding per-page remembered sets.
OLD generation pages oldCache[i] collected rarely YOUNG generation pages new Payload() collected often old → young reference store barrier fires on the write per-page remembered set (double-buffered bitmap) a young-only collection reads this instead of scanning all of old
That store barrier is the reason generational ZGC has slightly different throughput characteristics from the old one, and it is directly measurable — see the microbenchmarks further down. The mechanism is even named in the VM's diagnostic flags: ZBufferStoreBarriers is true by default, meaning the barrier appends to a per-thread buffer that is drained later rather than updating the remembered set inline.

Checking what you are actually running

Before benchmarking anything, confirm the collector. On JDK 25 the old flag does not fail — it warns and continues, which is exactly the behaviour that leaves stale flags in deployment scripts for years:
$ 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
Java(TM) SE Runtime Environment (build 25.0.3+9-LTS-195)
The cheapest programmatic proof of generational mode is the set of JMX beans. Non-generational ZGC exposed one cycle bean and one pause bean; generational ZGC exposes four:
collector : ZGC Minor Cycles + ZGC Minor Pauses + ZGC Major Cycles + ZGC Major Pauses
env/Env.java in the repository prints this and derives generational? yes from it, along with everything else a reproducible measurement needs.
The measurement trap that invalidates most GC comparisons. A benchmark that loops while(true) { doRequest(); } stops issuing load exactly when the system is least able to serve it. If a 100 ms pause freezes the application, the load generator does not send requests during those 100 ms — it records one slow request per thread and moves on. Gil Tene named this coordinated omission. The harness used here is open loop: each request has a fixed intended arrival time computed up front, and latency is measured from that intended time, not from when a thread got around to it. It records both numbers so the difference is visible rather than asserted. On the G1 run below, the gap between them is 2910x — a closed-loop harness would have reported G1's p99.9 as 0.033 ms instead of 95.169 ms, and concluded that G1 beat ZGC on tail latency. Full explanation in docs/06-methodology.md.

The benchmark, and what it deliberately does

The workload is an ordinary request handler: allocate a response buffer, read from a cache, and occasionally refresh a cache entry. The parameters are chosen to put the collector under the kind of pressure a real service applies, not the kind a microbenchmark applies:
  • ~585 MB live set in a 2 GB heap — 150,000 entries of 4 KB, held for the whole run. A benchmark that only allocates immediately-dead garbage measures the TLAB pointer-bump, which is identical on both collectors.
  • ~328 MB/s allocation rate at 20,000 requests/second.
  • 20% of requests overwrite a random cache slot, creating exactly the old-to-young references the store barrier exists for.
  • Four worker threads on a 12-thread machine. The workers busy-spin between arrivals to get sub-millisecond scheduling accuracy, so they must not occupy every core — starving ZGC's concurrent threads would measure the scheduler, not the collector.
  • -Xms2g -Xmx2g throughout, so heap resizing is not a variable.
$ java -XX:+UseZGC  -Xms2g -Xmx2g -cp out com.ankurm.zgc.latency.LatencyHarness
$ java -XX:+UseG1GC -Xms2g -Xmx2g -cp out com.ankurm.zgc.latency.LatencyHarness

Results: 60 seconds, 1.2 million requests

Response time by percentile — logarithmic scale ZGC G1 0.01 ms 0.1 ms 1 ms 10 ms 100 ms p50 0.006 0.005 p99 0.029 8.042 p99.9 1.437 95.169 p99.99 23.675 126.949 max 32.178 133.018 identical at p50 and p90; every advantage ZGC has is past p99
MetricGenerational ZGCG1Ratio
p50 response time0.006 ms0.005 msG1 by 20%
p900.009 ms0.009 mstie
p990.029 ms8.042 ms277x
p99.91.437 ms95.169 ms66x
p99.9923.675 ms126.949 ms5.4x
max32.178 ms133.018 ms4.1x
mean0.016 ms0.388 ms24x
total stop-the-world time1.503 ms1,139.624 ms758x
longest single pause0.054 ms104.449 ms1934x
Full GCs / evacuation failures0 / 04 / 16
The shape matters more than any single number. The two collectors are indistinguishable up to p90. Everything ZGC buys is in the tail. If your service-level objective is a mean or a p95, this table is telling you to stay on G1 and spend the effort somewhere that will move a metric you actually report. The total-freeze-time row is the one to sit with. Over the same 60 seconds of the same workload, ZGC froze the application for a cumulative 1.5 milliseconds. G1 froze it for 1.14 seconds.

Where G1's time actually went

Parsing the unified GC logs breaks the total down. G1's 1,139 ms was not evenly distributed:
STOP-THE-WORLD PAUSES (this is application freeze time)
  count        : 68
  total        : 1,139.624 ms
  p50          : 12.857 ms
  max          : 104.449 ms

  by phase
    Pause Young (Normal)           n=17     total= 403.699 ms  max=103.308 ms
    Pause Young (Concurrent Start) n=11     total= 153.384 ms  max= 20.587 ms
    Pause Young (Prepare Mixed)    n=8      total= 110.491 ms  max= 24.639 ms
    Pause Young (Mixed)            n=11     total= 247.284 ms  max= 30.879 ms
    Pause Remark                   n=10     total=  13.736 ms  max=  1.745 ms
    Pause Cleanup                  n=9      total=   2.472 ms  max=  0.438 ms
    Pause Full                     n=2      total= 208.558 ms  max=104.449 ms
Two Full GCs, 208 ms between them, plus 16 evacuation failures. G1 ran out of space to copy survivors into, and had to compact. That is G1's failure mode under sustained allocation pressure with a large live set, and it is what produced the 133 ms worst case. ZGC's log over the same workload:
STOP-THE-WORLD PAUSES (this is application freeze time)
  count        : 106
  total        : 1.503 ms
  max          : 0.054 ms

  by phase
    Pause Mark Start             n=22     total=   0.351 ms  max=  0.054 ms
    Pause Mark Start (Major)     n=8      total=   0.139 ms  max=  0.021 ms
    Pause Mark End               n=38     total=   0.683 ms  max=  0.044 ms
    Pause Relocate Start         n=38     total=   0.330 ms  max=  0.021 ms
Three pauses per cycle, none above 54 microseconds.
The category error that makes ZGC look terrible on your dashboard. The same ZGC log also contains Concurrent Mark 894 ms and Concurrent Relocate 1,092 ms. Those are not pauses — the application ran throughout. A script that sums every duration in a ZGC log reports about two seconds of “GC time” for a run whose actual freeze time was 1.5 ms. The identical trap exists in JMX: GarbageCollectorMXBean.getCollectionTime() on the ZGC Minor Cycles and ZGC Major Cycles beans reports concurrent cycle duration, while the ZGC ... Pauses beans report real freeze time. G1's beans are all pauses except G1 Concurrent GC. A dashboard that sums getCollectionTime() across all beans will conclude ZGC is a thousand times slower than it is — which is how ZGC acquires a reputation for being slow on teams that never updated their query. The parser in analysis/GcLogParser.java keeps the two categories in separate buckets on purpose.

ZGC's own failure mode: allocation stalls

ZGC's p99.99 above was 23.675 ms. Its longest pause was 0.054 ms. Those two facts are both true, and reconciling them is the most useful thing in this post. When an application allocates faster than the collector can reclaim, a thread that asks for memory and cannot be given a page is stalled until the collector frees one. An allocation 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.
The GC log for that ZGC run contains 40 allocation stalls. They are the p99.99. To see the effect cleanly, the repository includes a demo that deliberately drives a 512 MB heap into the ground while recording jdk.ZAllocationStall via JFR, then reads its own recording back:
$ java -XX:+UseZGC -Xms512m -Xmx512m -Dseconds=20 \
       -cp out com.ankurm.zgc.tuning.AllocationStallDemo

young collections : 1,771
old collections   : 217
ALLOCATION STALLS : 32,200

total time threads spent stalled : 139,439 ms
mean stall                       : 4.33 ms
longest stall                    : 48.32 ms

stalls by ZGC page type
  Small    count=32,200   total=139,439 ms   mean=4.33 ms

ten longest stalls
  duration     page     size       thread
     48.32 ms  Small    2097152    allocator-3
     47.99 ms  Small    2097152    allocator-10
139 seconds of cumulative stalled thread time inside a 20-second run. The pause chart for that run is a flat line near zero.
The default that hides them. jdk.ZAllocationStall has a JFR threshold of 10 ms. Anything shorter is not recorded. In the run above the mean stall was 4.33 ms — so a default JFR profile would have discarded the majority of them and shown you a much healthier system than you have. If you run ZGC in production, enable the event with .withoutThreshold().withStackTrace(), or set threshold to 0 ms in your .jfc. With stack traces on you get the exact allocation site that stalled, which is far more actionable than any log line. Details and a ready-to-use configuration in docs/03-allocation-stalls.md.

A negative result worth publishing

The two flags everyone recommends for allocation stalls are SoftMaxHeapSize (start collecting earlier, keeping a reserve) and ZAllocationSpikeTolerance (assume the allocation rate can spike further than the default 2x). Running the identical stress with each of them:
ConfigurationAllocation stallsTotal stalled timeMean stall
baseline32,200139,439 ms4.33 ms
-XX:SoftMaxHeapSize=400m31,967138,521 ms4.33 ms
-XX:ZAllocationSpikeTolerance=532,442140,558 ms4.33 ms
Within noise. Neither helped at all. That is not a bug, and it is the useful part: those flags buy headroom for spikes. They cannot conjure heap that does not exist. Under sustained overload, the only fixes are a bigger heap or a lower allocation rate. Tuning guides present these flags as remedies for allocation stalls without distinguishing the two cases, and that distinction is the whole difference between a flag that works and an afternoon wasted.

The same pressure on G1

Running the identical stress under G1 produces zero allocation stalls — G1 has no such mechanism. Instead:
$ grep -c "Pause Full" logs/g1-stress.log
168

[22:54:20.134][0.843s] GC(35) Pause Full (G1 Compaction Pause) 511M->284M(512M) 51.329ms
[22:54:20.242][0.950s] GC(42) Pause Full (G1 Compaction Pause) 488M->284M(512M) 52.393ms
...
GC(1521) Pause Young (Mixed) (G1 Evacuation Pause) (Evacuation Failure: Allocation) 493M->493M(512M)
168 Full GCs in 20 seconds. The failure modes are mirror images: ZGC's is quiet, per-thread and invisible to pause metrics; G1's is loud, global and impossible to miss. G1's is easier to find. ZGC's is easier to live with — provided you are measuring it.

The throughput side: what the barriers actually cost

Latency is only half the trade. The barriers are a permanent throughput tax, and JMH microbenchmarks designed to isolate each one can put a number on it. Full sources in jmh/.

The load barrier, isolated

LoadBarrierBench walks a shuffled one-million-node linked structure — one reference load, and therefore one barrier, per step — and compares it against summing an int[] of the same length: same element count, zero reference loads, zero barriers. The node order is shuffled deliberately, because a “random” pointer chase that is secretly sequential measures the hardware prefetcher rather than the collector.
BenchmarkWhat it loadsZGCG1ZGC cost
sumPrimitives1M ints — no barrier on either collector270.58 µs270.55 µs0.01%
readSameReferenceRepeatedlyone already-good reference, 1024x0.023 µs0.022 µs4.5%
chaseReferences1M reference loads through a shuffled graph92,329 µs82,081 µs12.5%
The first row is the one that makes the rest trustworthy. Summing an int[] takes 270.58 µs on ZGC and 270.55 µs on G1 — a 0.01% difference, far inside the error bars. That is the control working: ZGC taxes reference loads and only reference loads. Anyone who tells you ZGC slows down all memory access is contradicted by that row. The third row is the price. On a workload that does nothing but chase pointers through a large object graph, ZGC is 12.5% slower. That is close to a worst case — real code interleaves arithmetic, primitive access and method calls between reference loads — but it is the right number to have in mind before moving an analytics or graph-traversal workload to ZGC. The middle row shows the floor: when the pointer is already good, the barrier is a test-and-branch, and costs about 4.5%.

The store barrier, isolated

StoreBarrierBench compares four stores into a long-lived array, differing only in whether they create a cross-generational edge:
BenchmarkStoreOld→young edge?ZGCG1
storePrimitiveIntoOldints[i] = inot a reference store1.879 ns2.202 ns
storeNullIntoOldarr[i] = nullno — barrier runs, records nothing3.239 ns1.943 ns
storeOldToOldarr[i] = arrno3.462 ns3.780 ns
storeOldToYoungarr[i] = new Object()yes6.582 ns4.934 ns
The primitive row is again the control — no barrier on either collector, and the two are within noise of each other. The interesting comparison is the last row against the second. On ZGC, a store that creates a cross-generational edge costs 2.0x a structurally identical store that does not; on G1, 2.5x. That multiplier is the remembered-set bookkeeping, cleanly separated from allocation cost and from the barrier's mere presence. In absolute terms G1's cross-generational store is cheaper here (4.934 ns vs 6.582 ns) — a reminder that generational ZGC's store barrier is new, and G1's has had a decade of tuning behind it. One detail that makes or breaks this benchmark: it walks slots with a stride of 4099 rather than sequentially. Sequential stores hit the same card or page repeatedly, and both collectors have an “already dirty” fast path that would hide almost all of the cost. That is the most common way a store-barrier microbenchmark accidentally measures nothing.

The crossover: where generational ZGC starts winning on throughput too

PromotionBench varies one thing — how many previously allocated objects stay reachable — and therefore how badly the “most objects die young” assumption is violated.
Live objects retainedZGC (ops/ms)G1 (ops/ms)Winner
0 — everything dies immediately13,90414,816G1 by 6.6%
4,096 — small working set13,63714,421G1 by 5.7%
262,144 — sustained promotion12,64111,452ZGC by 10.4%
The ranking inverts. On pure young garbage, G1's cheaper allocation path wins. Once enough objects survive long enough to be promoted, G1 degrades by 23% while ZGC degrades by only 9%, and ZGC comes out ahead. This is worth internalising, because it contradicts the usual summary that “G1 wins throughput, ZGC wins latency”. G1 wins throughput on the workload that suits generational collection best. On the workload that stresses it — caches, session stores, in-memory indexes, anything with a large slowly churning live set — ZGC wins on throughput as well. Raw allocation throughput, incidentally, is a tie: 113,180 vs 109,399 ops/ms for 64-byte objects, within about 3%.

Three things that surprise people

ZGC disables compressed oops

This is the largest hidden cost of switching, and it is rarely mentioned. On a heap under about 32 GB, HotSpot normally stores references as 32-bit offsets, roughly halving the space reference fields take. ZGC cannot, because it needs those high bits for the color:
# ZGC, 2 GB heap
bool UseCompressedOops = false  {product lp64_product} {ergonomic}

# G1, same 2 GB heap
bool UseCompressedOops = true   {product lp64_product} {ergonomic}
Every reference field costs 8 bytes instead of 4. A HashMap.Node goes from 32 bytes to 48. A reference-dense heap — a large in-memory graph, an ORM session cache, a parsed JSON DOM — can grow 30–40% on switching collector, before any latency benefit arrives. “We gave both 8 GB” is not a fair comparison if one of them needs 11 GB to hold the same objects.

A 4 MB array does not get a Medium page

ZGC allocates into three page classes: Small (2 MB pages, objects up to 256 KB), Medium (objects up to 4 MB), and Large (one page per object, never relocated). Allocating one object of each size and reading the resulting jdk.ZPageAllocation events back:
ZGC pages actually allocated while creating the objects above
  page type  count      total bytes
  Large      2          25,165,824
  Medium     1          33,554,432
Two Large pages — for the 16 MB object, and for the 4 MB one. A new byte[4 * 1024 * 1024] is 4 MB plus a 16-byte array header, which is over the medium-object limit, so it receives an 8 MB Large page of its own. A 2x footprint amplification caused by sixteen bytes. If you pool buffers, pool them slightly under the boundary.

G1's humongous threshold is lower than you think

At a 2 GB heap, G1HeapRegionSize is ergonomically 1 MB, so anything over 512 KB is humongous: allocated straight into the old generation, spanning whole regions, never eligible for the cheap young-collection path.
G1HeapRegionSize     : 1,048,576 bytes (1 MB)
=> G1 humongous threshold: objects larger than 524,288 bytes (512 KB) go straight to old gen
A service that reads 1 MB request bodies into a single array is generating humongous garbage on every request. -XX:G1HeapRegionSize=8m moves the threshold to 4 MB and is one of the few G1 flags that regularly delivers a large win. The repository covers a dozen more of these — virtual thread stacks living on the heap as StackChunk objects, ZUncommit silently disabling itself when -Xms == -Xmx, System.gc() meaning something very different on each collector, container sizing, reference processing — in docs/07-corner-cases.md.

So which one should you use?

G1 remains the right default. It is balanced, it needs no tuning to be adequate, and it wins the median. Move to ZGC when you have a stated tail-latency requirement that G1 is measurably missing — not because ZGC is newer. A decision procedure, stopping at the first yes:
  1. Is GC actually your problem? Take a JFR recording and compare jdk.GCPhasePause and jdk.ZAllocationStall against your request latency. Most “GC problems” are lock contention, downstream calls, or pool starvation, and changing collector will cost you a week and fix nothing.
  2. Small heap, and an SLO expressed as a mean or p95? Stay on G1.
  3. Hard p99 or p99.9 SLO in the single- or low-double-digit milliseconds that G1 is missing? This is the case ZGC was built for.
  4. Heap above about 32 GB with a large live set? G1's pauses scale with live data in the collection set; ZGC's do not. ZGC gets better as the heap grows.
  5. Can you afford the footprint? Budget 30–50% more memory, more if the heap is reference-dense. A ZGC heap sized like a G1 heap is a ZGC heap that stalls — and you will conclude, wrongly, that ZGC is bad.
  6. Throughput-bound batch job with a hot pointer-chasing loop? Measure first, and consider Parallel GC, which still beats both on raw throughput when pauses do not matter.
If you do switch, a defensible starting configuration:
-XX:+UseZGC
-Xms8g -Xmx8g                      # fixed heap: no resize noise
-XX:SoftMaxHeapSize=6g             # 25% reserve for allocation spikes
-Xlog:gc*,gc+alloc=debug:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m
-XX:StartFlightRecording=settings=profile,filename=app.jfr,maxsize=500m
plus a .jfc override setting jdk.ZAllocationStall to a 0 ms threshold. Without that last line you are flying blind on the one metric that matters.

Reproducing this

Every number in this post came from one script on one laptop — AMD Ryzen 5 5600U, 6 cores / 12 threads, 15.3 GB RAM, Windows 11, java 25.0.3+9-LTS-195. The absolute values are illustrative. What generalises is the relationship: G1 wins the median, ZGC wins the tail, and the gap widens as the live set grows. What does not generalise is any specific millisecond figure, which is why the suite is designed to be run against a workload shaped like yours.
git clone https://ankurm.com/git.app/asmhatre/zgc-jdk25-benchmarks.git
cd zgc-jdk25-benchmarks

pwsh scripts/run-all.ps1 -JavaHome 'C:\Program Files\Java\jdk-25.0.3'   # Windows
./scripts/run-all.sh /path/to/jdk-25                                    # Linux / macOS
Raw, unedited output for every run in this post is committed under results/.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.