1
0

Generational ZGC vs G1 on JDK 25: benchmarks, internals and captured results

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
This commit is contained in:
2026-07-31 08:47:16 +05:30
commit e189da79ba
45 changed files with 12572 additions and 0 deletions

View File

@@ -0,0 +1,115 @@
# 1. The generational model, and what JDK 25 actually changed
> Companion notes for [Generational ZGC on JDK 25: Benchmarks vs G1](https://ankurm.com/generational-zgc-jdk-25-vs-g1/).
> Everything here is verified against `java 25.0.3+9-LTS-195`.
## 1.1 The one-paragraph history
| Release | What happened |
|---|---|
| JDK 11 | ZGC arrives as an experimental, **non-generational** collector (JEP 333). One generation, whole-heap marking every cycle. |
| JDK 15 | ZGC becomes a production feature (JEP 377). |
| JDK 21 | **Generational ZGC** ships as an opt-in mode behind `-XX:+ZGenerational` (JEP 439). |
| JDK 23 | Generational becomes the **default** (JEP 474). `-XX:-ZGenerational` still gets you the old one. |
| JDK 24 | The non-generational mode is **removed** (JEP 490). `ZGenerational` becomes an obsolete flag. |
| JDK 25 | First **LTS** where ZGC is generational-only. This is the release most teams will actually adopt. |
Verify the removal yourself — the JVM says so out loud:
```console
$ 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
```
That warning matters operationally: if your start-up scripts still carry `-XX:+ZGenerational` from a
JDK 21 experiment, JDK 25 will *not* fail fast. It warns and continues. Grep your deployment
manifests.
## 1.2 What "generational" buys, concretely
The weak generational hypothesis: most objects die young. A collector that exploits it can reclaim
the young generation without looking at the old one.
Non-generational ZGC could not do that. Every cycle marked the **entire** heap. With a 600 MB live
set and a 300 MB/s allocation rate, that means repeatedly tracing 600 MB of objects that you
already know are alive, in order to reclaim garbage that lives in a few megabytes of recently
allocated pages.
Generational ZGC splits this into:
- a **minor (young) collection** that marks and relocates only young pages, and
- a **major (old) collection** that additionally handles the old generation, run far less often.
You can see the split in the JMX bean names, which is the cheapest possible proof of what you are
running:
```console
collector : ZGC Minor Cycles + ZGC Minor Pauses + ZGC Major Cycles + ZGC Major Pauses
```
Four beans, not two. Under non-generational ZGC there was one cycle bean and one pause bean.
`env/Env.java` in this repository prints exactly this and derives `generational? yes`.
## 1.3 The cost of the split: you now need a write barrier
There is no free lunch. To collect young without scanning old, the collector must know about every
**old-to-young reference**. Those are created by ordinary application code:
```java
oldCache[i] = new Payload(); // an old object now points at a brand-new young object
```
If a young collection ignored `oldCache`, it would conclude `new Payload()` is unreachable and free
a live object.
So generational ZGC added a **store barrier** — code the JIT injects around reference stores, which
records the cross-generational edge. Non-generational ZGC had *no* store barrier at all; it only
had a load barrier. This is the single biggest structural change in JEP 439, and it is why
generational ZGC has slightly different throughput characteristics from the old one on
store-heavy code. See [02-barriers.md](02-barriers.md).
You can see the mechanism named in the VM's own diagnostic flags:
```console
$ java -XX:+UseZGC -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | findstr Z
bool ZBufferStoreBarriers = true {diagnostic} {default}
int ZTenuringThreshold = -1 {diagnostic} {default}
bool ZUseMediumPageSizeRange = true {diagnostic} {default}
```
- `ZBufferStoreBarriers` — the store barrier does not update a remembered set inline; it appends to
a per-thread buffer that is drained later. That is what keeps the barrier cheap.
- `ZTenuringThreshold = -1`**adaptive**. ZGC ages objects like any generational collector, but
unlike G1's `MaxTenuringThreshold` (default 15, a documented product flag), ZGC's is a diagnostic
flag that defaults to "let the collector decide". Pinning it is almost always a mistake; knowing
it exists is occasionally the answer to "why did my promotion rate change after an upgrade".
## 1.4 Where the generations physically live
ZGC does not have a contiguous eden / survivor / old layout the way Parallel or (loosely) G1 does.
Generation membership is a property of a **page**, and pages are 2 MB / medium / large units
scattered across the reserved address space. A young page whose objects survive enough cycles is
*promoted* by relocating its live objects into an old page.
This has a practical consequence people trip over: **there is no `-Xmn` for ZGC**. You cannot size
the young generation. ZGC decides how much of the heap is young dynamically, based on allocation
rate and how much headroom `SoftMaxHeapSize` leaves it. If you are used to tuning G1 with
`-XX:NewRatio` or `-XX:G1NewSizePercent`, that entire toolbox is gone. The ZGC equivalent of "give
the young generation more room" is "give the heap more room, or lower `SoftMaxHeapSize` so
collection starts earlier".
## 1.5 What this means for a migration
| If you relied on… | On generational ZGC you get… |
|---|---|
| `-Xmn`, `-XX:NewRatio`, `-XX:SurvivorRatio` | Nothing. Ignored or unavailable. Size the whole heap instead. |
| `-XX:MaxTenuringThreshold` | `ZTenuringThreshold`, diagnostic, adaptive by default. |
| `-XX:+ZGenerational` | An obsolete-flag warning. Remove it. |
| GC pause charts as your latency SLI | A misleading green dashboard. See [03-allocation-stalls.md](03-allocation-stalls.md). |
| `-XX:+UseStringDeduplication` | Still supported on ZGC (since JDK 18), not G1-only any more. |
---
Next: [02-barriers.md](02-barriers.md) — colored pointers, load barriers, store barriers and
remembered sets, with the microbenchmark that prices each one.

106
docs/02-barriers.md Normal file
View File

@@ -0,0 +1,106 @@
# 2. Colored pointers, load barriers, store barriers, remembered sets
> Runnable companions: [`jmh/.../LoadBarrierBench.java`](../jmh/src/main/java/com/ankurm/zgc/jmh/LoadBarrierBench.java),
> [`jmh/.../StoreBarrierBench.java`](../jmh/src/main/java/com/ankurm/zgc/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, `RSS` no 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=1` to 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](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](03-allocation-stalls.md) — the ZGC failure mode that pause charts
cannot see.

View File

@@ -0,0 +1,161 @@
# 3. Allocation stalls — the ZGC failure mode your dashboard cannot see
> Runnable companion: [`tuning/.../AllocationStallDemo.java`](../tuning/src/com/ankurm/zgc/tuning/AllocationStallDemo.java)
## 3.1 The claim, and why it is misleading
Every ZGC article ends with the same chart: pause times, flat, under a millisecond, at any heap
size. The chart is accurate. The implied conclusion — *therefore ZGC has no latency problem* — is
not, because **on ZGC, pauses are not where latency comes from.**
ZGC's stop-the-world pauses are O(number of GC roots). They do not scale with heap size or live-set
size, and they stay in the tens of microseconds. There are three of them per cycle: `Pause Mark
Start`, `Pause Mark End`, `Pause Relocate Start`.
What actually hurts is different. When the application allocates faster than the collector can
reclaim, a thread that requests memory and cannot be given a page is **stalled** until the collector
frees one. That 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.**
A ZGC service can report a p99.99 pause of 0.2 ms and a p99.99 request latency of 400 ms
simultaneously. Both numbers are correct. If your only GC SLI is pause time, you will never find the
second one.
## 3.2 Seeing it
`AllocationStallDemo` starts a JFR recording, deliberately drives a small heap into the ground, then
reads its own recording back. Measured on this repository's reference machine (Ryzen 5 5600U,
Windows 11, JDK 25.0.3), 512 MB heap, 15 seconds of allocation:
```console
$ java -XX:+UseZGC -Xms512m -Xmx512m -Dseconds=15 \
-cp out com.ankurm.zgc.tuning.AllocationStallDemo
young collections : 1,319
old collections : 164
page allocations : 30,768
ALLOCATION STALLS : 25,343
total time threads spent stalled : 100,989 ms
mean stall : 3.98 ms
longest stall : 49.01 ms
```
Read that again: **101 seconds of cumulative stalled thread time inside a 15-second run.** Twelve
allocator threads, each spending a large fraction of the run waiting for memory. The pause chart for
this run is a flat line near zero.
## 3.3 The threshold that hides them
`jdk.ZAllocationStall` has a **default JFR threshold of 10 ms**. Anything shorter is not recorded.
In the run above the *mean* stall was 3.98 ms — meaning a default JFR profile would have discarded
the majority of them and reported a much healthier picture.
If you take one operational thing from this repository, take this:
```java
recording.enable("jdk.ZAllocationStall").withoutThreshold();
```
or in a `.jfc` settings file:
```xml
<event name="jdk.ZAllocationStall">
<setting name="enabled">true</setting>
<setting name="threshold">0 ms</setting>
<setting name="stackTrace">true</setting>
</event>
```
With stack traces on, you get the exact allocation site that stalled — which is usually far more
actionable than any GC log line.
The same events are visible in the unified log without JFR:
```console
-Xlog:gc+alloc=debug
```
produces `Allocation Stall` lines. The `GcLogParser` in this repository counts them.
## 3.4 The stalls have a *shape*
The demo groups stalls by ZGC page class, which turns "we are out of memory" into a diagnosis:
- **Small** page stalls — ordinary allocation pressure. The fix is more heap, an earlier collection
trigger, or allocating less.
- **Medium** page stalls — objects in the 256 KB4 MB band. Often a buffer-pool or serialization
layer. Frequently fixable in application code.
- **Large** page stalls — one page per object. ZGC does not relocate large pages, so a heap with
many long-lived large objects fragments in a way ZGC cannot compact away.
A run dominated by Large-page stalls has a completely different remedy from one dominated by Small,
and no pause-time chart distinguishes them.
## 3.5 Mitigations, in the order you should try them
### `SoftMaxHeapSize` — start collecting earlier
```
-XX:SoftMaxHeapSize=400m # with -Xmx512m
```
ZGC's heuristic aims to keep the heap under `SoftMaxHeapSize` but is *allowed* to exceed it, up to
`-Xmx`, rather than stall the application. Setting it below `-Xmx` therefore creates a deliberate
reserve: normal operation targets the soft limit, and a spike can eat into the gap instead of
hitting a wall. On JDK 25 it defaults to `-Xmx`, i.e. no reserve at all.
It is a `manageable` flag, so you can change it on a running JVM — genuinely useful during an
incident:
```console
$ jcmd <pid> VM.set_flag SoftMaxHeapSize 400m
```
### `ZAllocationSpikeTolerance` — assume bigger spikes
```
-XX:ZAllocationSpikeTolerance=5 # default 2.0
```
ZGC predicts when to start a cycle from the observed allocation rate multiplied by this tolerance
factor. The default of 2.0 assumes allocation can double. A bursty service — one that goes from idle
to full throttle in a second — routinely violates that, and the collector starts a cycle too late.
Raising the tolerance makes ZGC start earlier and more often: you pay CPU to buy headroom.
### `-XX:ZCollectionIntervalMinor` / `Major` — collect on a clock
Both default to `-1.0` (disabled). Setting `ZCollectionIntervalMinor=1` forces a young collection
every second regardless of allocation rate. This is a blunt instrument, but it is the right one for
a service with a very spiky duty cycle where the rate-based heuristic keeps being surprised.
### More heap
The honest answer, when you can afford it. ZGC trades memory for latency by design; a ZGC heap sized
like a G1 heap is a ZGC heap that stalls.
## 3.6 What the same pressure looks like on G1
Run the identical demo under `-XX:+UseG1GC` and there are no allocation stalls, because G1 does not
have that mechanism. Instead you get **to-space exhaustion** (an evacuation failure) followed by a
**Full GC** — a genuine, whole-application, stop-the-world compaction that will show up in your
pause chart as a multi-hundred-millisecond spike.
So the failure modes are mirror images:
| | ZGC | G1 |
|---|---|---|
| Symptom under memory pressure | allocation stalls | evacuation failure → Full GC |
| Affects | the allocating thread(s) | every thread |
| Visible in pause metrics | **no** | yes |
| Typical magnitude here | 4 ms mean, 49 ms max, thousands of them | hundreds of ms, few of them |
G1's failure is louder and easier to find. ZGC's is quieter and easier to live with — provided you
are actually measuring it.
---
Next: [04-tuning-reference.md](04-tuning-reference.md).

101
docs/04-tuning-reference.md Normal file
View File

@@ -0,0 +1,101 @@
# 4. Tuning reference (JDK 25)
Everything below was read out of a live `java 25.0.3+9-LTS-195` with
`-XX:+PrintFlagsFinal` and `HotSpotDiagnosticMXBean`. Raw dumps:
[`results/02-flags-zgc.txt`](../results/02-flags-zgc.txt),
[`results/02-flags-g1.txt`](../results/02-flags-g1.txt).
The general rule for ZGC is unusual and worth stating plainly: **there is almost nothing to tune.**
The design goal was to remove knobs, not add them. If you find yourself reaching for a fourth flag,
the answer is usually heap size or application allocation behaviour.
## 4.1 ZGC — product flags (supported, safe to use)
| Flag | Default (2 GB heap) | What it does | When to touch it |
|---|---|---|---|
| `-XX:+UseZGC` | off | Selects ZGC. Always generational on JDK 24+. | — |
| `-XX:SoftMaxHeapSize` | = `-Xmx` | Soft target the GC heuristic aims for; may be exceeded up to `-Xmx` rather than stall. **`manageable`** — changeable at runtime via `jcmd VM.set_flag`. | Set below `-Xmx` to create spike headroom. The single highest-value ZGC flag. |
| `-XX:ZAllocationSpikeTolerance` | `2.0` | Multiplier on observed allocation rate when predicting when to start a cycle. | Raise (35) for bursty services that stall on ramp-up. |
| `-XX:ZCollectionIntervalMinor` | `-1.0` (off) | Force a young cycle every N seconds. | Very spiky duty cycles where rate prediction keeps failing. |
| `-XX:ZCollectionIntervalMajor` | `-1.0` (off) | Force an old cycle every N seconds. | Slow, steady old-gen growth you want amortised. |
| `-XX:ZCollectionInterval` | `0.0` (off) | Legacy combined interval. | Prefer the Minor/Major pair. |
| `-XX:ZCollectionIntervalOnly` | `false` | Collect *only* on the interval, ignoring the allocation-rate heuristic. | Benchmarks and reproducible experiments. Not production. |
| `-XX:ZFragmentationLimit` | `5.0` (%) | A page is added to the relocation set when this much of it is garbage. | Lower = more aggressive compaction, more CPU. Rarely worth changing. |
| `-XX:ZYoungCompactionLimit` | `25.0` (%) | Equivalent threshold for young pages. | Rarely. |
| `-XX:+ZUncommit` | `true`, **but `false` when `-Xms == -Xmx`** | Return unused heap to the OS. | Leave on. Note the automatic disable — see §4.4. |
| `-XX:ZUncommitDelay` | `300` (s) | How long a page must stay unused before being uncommitted. | Lower it in bursty container workloads to give memory back sooner. |
## 4.2 ZGC — diagnostic flags (need `-XX:+UnlockDiagnosticVMOptions`)
These appear in no tuning guide. You should almost never set them. You should know they exist,
because they name the mechanisms.
| Flag | Default | Meaning |
|---|---|---|
| `ZBufferStoreBarriers` | `true` | The generational store barrier appends to a per-thread buffer that is drained later, rather than updating remembered sets inline. |
| `ZTenuringThreshold` | `-1` | Age at which young objects are promoted. `-1` = adaptive. ZGC's answer to `MaxTenuringThreshold`. |
| `ZUseMediumPageSizeRange` | `true` | JDK 25: the medium page size is a **range** chosen at runtime, not a constant. This is why there is no `ZPageSizeMedium` product flag to read. |
| `ZStressFastMediumPageAllocation` | `false` | Test hook. |
| `ZIndexDistributorStrategy` | `0` | Internal work-distribution strategy for parallel phases. |
| `ZStatisticsInterval` | `10` (s) | Interval for `-Xlog:gc+stats`. |
## 4.3 G1 — the flags that still matter on JDK 25
| Flag | Default (2 GB heap) | Notes |
|---|---|---|
| `-XX:+UseG1GC` | default collector on server-class machines | |
| `-XX:MaxGCPauseMillis` | `200` | A *goal*, not a guarantee. G1 sizes the young generation to try to hit it. Setting it very low (e.g. 5) makes G1 shrink young until it collects constantly — a classic self-inflicted throughput cliff. |
| `-XX:G1HeapRegionSize` | ergonomic, `1 MB` at 2 GB heap | Power of two, 132 MB, roughly heap/2048. **Determines the humongous threshold** (half a region). |
| `-XX:G1NewSizePercent` / `G1MaxNewSizePercent` | `5` / `60` | Bounds on young generation size. |
| `-XX:InitiatingHeapOccupancyPercent` | `45` | When to start a concurrent cycle. G1 adapts this by default (`-XX:-G1UseAdaptiveIHOP` to pin it). |
| `-XX:G1MixedGCLiveThresholdPercent` | `85` | Old regions above this liveness are not collected in mixed GCs. |
| `-XX:MaxTenuringThreshold` | `15` | Real, documented, product flag — unlike ZGC's. |
**The humongous threshold is the G1 flag most people should check and don't.** At a 2 GB heap the
region size is 1 MB, so anything over 512 KB is humongous: allocated straight into the old
generation, spanning whole regions, never given the cheap young-collection path. A service that
allocates 1 MB buffers is generating humongous objects on every request. Confirmed on this machine:
```console
G1HeapRegionSize : 1,048,576 bytes (1 MB)
=> G1 humongous threshold: objects larger than 524,288 bytes (512 KB) go straight to old gen
```
Raising `-XX:G1HeapRegionSize=8m` moves the threshold to 4 MB and can transform such a workload.
## 4.4 Two ergonomic surprises worth knowing
**`ZUncommit` silently turns itself off when `-Xms == -Xmx`.** Verified:
```console
$ java -XX:+UseZGC -Xms2g -Xmx2g ... PageSizeDemo
ZUncommit = false
```
but with a range (`-Xms512m -Xmx2g`) it is `true`. This is logical — you asked for a fixed heap, so
there is nothing to give back — but it surprises people who set a fixed heap *and* expect memory to
be returned to a container.
**`SoftMaxHeapSize` defaults to `-Xmx`, which means "no reserve".** Its default is ergonomic and
equal to max heap, so out of the box it does nothing. It only becomes useful when you set it.
## 4.5 A starting configuration
For a latency-sensitive JVM service on JDK 25, this is a defensible starting point — not because
these values are optimal, but because each one is there for a stated reason:
```
-XX:+UseZGC
-Xms8g -Xmx8g # fixed heap: no resize noise
-XX:SoftMaxHeapSize=6g # 25% reserve for allocation spikes
-XX:ZAllocationSpikeTolerance=3 # this service ramps fast
-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` threshold to `0 ms`
(see [03-allocation-stalls.md](03-allocation-stalls.md) §3.3).
---
Next: [05-logging-and-jfr.md](05-logging-and-jfr.md).

137
docs/05-logging-and-jfr.md Normal file
View File

@@ -0,0 +1,137 @@
# 5. Reading the logs and the flight recording
> Runnable companion: [`analysis/.../GcLogParser.java`](../analysis/src/com/ankurm/zgc/analysis/GcLogParser.java)
## 5.1 The unified log lines you need
```
-Xlog:gc*,gc+alloc=debug:file=gc.log:time,uptime,level,tags:filecount=5,filesize=50m
```
| Selector | Gives you |
|---|---|
| `gc` | one line per collection |
| `gc+phases` | every sub-phase, including the three ZGC pauses |
| `gc+heap` | before/after occupancy |
| `gc+alloc=debug` | **`Allocation Stall` lines** — off by default, and the reason most people never see stalls |
| `gc+start`, `gc+task`, `gc+ref` | cycle triggers, worker counts, reference processing |
## 5.2 The category error that makes ZGC look terrible
A ZGC cycle produces lines like this:
```
GC(41) Minor Collection (Allocation Rate) 1180M(58%)->402M(20%) 0.051s
GC(41) y: Pause Mark Start 0.014ms
GC(41) y: Concurrent Mark 32.109ms
GC(41) y: Pause Mark End 0.011ms
GC(41) y: Concurrent Relocate 14.802ms
GC(41) y: Pause Relocate Start 0.009ms
```
A naive script that sums every duration on every line reports "51 ms of GC" for this cycle. The real
stop-the-world time is **0.034 ms** — the three `Pause` lines. The `Concurrent` phases ran alongside
the application.
G1's log has no such trap: its lines are pauses.
```
GC(12) Pause Young (Normal) (G1 Evacuation Pause) 1180M->402M(2048M) 16.784ms
```
`GcLogParser` in this repository keeps the two categories in separate buckets and prints them
separately, precisely so the comparison cannot be made accidentally. It also prints the sentence
that should accompany every such table:
```
Adding the block above to the pause block would be a category error:
concurrent time overlaps application execution by design.
```
## 5.3 The same trap exists in JMX
`GarbageCollectorMXBean.getCollectionTime()` means different things per collector:
| Bean | Reports |
|---|---|
| `ZGC Minor Cycles` / `ZGC Major Cycles` | **concurrent cycle** duration — not freeze time |
| `ZGC Minor Pauses` / `ZGC Major Pauses` | real stop-the-world time |
| `G1 Young Generation` / `G1 Old Generation` | real stop-the-world time |
| `G1 Concurrent GC` | concurrent cycle — despite sitting in the same bean list |
A dashboard that sums `getCollectionTime()` over all beans will report ZGC as spending ~1.6 seconds
"in GC" during a 60-second run where its actual freeze time rounded to **0 ms**. That is how ZGC
acquires a reputation for being slow on teams that never changed their dashboard query.
`GcObserver` in this repository classifies each bean and labels it in the output:
```
GC activity during the measured window
ZGC Minor Cycles collections=18 time= 919 ms [cycle (mostly concurrent)]
ZGC Minor Pauses collections=54 time= 0 ms [stop-the-world]
ZGC Major Cycles collections=3 time= 667 ms [cycle (mostly concurrent)]
ZGC Major Pauses collections=15 time= 0 ms [stop-the-world]
```
Note also the **3 pauses per cycle** arithmetic: 18 minor cycles → 54 minor pauses.
## 5.4 JFR events worth enabling
| Event | Default threshold | Why |
|---|---|---|
| `jdk.ZAllocationStall` | **10 ms** | The real ZGC latency source. Set to `0 ms` and enable stack traces. |
| `jdk.ZPageAllocation` | — | Shows the Small / Medium / Large split; diagnoses *which kind* of pressure. |
| `jdk.ZYoungGarbageCollection` / `jdk.ZOldGarbageCollection` | — | Cycle counts with cause. |
| `jdk.ZRelocationSet`, `jdk.ZRelocationSetGroup` | — | What is being compacted each cycle. |
| `jdk.ZUncommit`, `jdk.ZUnmap` | — | Heap being handed back to the OS. |
| `jdk.GCPhasePause` | — | Works for both collectors; the honest cross-collector pause metric. |
Starting a recording programmatically, which is what both demos in this repository do:
```java
try (Recording recording = new Recording()) {
recording.enable("jdk.ZAllocationStall").withoutThreshold().withStackTrace();
recording.setToDisk(true);
recording.setDestination(Path.of("stalls.jfr"));
recording.start();
// ... workload ...
recording.stop();
}
```
Reading it back without any external tool:
```java
try (RecordingFile file = new RecordingFile(Path.of("stalls.jfr"))) {
while (file.hasMoreEvents()) {
RecordedEvent e = file.readEvent();
if (e.getEventType().getName().equals("jdk.ZAllocationStall")) { ... }
}
}
```
> **A `RecordedEvent` gotcha that costs an hour.** `getValue` is declared `<T> T getValue(String)`.
> Writing `String.valueOf(e.getValue("type"))` lets javac infer `T = char[]`, because
> `String.valueOf(char[])` is an applicable overload — and you get
> `ClassCastException: class java.lang.String cannot be cast to class [C` at runtime. Bind to an
> `Object` local first. Similarly `RecordedEvent::getDuration` is overloaded, so a method reference
> to it will not compile inside a `Comparator.comparing(...)`; use an explicit lambda.
And from the command line, with the stack that was stalled:
```console
$ jfr summary stalls.jfr
$ jfr print --events ZAllocationStall --stack-depth 10 stalls.jfr
jdk.ZAllocationStall {
startTime = 22:44:24.809 (2026-07-30)
duration = 2.20 ms
type = "Small"
size = 2.0 MB
eventThread = "allocator-3" (javaThreadId = 43)
stackTrace = [ ... ]
}
```
---
Next: [06-g1-in-jdk25.md](06-g1-in-jdk25.md).

136
docs/06-methodology.md Normal file
View File

@@ -0,0 +1,136 @@
# 6. Methodology — how to not publish a meaningless GC benchmark
Most GC comparisons on the internet are wrong in one of a small number of specific, fixable ways.
This chapter lists them, because a benchmark's credibility is entirely a function of which of these
it avoided.
## 6.1 Coordinated omission
**The mistake.** A closed-loop harness — `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. The pause was real; your users experienced it as 100 ms of queueing across ~2000 requests;
the benchmark reports it as a handful of samples.
**The fix.** Fix the arrival schedule up front and measure from *intended* arrival, not from when
the thread actually got around to the request.
```java
long intended = start + i * interArrivalNanos;
parkUntil(intended);
long actualStart = System.nanoTime();
handleRequest();
long done = System.nanoTime();
responseTime.record(done - intended); // honest
serviceTime.record(done - actualStart); // what a closed-loop harness reports
```
**How much it matters.** This repository records both and prints the ratio. From the 60-second run
on the reference machine:
| Collector | p99.9 response time | p99.9 service time | ratio |
|---|---|---|---|
| ZGC | 1.437 ms | 0.143 ms | 10x |
| G1 | 95.169 ms | 0.033 ms | **2910x** |
A closed-loop benchmark of this exact workload would have concluded that G1's p99.9 is 0.033 ms —
*better* than ZGC's 0.143 ms — and published a chart showing G1 winning on tail latency. The honest
numbers say G1's p99.9 is 66x worse.
Note that the distortion is not uniform: it is far larger for the collector with larger pauses. So
coordinated omission does not merely add noise, it **systematically favours the collector you are
trying to evaluate against**.
## 6.2 Benchmarking the sleep instead of the system
The first version of this harness used `LockSupport.parkNanos(remaining)` between requests. On
Windows a park rounds up to roughly 1 ms, and the inter-arrival interval was 200 µs. Every park
overshot by several intervals; the thread then blasted through the backlog and parked again. The
harness reported a p50 of 0.590 ms for a request that takes 3 µs, and the shape of the distribution
looked plausibly like a GC effect.
The fix is to park only when the deadline is far away (3 ms here, comfortably beyond the timer
slack) and busy-spin the rest. It costs a core per worker thread, which is why the worker count
defaults to a *third* of the logical CPUs — see §6.4.
If your latency harness's p50 is much larger than the work it does, suspect your sleep before you
suspect the collector.
## 6.3 Comparing pause time against cycle time
Covered in detail in [05-logging-and-jfr.md](05-logging-and-jfr.md). In one line: ZGC's
`GarbageCollectorMXBean` "Cycles" beans and its `Concurrent ...` log lines are **not** freeze time,
and summing them against G1's pause numbers produces a comparison that is off by two orders of
magnitude in G1's favour.
## 6.4 Starving the collector of CPU
ZGC does concurrent work on its own threads. If the benchmark's load generator occupies every core —
which a busy-spinning open-loop harness will happily do — those threads cannot run, the collector
falls behind, and you measure a scheduling problem while believing you measured a GC.
This repository's harness therefore defaults to `availableProcessors() / 3` worker threads and states
so in its output. On the 12-thread reference machine that is 4 spinning workers, leaving 8 logical
CPUs for the JVM's own threads.
## 6.5 Letting the heap resize during measurement
`-Xms``-Xmx` means the heap grows and shrinks during the run, and you are partly measuring the
resize policy. Every run in this repository uses `-Xms2g -Xmx2g`.
A side effect worth knowing: **`ZUncommit` automatically becomes `false` when `-Xms == -Xmx`**
(verified in [`results/05-pages-zgc.txt`](../results/05-pages-zgc.txt)). Fixing the heap therefore
also removes uncommit behaviour from the comparison. That is the right call for a benchmark and the
wrong call if uncommit is the thing you wanted to study.
## 6.6 Forgetting that JMH forks a fresh JVM
This one silently invalidates a large fraction of published GC microbenchmarks:
```console
# WRONG -- the fork does not inherit the outer JVM's GC flags
java -XX:+UseZGC -jar benchmarks.jar
# RIGHT
java -jar benchmarks.jar -jvmArgs "-XX:+UseZGC -Xms2g -Xmx2g"
```
JMH starts a separate JVM for each trial. Flags on the outer `java` command configure the *harness*,
not the forked JVM under measurement. Run it the wrong way twice and you get two runs of the default
collector, a pleasingly small difference between them, and a blog post.
Verify with `-XX:+PrintFlagsFinal` inside the fork, or simply read JMH's own `# VM options:` banner —
it prints the fork's arguments.
## 6.7 Measuring an empty heap
A collector with nothing to trace is fast. A benchmark that only allocates immediately-dead garbage
measures the TLAB allocation path and nothing else — and every collector has essentially the same
TLAB path.
The interesting variable is the **live set**: how much genuinely reachable data must be marked, and
how often it is mutated. This repository's harness holds ~585 MB live in a 2 GB heap and rewrites a
random slot on 20% of requests. `PromotionBench` parameterises the same idea directly through
`survivorDepth`.
## 6.8 Dead-code elimination
If the JIT can prove your allocation is unused, it deletes it, and you benchmark an empty loop. Use
JMH's `Blackhole` or return values, and — critically — **check `gc.alloc.rate.norm`** from
`-prof gc`. It reports bytes allocated per operation. If that number is not what the source code
implies, escape analysis removed something and the benchmark is measuring nothing.
## 6.9 One run is not a measurement
Everything in [`results/`](../results) is a single run on a single laptop, and is presented as an
illustration of *shape*, not as a benchmark result you should plan capacity from. Ryzen 5 5600U,
Windows 11, 15.3 GB RAM, JDK 25.0.3 — with browser tabs, an OS, and thermal limits. Server hardware,
Linux, and a quiet machine will produce different absolute numbers.
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.
---
Next: [07-corner-cases.md](07-corner-cases.md).

163
docs/07-corner-cases.md Normal file
View File

@@ -0,0 +1,163 @@
# 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/`](../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:
```console
# 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 **3040%** just by switching
collector, with no application change.
- **Object header layout differs**, so heap-size estimates from a G1 heap dump do not transfer.
- **`-Xmx` is 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.
- `UseCompressedClassPointers` stays `true` on 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:
```console
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.
```console
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
```console
# -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
```console
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:MaxRAMPercentage` is the right knob in containers; `-Xmx` hard-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:+UseStringDeduplication`
is 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:+AlwaysPreTouch`** is `false` by default on both, and is worth turning on for both if you
care about first-minute latency.
---
Next: [08-choosing.md](08-choosing.md).

97
docs/08-choosing.md Normal file
View File

@@ -0,0 +1,97 @@
# 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 3050% 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).