Versions and limits, and where JEP 535 actually stands. JEP 535 is listed among the features targeted to JDK 28 (InfoWorld, 24 September 2026), but it is not integrated in Temurin 28+21 EA, the build I used: plain-XX:+UseShenandoahGCstill starts in SATB mode there, as the probe below shows. So I benchmarked generational mode by asking for it explicitly with-XX:ShenandoahGCMode=generational, against SATB asked for explicitly. Other versions: Temurin 25.0.5+8 (probe only), Spring Boot 4.1.2, a 2 vCPU, 8 GB Linux machine, heap fixed at 1 GiB. The load generator ran on the same machine as the server, which makes the box small and noisy, so treat the numbers as a comparison between runs on one machine, not as capacity figures. Oracle’s jdk.java.net builds do not contain Shenandoah, so I used Temurin. Code and output: shenandoah-jdk28 module of the zgc-jdk25-benchmarks repository.
What JEP 535 changes, and what the default is today
Shenandoah has two modes. The older one, SATB, treats the heap as one space: every collection cycle marks the whole heap concurrently, using a snapshot taken when marking starts. The generational mode splits the heap into a young generation, collected often because most objects die young, and an old generation, collected less often. JEP 535 would make the generational mode the default and deprecate SATB, mainly so the project does not have to maintain two modes. That is the plan, not the present. I checked what the default is on both JDKs by starting a JVM with each flag combination and reading the line the collector prints at startup. This is the JDK 28 part ofoutput/01-gc-mode-probe.txt:
== JDK 28 EA: OpenJDK 64-Bit Server VM Temurin-28+21-202610221655 (build 28-beta+21-ea, mixed mode, sharing)
-- java -XX:+UseShenandoahGC -Xlog:gc+init -version
[0.006s][info][gc,init] Mode: Snapshot-At-The-Beginning (SATB)
-- java -XX:+UseShenandoahGC -XX:+PrintFlagsFinal -version (ShenandoahGCMode)
ccstr ShenandoahGCMode = satb {product} {default}
-- java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -Xlog:gc+init -version
[0.008s][info][gc,init] Mode: Generational
-- java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb -Xlog:gc+init -version
[0.007s][info][gc,init] Mode: Snapshot-At-The-Beginning (SATB)
From output/01-gc-mode-probe.txt.
Read the Mode: line after the first command and the ShenandoahGCMode line: with no mode flag the mode is Snapshot-At-The-Beginning (SATB) and ShenandoahGCMode defaults to satb. The JDK 25 section of the same file says the same. Generational mode exists in both and only has to be asked for, which is what makes a before-and-after comparison possible today. I will rerun the module the day a build contains the JEP, because then the plain flag and the explicit one should agree.
What the generational mode looks like in the GC log
Generational cycles are labelled in the log by the generation they collect, for example
Concurrent marking (Young) and Concurrent marking (Old). Two things in the logs matter for the rest of this article. First, old-generation marking is done in increments that a young collection can interrupt. The log says so in plain words, as in this excerpt from output/04-gc-events.txt:
== shenandoah-generational-4.log: what a 'Cancelling GC' line is (old marking preempted for a young cycle)
[22.999s] Preempting old generation mark to allow Young GC
[22.999s] Cancelling GC: Concurrent GC
So a line reading Cancelling GC: Concurrent GC is not an error: it is old marking being pre-empted so a young cycle can run. Second, a cycle can end in a full collection, which is a stop-the-world pause that compacts the whole heap. I saw two of those in ten Shenandoah runs, both in generational mode.- The probe script:
scripts/probe.sh; its full output:output/01-gc-mode-probe.txt. - JEP 535 is covered in InfoWorld: Java 28 starts to take shape; the JEP page itself did not load for me when I checked, so I rely on the article for its status.
- For the other collector change landing around JDK 28, see ZGC in JDK 28: adaptive heap sizing and faster startup.
The service and the load: a big live cache plus steady garbage
A generational collector is built for programs whose objects mostly die young while a smaller set lives long. I wrote a service with exactly that shape, because a service that allocates nothing long-lived would not exercise the old generation at all. It keeps a cache of 200,000 slots of 1 KiB each, about 210 MiB of live data, and each request replaces a few slots (so the old value becomes garbage in the old generation) and allocates about 100 KiB of short-lived arrays. These constants are fromsrc/main/java/com/ankurm/shenandoah/ShenandoahApplication.java:
/** 200,000 slots of 1 KiB is a live set of roughly 210 MiB. */
static final int SLOTS = 200_000;
/** Slots replaced per request: the old value becomes old-generation garbage. */
static final int REPLACED_PER_REQUEST = 8;
/** Short-lived 1 KiB arrays allocated per request: about 100 KiB of young garbage. */
static final int TEMPORARIES = 100;
and this is the whole request handler:
@GetMapping("/work")
String work() {
ThreadLocalRandom r = ThreadLocalRandom.current();
for (int i = 0; i < REPLACED_PER_REQUEST; i++) {
cache.set(r.nextInt(SLOTS), new byte[1024]);
}
byte[][] temp = new byte[TEMPORARIES][];
for (int i = 0; i < TEMPORARIES; i++) {
temp[i] = new byte[1024];
temp[i][0] = (byte) i;
}
sink = temp[r.nextInt(TEMPORARIES)][0] + cache.get(r.nextInt(SLOTS))[0];
return "ok";
}
The load generator, scripts/LoadGen.java, has two modes. The first is the one that produces the latency figures. It is an open loop: requests are scheduled at a fixed rate (3,000 per second here) no matter how the server is doing, and each request’s latency is measured from the time it was supposed to be sent. If the server stalls for two seconds, the thousands of requests that were due during the stall are charged for the wait. A closed loop, where each client waits for a reply before sending the next request, would instead go quiet during the stall and hide it, a flaw known as coordinated omission.
if (open) {
intended = start + next.getAndIncrement() * intervalNanos;
if (intended >= end) return;
long wait = intended - System.nanoTime();
if (wait > 0) LockSupport.parkNanos(wait);
} else {
if (intended >= measureFrom) {
int i = count.getAndIncrement();
if (i < latencies.length) latencies[i] = done - intended;
The second mode is a closed loop with 16 clients sending back to back; it reports peak throughput. A run starts the server with the collector’s flags, warms it up for 30 seconds, measures the open loop for 60 seconds, runs the closed loop for 20 seconds, and cuts the GC pauses out of the unified log to exactly the 60-second window. This is how the JVM is started, from scripts/bench-one.sh:
env -u JAVA_TOOL_OPTIONS "$JH/bin/java" -Xms1g -Xmx1g "$@" \
-Xlog:gc*:file="$LOG":time,uptime \
-jar "$JAR" --server.port=$PORT --spring.main.banner-mode=off --logging.level.root=WARN >/dev/null 2>&1 &
And these are the four configurations, from scripts/run-all.sh. They are interleaved, so any slow drift on the machine lands on all four alike:
./scripts/bench-one.sh "$J28" g1 "$rep" -XX:+UseG1GC | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" zgc "$rep" -XX:+UseZGC | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" shenandoah-satb "$rep" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=satb | tee -a output/02-runs.txt
./scripts/bench-one.sh "$J28" shenandoah-generational "$rep" -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational | tee -a output/02-runs.txt
What this setup can and cannot tell you
It can tell you how four collectors behave on one JDK build, one machine and one workload, with the same flags and heap, over five interleaved runs. It cannot tell you how they behave on a large heap, on many cores, with a different allocation pattern, or under a load generator that runs somewhere else. The server and the load generator share two vCPUs, so every run includes CPU contention between them and the collector’s own threads. The fixed 1 GiB heap with a roughly 210 MiB live set is a deliberately tight, busy configuration; a heap several times larger would give a generational collector more room. I measured stop-the-world pauses, request latency, peak throughput and peak resident memory. I did not measure CPU time per request, which is where a generational collector is supposed to pay off, so a claim about efficiency is outside what these runs show.
- Harness:
scripts/bench-one.sh,scripts/LoadGen.java,scripts/gc_pauses.py. - The service:
src/main/java/com/ankurm/shenandoah/ShenandoahApplication.java.
Results: pauses were tiny, but the latency tail was not
Here is the summary the harness writes after five interleaved runs of each collector, the median of each column, fromoutput/03-summary.txt:
median of 5 runs per configuration (latency from a fixed-rate open loop; throughput from a closed loop)
configuration p50 ms p99 ms p99.9 ms max ms peak req/s pauses pause p99 ms pause max ms pause total ms RSS MB
g1 0.63 29.2 128.1 179.5 8900 65 111.576 111.576 1384 1205
zgc 0.60 7.4 17.3 36.4 7117 155 0.090 0.168 3 1250
shenandoah-satb 0.78 14.9 43.6 86.4 5026 204 0.981 1.475 44 1212
shenandoah-generational 0.82 1557.5 1820.4 1864.7 5498 293 1.398 2.553 76 1227
From output/03-summary.txt.
The same numbers as a table:
| Collector | p50 ms | p99 ms | p99.9 ms | max ms | peak req/s | pauses | longest pause ms | total pause ms | peak RSS MB |
|---|---|---|---|---|---|---|---|---|---|
| G1 | 0.63 | 29.2 | 128.1 | 179.5 | 8,900 | 65 | 112 | 1,384 | 1,205 |
| ZGC (generational) | 0.60 | 7.4 | 17.3 | 36.4 | 7,117 | 155 | 0.168 | 3 | 1,250 |
| Shenandoah, SATB | 0.78 | 14.9 | 43.6 | 86.4 | 5,026 | 204 | 1.48 | 44 | 1,212 |
| Shenandoah, generational | 0.82 | 1,557.5 | 1,820.4 | 1,864.7 | 5,498 | 293 | 2.55 | 76 | 1,227 |
| p99 ms, run | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| G1 | 28.7 | 29.2 | 29.2 | 42.7 | 66.3 |
| ZGC (generational) | 7.3 | 7.0 | 7.4 | 8.0 | 7.5 |
| Shenandoah, SATB | 14.9 | 14.6 | 811.5 | 21.2 | 12.7 |
| Shenandoah, generational | 1,557.5 | 3,077.1 | 24.5 | 1,657.9 | 691.6 |
output/02-runs.txt.
Why the pauses and the tail disagree. The pause column only counts stop-the-world pauses, and for generational Shenandoah those stayed at a few milliseconds in every run but one. A latency stall of a second or more is not a GC pause by that definition. The open-loop measurement charges every request that was due during a stall, so one stall of about two seconds is enough to move the 99th percentile into seconds: in the 1,558 ms run the maximum was 1,906 ms, only a little above the p99, which is what a single long stall looks like. That is my reading of the numbers, not something the logs state.
- All per-run lines:
output/02-runs.txt; the medians:output/03-summary.txt. - How the medians are computed:
scripts/summarize.py.
What the GC logs say about the stalls, and what they do not
My first guess was allocation stalls, the situation where the collector cannot keep up and Shenandoah makes allocating threads wait or falls back to a degenerated or full collection. So I counted the events that would signal that in every Shenandoah log. This isoutput/04-gc-events.txt, produced by scripts/log-events.sh:
run cancelling_gc pause_full pause_degenerated failed_to_allocate
shenandoah-satb-1 0 0 0 0
shenandoah-satb-2 0 0 0 0
shenandoah-satb-3 0 0 0 0
shenandoah-satb-4 0 0 0 0
shenandoah-satb-5 0 0 0 0
shenandoah-generational-1 4 1 0 0
shenandoah-generational-2 7 1 0 0
shenandoah-generational-3 5 0 0 0
shenandoah-generational-4 9 0 0 0
shenandoah-generational-5 6 0 0 0
From output/04-gc-events.txt.
The two modes differ in the first two columns, and in nothing else. SATB had no Cancelling GC lines (they belong to the old-generation pre-emption described earlier) and no full pauses. Generational mode had a handful of Cancelling GC lines in every run, and a full pause in runs 1 and 2. Here is the one in run 1, copied from output/04-gc-events.txt:
== shenandoah-generational-1.log: the Pause Full
[34.040s] GC(62) Pause Full 737M->214M(1024M) 239.022ms
That is a 239 ms stop-the-world compaction, and it does not explain a stall of seconds on its own. And the last two columns are zero everywhere: no degenerated pauses and no Failed to allocate lines, so I have no direct evidence for allocation stalls either. Runs 3, 4 and 5 of the generational mode have no full pause and still show p99 values of 24 ms, 1,658 ms and 692 ms, so the full pause cannot be the whole story.
Where that leaves the cause. I do not know it. What the logs establish is that generational mode ran more collection cycles than SATB (a median of 293 pauses in the window against 204) and spent more time in pauses (76 ms against 44 ms), that two of its runs contain a full collection, and that its tail latency was far worse in four of five runs. What they do not show is a link between a specific log event and a specific stall. A stall of the same kind also hit one SATB run (812 ms p99) with no GC event at all, which suggests that this 2-vCPU machine produces stalls of its own, and that generational mode may be more exposed to them because it does more concurrent work. That second part is a hypothesis I did not test.
How I checked that the pause parser was not hiding anything
The obvious worry is that my parser dropped the interesting pauses. It keeps every log line that contains
Pause and ends in a duration, for all four collectors, and then keeps only those inside the 60 second window. I ran it over the whole of the run-1 log with no window and it reports 1,003 pauses with a longest of 239.022 ms, which is the full pause above. In the windowed result for the same run the longest pause is 1.4 ms, so that full pause fell outside the window, in the warm-up or just before the measurement began, and was correctly left out. In run 2 the full pause (209 ms) fell inside the window and is the 209 ms longest pause for that run. The parser is scripts/gc_pauses.py.- Event counts and log excerpts:
output/04-gc-events.txt; the script that produces them:scripts/log-events.sh. - If you want to look at the raw logs,
scripts/bench-one.shwrites them totarget/gclogson your machine. I did not commit them: they are large and specific to the box.
What I would do with this
If you run Shenandoah today, the default does not change until a build contains JEP 535, so nothing here forces a decision yet. When you do evaluate generational mode, do it with your own service and the flag pair-XX:ShenandoahGCMode=satb and -XX:ShenandoahGCMode=generational, because that isolates the one thing that will change. Pin the old mode explicitly before you upgrade if you want the behaviour you have now to survive the flip.
Measure the tail with an open-loop generator, not only the pause log: in these runs the two disagreed by a factor of several hundred. And repeat each configuration several times, interleaved, because the stalls came and went between runs of the same flags. Three runs would have given me a median of 1,557 ms for generational mode and 15 ms for SATB, and five gave me the same medians with a very different picture of how reliable they were.
As for the conclusion, the data supports a narrow one: on a 2 vCPU machine, a 1 GiB heap and this cache-plus-garbage workload, generational Shenandoah in JDK 28 EA was clearly worse than SATB Shenandoah for tail latency, and generational ZGC was the best of the four. It does not support the claim that generational Shenandoah is worse in general, since I did not test a larger heap, more cores or a separate load generator, and the JEP itself is not in the build. Those are the next three experiments.
Reproduce everything
You need a JDK 25+ to build, Temurin 25 and Temurin 28 EA (or any build that ships Shenandoah) to run, and Maven 3.9. The commands are from the module’s README.md.
git clone https://ankurm.com/git.app/asmhatre/zgc-jdk25-benchmarks.git
cd zgc-jdk25-benchmarks/shenandoah-jdk28
mvn -B -DskipTests package
export J25=/path/to/temurin-25 J28=/path/to/temurin-28-ea
scripts/run-all.sh # about 2.5 minutes per run, 12 runs
scripts/more-reps.sh 4 5 # two more interleaved reps of all four collectors
scripts/log-events.sh # stall-related events per Shenandoah run
Expect different absolute numbers on your hardware. The probe in output/01 is the file that will change first when a JDK 28 build includes the JEP.
What went wrong while building this
Four things cost me time. First, the Oracle early-access build of JDK 28 from jdk.java.net does not contain Shenandoah at all, so I switched to Temurin. Second, I had planned the article around “generational is now the default”, and the probe showed it is not, in either JDK, so the benchmark uses the explicit flag and the article says so at the top. Third, my first smoke test used a 3 second warm-up and every collector showed a p99 near one second, a start-up transient, so the runs use a 30 second warm-up and a fixed request rate. Fourth, the first three runs left me with a median I did not trust, which is why the module has
scripts/more-reps.sh and the results are medians of five.- Module README with the versions table:
README.md. - Per-run results:
output/02-runs.txt.
Further reading
- Companion code and every output file: zgc-jdk25-benchmarks / shenandoah-jdk28.
- ZGC in JDK 28: adaptive heap sizing and faster startup, the other JDK 28 collector change I measured.
- What is coming in JDK 28: value classes preview and strict field initialization.
- What actually got faster in JDK 27, measured on Spring Boot.
- A developer’s deep dive into Java memory management and garbage collection, for the generational background.
- Spring Boot and the JDK AOT cache (JEP 514 and 515): startup benchmarks.
- InfoWorld: Java 28 starts to take shape.
No Comments yet!