Skip to main content

Generational Shenandoah by Default in JDK 28 (JEP 535): Benchmarked Against G1 and ZGC

Generational Shenandoah is slated to become the default in JDK 28 (JEP 535), but it is not in the early-access build yet. I benchmarked it explicitly against SATB Shenandoah, G1 and generational ZGC on a Spring Boot service, with pauses, tail latency, throughput and RSS.

Shenandoah generational JDK 28 is the change I wanted to measure: JEP 535 proposes to make generational mode the default for the Shenandoah collector and to deprecate the old mode, which the JEP calls SATB (snapshot-at-the-beginning). The JEP is targeted to JDK 28, so the question for anyone running Shenandoah is simple: if the default flips under you, does your service get better or worse? I ran the same Spring Boot service on JDK 28 early access under four collectors, G1, generational ZGC, Shenandoah in SATB mode and Shenandoah in generational mode, at a fixed request rate, and recorded latency, GC pauses, peak throughput and resident memory. The honest headline is not a clean win for the new default. On my small test machine, generational Shenandoah had tiny GC pauses and still produced latency stalls of hundreds of milliseconds to several seconds in four of five runs, and I could not pin the cause on the collector from the logs alone. Everything below is in the companion repository, and every number is traceable to a file in it.
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:+UseShenandoahGC still 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 of output/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 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 from src/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.

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, from output/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:
Collectorp50 msp99 msp99.9 msmax mspeak req/spauseslongest pause mstotal pause mspeak RSS MB
G10.6329.2128.1179.58,900651121,3841,205
ZGC (generational)0.607.417.336.47,1171550.16831,250
Shenandoah, SATB0.7814.943.686.45,0262041.48441,212
Shenandoah, generational0.821,557.51,820.41,864.75,4982932.55761,227
Taking the collectors in turn. G1 has the best throughput of the four, about 8,900 requests per second at peak, and the worst pauses: a longest pause around 112 ms and 1.4 seconds of total stop-the-world time in the window, which is consistent with its 29 ms p99. Generational ZGC is the latency winner on this box: a 7.4 ms p99, pauses of 0.17 ms at most, and about 7,100 requests per second. Its resident memory is the highest of the four, at 1,250 MB against about 1,210 to 1,230 for the others, though with the heap fixed at 1 GiB the spread is small. Shenandoah in SATB mode sits between them: pauses of about a millisecond, a 14.9 ms p99 and about 5,000 requests per second. Generational Shenandoah is the surprise. Its pauses are as small as SATB’s (longest pause about 2.6 ms) and its throughput is slightly higher (5,498 against 5,026 requests per second), yet its median p99 is 1,557 ms. The chart shows why a single median is the wrong way to read that row: each dot is one run’s p99.
1 ms 10 ms 100 ms 1 s 10 s p99 per run, log scale (dot = one run, line = median of five) Open loop, 3,000 requests per second, 60 s window G1 ZGC (generational) Shenandoah, SATB Shenandoah, generational
The figure shows that G1 (29 to 66 ms), ZGC (7 to 8 ms) and SATB Shenandoah (13 to 21 ms) are tightly grouped except for one SATB run at 812 ms, while generational Shenandoah is all over the place: 24 ms in one run, and 692, 1,558, 1,658 and 3,077 ms in the others. The same numbers, per run:
p99 ms, run12345
G128.729.229.242.766.3
ZGC (generational)7.37.07.48.07.5
Shenandoah, SATB14.914.6811.521.212.7
Shenandoah, generational1,557.53,077.124.51,657.9691.6
Built from the per-run lines in 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.

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 is output/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.sh writes them to target/gclogs on 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.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.