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.
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.
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.
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
Metric
Generational ZGC
G1
Ratio
p50 response time
0.006 ms
0.005 ms
G1 by 20%
p90
0.009 ms
0.009 ms
tie
p99
0.029 ms
8.042 ms
277x
p99.9
1.437 ms
95.169 ms
66x
p99.99
23.675 ms
126.949 ms
5.4x
max
32.178 ms
133.018 ms
4.1x
mean
0.016 ms
0.388 ms
24x
total stop-the-world time
1.503 ms
1,139.624 ms
758x
longest single pause
0.054 ms
104.449 ms
1934x
Full GCs / evacuation failures
0 / 0
4 / 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:
Configuration
Allocation stalls
Total stalled time
Mean stall
baseline
32,200
139,439 ms
4.33 ms
-XX:SoftMaxHeapSize=400m
31,967
138,521 ms
4.33 ms
-XX:ZAllocationSpikeTolerance=5
32,442
140,558 ms
4.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:
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.
Benchmark
What it loads
ZGC
G1
ZGC cost
sumPrimitives
1M ints — no barrier on either collector
270.58 µs
270.55 µs
0.01%
readSameReferenceRepeatedly
one already-good reference, 1024x
0.023 µs
0.022 µs
4.5%
chaseReferences
1M reference loads through a shuffled graph
92,329 µs
82,081 µs
12.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:
Benchmark
Store
Old→young edge?
ZGC
G1
storePrimitiveIntoOld
ints[i] = i
not a reference store
1.879 ns
2.202 ns
storeNullIntoOld
arr[i] = null
no — barrier runs, records nothing
3.239 ns
1.943 ns
storeOldToOld
arr[i] = arr
no
3.462 ns
3.780 ns
storeOldToYoung
arr[i] = new Object()
yes
6.582 ns
4.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 retained
ZGC (ops/ms)
G1 (ops/ms)
Winner
0 — everything dies immediately
13,904
14,816
G1 by 6.6%
4,096 — small working set
13,637
14,421
G1 by 5.7%
262,144 — sustained promotion
12,641
11,452
ZGC 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:
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:
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.
Small heap, and an SLO expressed as a mean or p95? Stay on G1.
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.
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.
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.
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
zgc-jdk25-benchmarks — the complete companion repository: eight documentation chapters, four JMH benchmark classes, the open-loop latency harness, the allocation-stall demo, the GC log parser, and every raw result
No Comments yet!