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

93
jmh/pom.xml Normal file
View File

@@ -0,0 +1,93 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
JMH module for the ZGC-vs-G1 comparison.
Build: mvn -q clean package
Run: java -XX:+UseZGC -Xms2g -Xmx2g -jar target/benchmarks.jar
java -XX:+UseG1GC -Xms2g -Xmx2g -jar target/benchmarks.jar
Note that the GC flags go on the *outer* java command only if you also pass -jvmArgsAppend, or
JMH will fork a fresh JVM without them. The run scripts in ../scripts do this correctly; see
the README for the explanation, it is a classic way to publish a meaningless GC benchmark.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.zgc</groupId>
<artifactId>zgc-jmh</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>ZGC vs G1 JMH benchmarks (JDK 25)</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>25</maven.compiler.release>
<jmh.version>1.37</jmh.version>
<uberjar.name>benchmarks</uberjar.name>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>${maven.compiler.release}</release>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<finalName>${uberjar.name}</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,70 @@
package com.ankurm.zgc.jmh;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;
/**
* How fast can the application allocate short-lived objects?
*
* <p>This is the benchmark everyone writes first, and on its own it is close to useless -- but it
* is useful as a <em>baseline</em>, because it isolates the one thing that is almost purely a
* function of the allocation path rather than of the marking or relocation machinery.
*
* <p>What it actually measures:
*
* <ul>
* <li>The thread-local allocation buffer (TLAB) fast path, which is a pointer bump plus a bounds
* check on every collector. G1 and ZGC use the same idea here, so raw allocation of tiny
* objects tends to be a near-tie.</li>
* <li>The <em>size</em> of the young generation, indirectly: the more often the allocator has to
* refill a TLAB from a fresh region/page, the more the collector's page-allocation path
* shows up.</li>
* </ul>
*
* <p>Run it with {@code -prof gc} and read the {@code gc.alloc.rate.norm} column -- it reports
* bytes allocated per operation. If that number is not exactly what you expect from the source
* code, escape analysis removed an allocation and you are benchmarking nothing:
*
* <pre>
* java -jar target/benchmarks.jar AllocationBench -prof gc -jvmArgs "-XX:+UseZGC -Xms2g -Xmx2g"
* </pre>
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
@Fork(1)
public class AllocationBench {
/**
* Object sizes chosen to straddle the boundaries that matter:
* 64 B is a typical small POJO/array, 1 KB is a request buffer, 32 KB is large enough that
* on G1 it approaches the humongous threshold for small region sizes and on ZGC it stops
* fitting neatly into the small-page allocation path.
*/
@Param({"64", "1024", "32768"})
public int size;
@Benchmark
public byte[] allocate() {
// Returned (not consumed by a Blackhole) so JMH's return-value sink keeps it alive just
// long enough to prevent scalar replacement, without adding a second store to the loop.
byte[] b = new byte[size];
b[0] = 1;
return b;
}
/**
* The same allocation, but immediately dead. Comparing the two shows how much of the cost is
* the allocation itself versus keeping the reference alive for one more instruction.
*/
@Benchmark
public void allocateAndDrop(Blackhole bh) {
byte[] b = new byte[size];
b[0] = 1;
bh.consume(b[0]);
}
}

View File

@@ -0,0 +1,120 @@
package com.ankurm.zgc.jmh;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;
/**
* Isolates the cost of ZGC's <b>load barrier</b>.
*
* <p>This is the benchmark that explains ZGC's throughput deficit, and almost nobody publishes it.
*
* <h2>What a load barrier is</h2>
* ZGC stores metadata in unused high bits of every heap pointer ("colored pointers"). Whenever your
* code reads a reference field, the JIT emits a few extra instructions that check those bits and,
* if the pointer is stale (its object has been relocated, or has not been marked yet in this
* cycle), fix it up before your code ever sees it. That fix-up is what lets ZGC relocate objects
* while the application keeps running -- there is no stop-the-world phase in which pointers are
* corrected, because every read corrects its own pointer lazily.
*
* <p>The price is that <em>every reference load</em> costs more. Not primitive loads -- {@code
* int[]} and {@code long} fields are untouched -- only loads of references. G1 has no read barrier
* at all, so on a workload that is dominated by chasing pointers through a large object graph, G1
* has a structural advantage.
*
* <h2>How this benchmark separates the two</h2>
* {@link #chaseReferences()} walks a linked structure: every step is a reference load and therefore
* a barrier. {@link #sumPrimitives()} walks an {@code int[]} of identical length: same memory
* traffic, same cache behaviour, zero reference loads and therefore zero barriers. The difference
* between the two, measured under ZGC and again under G1, is as close to "the barrier tax" as you
* can get without reading assembly.
*
* <p>Both variants are also affected by whether a GC cycle is in progress -- ZGC's barrier is
* cheapest when the heap is "clean" and does more work during marking and relocation. Running with
* {@code -XX:ZCollectionInterval=1} forces cycles during the measurement and shows the worst case.
*
* <pre>
* java -jar target/benchmarks.jar LoadBarrierBench -jvmArgs "-XX:+UseZGC -Xms2g -Xmx2g"
* java -jar target/benchmarks.jar LoadBarrierBench -jvmArgs "-XX:+UseG1GC -Xms2g -Xmx2g"
* </pre>
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
@Fork(1)
public class LoadBarrierBench {
/** Node count. 1<<20 nodes is ~32 MB of nodes -- comfortably beyond L3 on most desktops. */
@Param({"1048576"})
public int nodes;
static final class Node {
Node next; // reference field -- a load barrier fires on every read of this
int value;
}
private Node head;
private int[] primitives;
@Setup(Level.Trial)
public void setup() {
// Build the chain in a shuffled order so that pointer-chasing genuinely misses cache
// rather than walking linearly through memory the prefetcher can predict. A benchmark
// whose "random" walk is actually sequential measures the prefetcher, not the barrier.
Node[] all = new Node[nodes];
for (int i = 0; i < nodes; i++) {
all[i] = new Node();
all[i].value = i;
}
int[] order = new int[nodes];
for (int i = 0; i < nodes; i++) order[i] = i;
java.util.Random rnd = new java.util.Random(20260730L);
for (int i = nodes - 1; i > 0; i--) {
int j = rnd.nextInt(i + 1);
int t = order[i]; order[i] = order[j]; order[j] = t;
}
for (int i = 0; i < nodes - 1; i++) {
all[order[i]].next = all[order[i + 1]];
}
head = all[order[0]];
primitives = new int[nodes];
for (int i = 0; i < nodes; i++) primitives[i] = i;
}
/** Reference-load dominated: one load barrier per step under ZGC, none under G1. */
@Benchmark
public int chaseReferences() {
int sum = 0;
Node n = head;
while (n != null) {
sum += n.value;
n = n.next; // <-- ZGC load barrier
}
return sum;
}
/** Primitive-load control: same element count, no reference loads, no barriers on any GC. */
@Benchmark
public int sumPrimitives() {
int sum = 0;
for (int v : primitives) sum += v;
return sum;
}
/**
* A "hot field" read of the same reference over and over. ZGC's barrier has a fast path that is
* just a test-and-branch when the pointer is already good, so this variant shows the *floor*
* of the barrier cost -- what you pay when nothing needs fixing up.
*/
@Benchmark
public void readSameReferenceRepeatedly(Blackhole bh) {
Node n = head;
for (int i = 0; i < 1024; i++) {
bh.consume(n.next); // <-- barrier, but always on an already-good pointer
}
}
}

View File

@@ -0,0 +1,75 @@
package com.ankurm.zgc.jmh;
import org.openjdk.jmh.annotations.*;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.TimeUnit;
/**
* Medium-lived objects: the workload that decides whether a generational collector actually helps.
*
* <h2>Why this exists</h2>
* The weak generational hypothesis says most objects die young. When that holds, a young collection
* is nearly free: the collector copies out the few survivors and declares the whole young region
* empty. When it does <em>not</em> hold -- when a large fraction of objects survive long enough to
* be promoted -- a generational collector does its work twice: once tracing them in the young
* generation, once again after promotion.
*
* <p>Real services sit in the middle. A request-scoped object graph that lives for the duration of
* a slow downstream call, a batch accumulating results, a connection buffer pool refilling itself:
* all of these produce objects that are neither immediately dead nor permanently live. This
* benchmark parameterises exactly that.
*
* <p>{@code survivorDepth} is the number of objects held alive at any moment. Depth 0 is pure
* young garbage (the friendly case). Large depths force promotion, and on generational ZGC that
* means the promoted objects must also be handled by the old-generation cycle.
*
* <p>This is also the benchmark where the JDK 25 G1 remembered-set improvements show up: with a
* high promotion rate, G1's mixed collections have more cross-region references to track.
*
* <pre>
* java -jar target/benchmarks.jar PromotionBench -prof gc -jvmArgs "-XX:+UseZGC -Xms2g -Xmx2g"
* </pre>
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@State(Scope.Thread)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
@Fork(1)
public class PromotionBench {
/**
* How many previously allocated payloads stay reachable.
* 0 -> every object dies immediately (best case for any generational GC)
* 4096 -> a modest working set that mostly dies in the young generation
* 262144 -> a large working set that forces sustained promotion into the old generation
*/
@Param({"0", "4096", "262144"})
public int survivorDepth;
@Param({"512"})
public int payloadBytes;
private Deque<byte[]> survivors;
@Setup(Level.Iteration)
public void setup() {
survivors = new ArrayDeque<>(Math.max(16, survivorDepth));
}
@Benchmark
public byte[] allocateWithSurvival() {
byte[] payload = new byte[payloadBytes];
payload[0] = 1;
if (survivorDepth == 0) {
return payload; // dies at the end of this method
}
survivors.addLast(payload);
if (survivors.size() > survivorDepth) {
return survivors.pollFirst(); // evicted after surviving `survivorDepth` allocations
}
return payload;
}
}

View File

@@ -0,0 +1,104 @@
package com.ankurm.zgc.jmh;
import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;
/**
* Isolates the cost of the <b>write/store barrier</b> -- the mechanism every generational collector
* needs in order to collect the young generation without scanning the old one.
*
* <h2>The problem being solved</h2>
* A young collection wants to mark only young objects. But an old object may hold the only
* reference to a young object. If the collector ignored the old generation entirely it would free a
* live object. Scanning the whole old generation would defeat the point of a young collection. So
* every generational collector intercepts reference stores and records the ones that create an
* old-to-young edge.
*
* <ul>
* <li><b>G1</b> dirties a <em>card</em> (a 512-byte granule of the heap) and hands it to
* concurrent refinement threads, which update per-region remembered sets. G1's barrier is
* famously the more expensive of the two: it has a pre-write component for SATB marking and
* a post-write component for card marking. JDK 24 moved G1's barrier expansion late into C2
* (JDK-8342382, "late barrier expansion"), which lets the optimiser see through more of it.
* <li><b>ZGC</b> before JEP 439 had <em>no</em> store barrier at all -- which is exactly why
* non-generational ZGC had to mark the entire heap every cycle. Generational ZGC added one,
* feeding per-page remembered sets stored as double-buffered bitmaps.</li>
* </ul>
*
* <h2>What this benchmark does</h2>
* {@link #storeOldToYoung()} writes a freshly allocated (young) object into an array that has been
* alive since {@code @Setup} and has therefore been promoted -- the barrier fires and records a
* cross-generational edge. {@link #storeNullIntoOld()} performs an identically shaped reference
* store whose value is {@code null}, which cannot create a cross-generational edge; comparing them
* separates "the barrier ran" from "the barrier had work to do". {@link #storePrimitiveIntoOld()}
* is the control: an {@code int} store into a long-lived array, which no collector needs to track.
*
* <pre>
* java -jar target/benchmarks.jar StoreBarrierBench -jvmArgs "-XX:+UseZGC -Xms2g -Xmx2g"
* java -jar target/benchmarks.jar StoreBarrierBench -jvmArgs "-XX:+UseG1GC -Xms2g -Xmx2g"
* </pre>
*/
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
@Fork(1)
public class StoreBarrierBench {
/** Size of the long-lived array being written into. Larger = more cards / more pages touched. */
@Param({"1048576"})
public int slots;
private Object[] oldArray;
private int[] oldPrimitiveArray;
private int cursor;
@Setup(Level.Trial)
public void setup() {
oldArray = new Object[slots];
oldPrimitiveArray = new int[slots];
for (int i = 0; i < slots; i++) {
oldArray[i] = new Object();
}
// Force the array (and its contents) into the old generation before measuring, otherwise
// the first iterations measure young-to-young stores, which no barrier needs to record.
System.gc();
System.gc();
}
private int nextSlot() {
// A strided walk rather than a sequential one, so consecutive stores land on different
// cards / pages. A sequential walk would dirty the same card repeatedly and the barrier's
// "already dirty" fast path would hide most of the cost.
cursor = (cursor + 4099) & (slots - 1);
return cursor;
}
/** Old-to-young reference store: the barrier fires AND has real work to record. */
@Benchmark
public void storeOldToYoung() {
oldArray[nextSlot()] = new Object();
}
/** Reference store of null: barrier code still runs, but records no cross-generational edge. */
@Benchmark
public void storeNullIntoOld() {
oldArray[nextSlot()] = null;
}
/** Old-to-old reference store: no new cross-generational edge is created. */
@Benchmark
public void storeOldToOld() {
oldArray[nextSlot()] = oldArray;
}
/** Control: a primitive store into an equally long-lived array. No barrier on any collector. */
@Benchmark
public int storePrimitiveIntoOld() {
int slot = nextSlot();
oldPrimitiveArray[slot] = slot;
return slot;
}
}