# 8. Choosing a collector on JDK 25 ## 8.1 The short version **G1 is still the right default.** It is the default for a reason: it is balanced, it needs no tuning to be adequate, and it wins the median. Switch to ZGC when you have a *stated* tail-latency requirement that G1 is measurably missing — not because ZGC is newer. ## 8.2 What the measurements actually say From the 60-second run in this repository (Ryzen 5 5600U, Windows 11, JDK 25.0.3, 2 GB heap, ~585 MB live, 20,000 req/s, ~328 MB/s allocation rate): | Metric | ZGC | G1 | Winner | |---|---|---|---| | p50 response time | 0.006 ms | 0.005 ms | G1, barely | | p90 | 0.009 ms | 0.009 ms | tie | | p99 | 0.029 ms | 8.042 ms | **ZGC, 277x** | | p99.9 | 1.437 ms | 95.169 ms | **ZGC, 66x** | | p99.99 | 23.675 ms | 126.949 ms | **ZGC, 5.4x** | | max | 32.178 ms | 133.018 ms | ZGC | | mean | 0.016 ms | 0.388 ms | ZGC | | total stop-the-world time | **1.503 ms** | **1,139.6 ms** | **ZGC, 758x** | | longest single pause | 0.054 ms | 104.449 ms | ZGC | | Full GCs / evacuation failures | 0 / 0 | 4 / 16 | ZGC | Note the shape: the two collectors are **indistinguishable up to p90**. Everything ZGC buys you is in the tail. If your SLO is a mean or a p95, this entire table is telling you to stay on G1 and spend the effort elsewhere. Note also that ZGC is not magic: its own p99.99 is 23.7 ms. Those are allocation stalls (the GC log for that run contains 40 of them), not pauses. ZGC moved the latency source, it did not delete it. ## 8.3 A decision procedure Work through it in order. Stop at the first "yes". 1. **Is GC actually your problem?** Get a JFR recording and look at `jdk.GCPhasePause` and `jdk.ZAllocationStall` against your request latency. Most "GC problems" are lock contention, downstream calls, or connection-pool starvation. Changing collector will not fix those and will cost you a week. 2. **Is your heap small (< 4 GB) and your SLO a mean or p95?** Stay on G1. ZGC's advantages need a tail requirement and a heap large enough that marking cost matters. 3. **Do you have a hard tail-latency SLO — p99 or p99.9 in single-digit or low-double-digit milliseconds — that G1 is missing?** This is the case ZGC was built for. Go to step 5. 4. **Is your heap very large (> 32 GB) with a large live set?** G1's pause time scales with live data in the collection set; ZGC's does not. ZGC becomes increasingly favourable as the heap grows. Go to step 5. 5. **Can you afford the footprint?** ZGC needs headroom (§8.4) *and* loses compressed oops ([07-corner-cases.md](07-corner-cases.md) §7.1). Budget 30–50% more memory than the equivalent G1 deployment, more if your heap is reference-dense. If you cannot, ZGC will stall and you will conclude — wrongly — that ZGC is bad. 6. **Is your workload throughput-bound with a hot pointer-chasing inner loop?** ZGC's load barrier is a real, permanent tax on reference loads. Batch jobs, analytics, and anything that walks large object graphs should measure before switching. Consider Parallel GC instead — for a batch job with no latency requirement, Parallel still beats both on raw throughput. ## 8.4 Sizing ZGC The single most common ZGC failure is a heap sized as if it were a G1 heap. - Start at **1.5x** the `-Xmx` you used for G1, then measure. - Set `SoftMaxHeapSize` to roughly **75%** of `-Xmx` so there is a reserve for allocation spikes. - Watch `jdk.ZAllocationStall` (threshold `0 ms`), not pause time. If stalls are non-zero under normal load, you are under-provisioned. - If stalls are concentrated on Large pages, the fix is in your application's allocation sizes, not in the heap size. ## 8.5 When the answer is neither - **Parallel GC** (`-XX:+UseParallelGC`) — batch jobs, ETL, anything where wall-clock throughput is the only metric and a multi-second pause is irrelevant. Still the throughput champion. - **Serial GC** (`-XX:+UseSerialGC`) — small containers, single-core, short-lived processes. Lowest overhead, smallest footprint, fastest start-up. - **Shenandoah** (`-XX:+UseShenandoahGC`) — the other concurrent-compacting collector. Broadly similar goals to ZGC with a different barrier design (Brooks-style forwarding rather than colored pointers); it keeps compressed oops, which makes it interesting precisely where §7.1 hurts. Worth benchmarking as a third option if footprint is your constraint. ## 8.6 The honest summary ZGC does not make garbage collection free. It converts a **rare, large, correlated** cost — a stop-the-world pause that hits every thread at once — into a **constant, small, uncorrelated** cost: barrier work on every reference access, plus a memory footprint premium. For a request-serving system with a tail-latency SLO, that trade is close to unambiguously good, and JDK 25 is the first LTS where you get it without an opt-in flag or a non-generational fallback to worry about. For a batch job, it is a straightforward loss. Most systems are somewhere in between, which is why the only defensible answer is to run [`scripts/run-all.ps1`](../scripts/run-all.ps1) against a workload shaped like yours. --- Back to the [README](../README.md).