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
8.0 KiB
7. Corner cases and things that bite
The blog post covers the headline concepts. This chapter is the long tail — the behaviours that do
not fit a narrative but do show up in production. Every claim here was verified on
java 25.0.3+9-LTS-195; raw dumps in results/.
7.1 ZGC turns off compressed oops. This is the biggest hidden cost.
On a heap under ~32 GB, HotSpot normally stores references as 32-bit offsets rather than 64-bit pointers ("compressed oops"), roughly halving the space taken by reference fields. ZGC cannot do this, because it needs the high bits of every pointer for the color. Verified:
# ZGC, 2 GB heap
bool UseCompressedOops = false {product lp64_product} {ergonomic}
# G1, same 2 GB heap
bool UseCompressedOops = true {product lp64_product} {ergonomic}
Consequences, in rough order of how often they surprise people:
- Every reference field costs 8 bytes instead of 4. A
HashMap.Node(hash, key, value, next) goes from 32 bytes to 48. A reference-dense heap — many small objects, many pointers, think a large in-memory graph, an ORM session cache, a JSON DOM — can grow 30–40% just by switching collector, with no application change. - Object header layout differs, so heap-size estimates from a G1 heap dump do not transfer.
-Xmxis not comparable across collectors. "We gave both 8 GB" is not a fair test if one of them needs 11 GB to hold the same object graph. This repository uses a byte-array-heavy live set partly for this reason:byte[]payloads are dominated by data, not references, which keeps the comparison honest. A pointer-heavy live set would have handed ZGC a footprint penalty that has nothing to do with pause behaviour.UseCompressedClassPointersstaystrueon both, so class pointers are unaffected.
If you are evaluating ZGC and your heap is full of small objects, measure footprint before you measure latency.
7.2 ZGC large pages are never relocated
ZGC's Large pages hold exactly one object each and are freed, not compacted. A heap with many long-lived multi-megabyte objects therefore fragments in a way ZGC structurally cannot fix. G1 has the mirror problem with humongous regions, but G1 has a Full GC that can compact them (at enormous cost).
Verified placement on a 2 GB heap — note the last line, which is the one that catches people:
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 4 MB and 16 MB allocations. The 4 MB one is the surprise: the documented
medium-object limit is 4 MB, but new byte[4 * 1024 * 1024] is 4 MB plus a 16-byte array header,
which is over the limit. It gets an 8 MB Large page to itself — a 2x footprint amplification caused
by sixteen bytes. If you pool buffers, pool them at 4 MB - 64 rather than exactly 4 MB.
7.3 G1's humongous threshold is lower than people 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 byte[] is producing humongous garbage on
every request. -XX:G1HeapRegionSize=8m moves the threshold to 4 MB and can be transformative.
This is one of the few G1 flags that regularly delivers a large win, and it is invisible unless you
go looking.
7.4 Virtual thread stacks live on the heap
A mounted virtual thread runs on a carrier's stack, but an unmounted one has its stack copied
into the heap as StackChunk objects. So a service with 200,000 parked virtual threads is holding
200,000 medium-sized, medium-lived objects that the collector must trace.
This interacts badly with a naive reading of "virtual threads are cheap": they are cheap in scheduler terms and not free in GC terms. Applications that move from a 200-thread pool to 200,000 virtual threads often see their live set and promotion rate change shape entirely, which is exactly the regime where the ZGC-vs-G1 answer flips. If you have made that migration, re-run your GC choice; the decision you made before it is stale.
7.5 System.gc() behaves differently
ExplicitGCInvokesConcurrent is false by default on both collectors here. Under G1 that means
an explicit System.gc() triggers a Full GC — a stop-the-world compaction. Under ZGC it
triggers a major cycle, most of which is still concurrent, so the blast radius is much smaller.
Libraries that call System.gc() (some direct-ByteBuffer cleanup paths, some test harnesses, some
profilers) are therefore far more damaging on G1. -XX:+ExplicitGCInvokesConcurrent is a cheap
mitigation on G1; -XX:+DisableExplicitGC is the blunt one.
Note that StoreBarrierBench in this repository calls System.gc() twice in @Setup — deliberately,
to force the array into the old generation before measurement, and outside the measured window.
7.6 Reference processing still stops the world briefly
Weak, soft, phantom references and finalizers are processed during specific phases. On ZGC this
happens under Concurrent Process Non-Strong, visible in the log:
Concurrent Process Non-Strong n=8 total= 5.635 ms max= 0.984 ms
Concurrent, therefore not freeze time — but a heap with millions of weak references still costs
real CPU and can extend cycle duration enough to trigger allocation stalls indirectly. WeakHashMap
at scale, and caches built on SoftReference, are the usual suspects. Soft references in particular
interact with SoftRefLRUPolicyMSPerMB and are cleared based on free heap, which behaves very
differently when SoftMaxHeapSize is set.
7.7 ZUncommit disables itself on a fixed heap
# -Xms2g -Xmx2g
ZUncommit = false
Logical — there is nothing to give back — but it surprises teams who set a fixed heap for
predictability and expect memory returned to the container. If you want uncommit, you must give
-Xms and -Xmx different values, and tune ZUncommitDelay (default 300 s) down if you want it to
happen promptly.
7.8 GC thread counts differ by default
ZGC : ParallelGCThreads = 8, ConcGCThreads = 3
G1 : ParallelGCThreads = 10, ConcGCThreads = 3
On the same 12-thread machine. If you pin GC threads for consistency across a fleet, you are
overriding two different ergonomic decisions with one number, and one of the collectors will be
mis-sized. Prefer leaving them ergonomic unless you are running in a CPU-limited container, where
you should set them explicitly because the JVM's view of availableProcessors() may not match the
cgroup quota.
7.9 Container limits
Both collectors respect cgroup limits, but the failure modes differ:
- ZGC's higher footprint (see §7.1) plus its need for headroom means a container sized for G1 will push ZGC into allocation stalls. Budget more memory, not the same memory.
-XX:MaxRAMPercentageis the right knob in containers;-Xmxhard-codes a number that will be wrong after the next resize.- ZGC reserves a large virtual address range. Tools that alarm on virtual size rather than RSS will report frightening numbers. Alarm on RSS.
7.10 Things that are the same and often assumed not to be
- String deduplication works on ZGC (since JDK 18), not just G1.
-XX:+UseStringDeduplicationis off by default on both. - Class unloading happens on both, during major/concurrent cycles.
- TLABs work identically; the fast allocation path is a pointer bump on both.
- Heap dumps (
jcmd GC.heap_dump) work the same and are stop-the-world on both. -XX:+AlwaysPreTouchisfalseby default on both, and is worth turning on for both if you care about first-minute latency.
Next: 08-choosing.md.