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
6.1 KiB
2. Colored pointers, load barriers, store barriers, remembered sets
Runnable companions:
jmh/.../LoadBarrierBench.java,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,
RSSno 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=1to 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 §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 — the ZGC failure mode that pause charts cannot see.