Generational Shenandoah (JEP 516): Best GC for Low Latency
GC Background: Why Generations Matter
The Generational Hypothesis is the foundation of most modern GC design:
Most objects die young.
In a typical Java application, the vast majority of objects are short-lived: request/response objects, DTOs, builder instances, stream pipeline intermediates. They are created, used briefly, and then immediately eligible for collection.
A GC that knows about this pattern can be far more efficient than one that treats all objects equally:
- Young generation (Eden + Survivor): newly allocated objects land here. Collected frequently and cheaply because most objects are already dead.
- Old generation: objects that survive multiple young-generation collections get promoted here. Collected infrequently — only when necessary.
G1 (the default Java GC since Java 9) and ZGC (which went generational in Java 21) both exploit this. Shenandoah did not — until Java 25.
Shenandoah Before Java 25
Shenandoah was designed with one goal: sub-millisecond GC pauses at any heap size. It achieves this via concurrent compaction — it moves objects while application threads are running, using a load barrier to redirect pointer reads in real time.
The original Shenandoah design was non-generational: it treated the entire heap as one region and collected it uniformly. This worked, but left performance on the table:
- Every GC cycle had to consider all live objects, not just the young ones
- Short-lived objects had to wait until a full heap scan triggered
- CPU overhead from the concurrent GC threads was higher than necessary
Generational Shenandoah in Java 25
JEP 516 adds a young generation to Shenandoah. The heap is now split:
┌─────────────────────────────────────────────────────────────────┐
│ Young Generation (smaller, collected frequently) │
│ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Eden │ │ Survivor0 │ │ Survivor1 │ │
│ └──────────┘ └───────────┘ └───────────┘ │
├─────────────────────────────────────────────────────────────────┤
│ Old Generation (larger, collected less often) │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Tenured objects │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
Young-generation collections are:
- More frequent: triggered by Eden filling up (happens often)
- Cheaper: fewer live objects to scan (most are already dead)
- Still concurrent: Shenandoah’s core concurrent magic still applies
Old-generation collections happen less often, and when they do, Shenandoah’s concurrent approach ensures pauses remain sub-millisecond.
How to Enable Generational Shenandoah
# Enable Shenandoah GC with generational mode (Java 25)
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar myapp.jar
# Check which GC is running (print GC details)
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational \
-Xlog:gc*:file=gc.log:tags,uptime,time \
-jar myapp.jar
The non-generational mode (-XX:ShenandoahGCMode=satb) still works if you need it. Generational is the new default when you specify Shenandoah.
GC Comparison: Which Collector to Choose?
Java 25 ships four production GCs. Here is when to use each:
| GC | Strengths | Best for |
|---|---|---|
| G1 (default) | Balanced throughput + latency, simple tuning | Most applications — keep unless you have specific needs |
| ZGC (generational since Java 21) | Sub-ms pauses, scales to TB heaps | Very large heaps (>32GB), latency-critical services |
| Shenandoah (generational since Java 25) | Sub-ms pauses, lower memory overhead than ZGC | Moderate heaps (4–32GB), latency-critical, memory-constrained |
| Serial / Parallel | Maximum throughput, simple | Batch jobs, CLI tools, embedded — where pauses don’t matter |
The key difference between ZGC and Shenandoah:
- ZGC uses colored pointers (stores GC metadata in pointer bits) — requires more metadata, slightly higher memory overhead
- Shenandoah uses a load barrier approach — lower memory overhead, similar pause characteristics
For a containerized microservice with a 4–8GB heap that must respond in under 10ms p99, Shenandoah generational is now the strongest choice.
Enabling with a Simple Application
// AllocationStorm.java — generates heavy allocation to show GC behavior
import module java.base;
public class AllocationStorm {
record Metric(String name, double value, Instant timestamp) {}
public static void main(String[] args) throws InterruptedException {
var metrics = new ArrayList<Metric>();
var random = new Random();
long iterations = 0;
System.out.println("GC: " + ManagementFactory.getGarbageCollectorMXBeans()
.stream().map(b -> b.getName()).toList());
while (true) {
// Allocate 10,000 short-lived Metric records per batch
for (int i = 0; i < 10_000; i++) {
metrics.add(new Metric(
"sensor." + random.nextInt(100),
random.nextGaussian() * 50 + 100,
Instant.now()
));
}
// Retain only the last 50,000 — discard the rest (simulates sliding window)
if (metrics.size() > 50_000) {
metrics = new ArrayList<>(metrics.subList(metrics.size() - 50_000, metrics.size()));
}
iterations++;
if (iterations % 100 == 0) {
long used = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
System.out.printf("Iteration %,d | Heap used: %,.1f MB%n",
iterations, used / 1_048_576.0);
}
Thread.sleep(1);
}
}
}
Run with Shenandoah generational and watch GC logs:
java -XX:+UseShenandoahGC \
-XX:ShenandoahGCMode=generational \
-Xmx512m \
-Xlog:gc*::tags,uptime \
AllocationStorm.java
You’ll see frequent, cheap young-gen collections handling the short-lived Metric objects, with rare old-gen collections for the retained 50,000.
Tuning Generational Shenandoah
In most cases, the defaults work well. If you need to tune:
# Adjust young generation size (default: automatically sized)
-XX:ShenandoahYoungRegionFraction=4 # young gen = 1/4 of heap
# Heuristic for when to start concurrent GC (default: adaptive)
-XX:ShenandoahGCHeuristics=adaptive
# Soft max heap — Shenandoah tries to keep live data below this
# before the hard -Xmx kicks in
-XX:SoftMaxHeapSize=400m -Xmx512m
# Thread count for concurrent GC (default: auto)
-XX:ConcGCThreads=4
The adaptive heuristic (default) monitors allocation rate and heap occupancy to decide when to start a GC cycle. It is well-tuned for most workloads.
Pause Time Comparison
On a latency-sensitive service (e.g., a low-latency API gateway) with an 8GB heap and mixed short-lived and long-lived data:
| GC | p99 pause | p99.9 pause | Memory overhead |
|---|---|---|---|
| G1 (Java 25 default) | ~8ms | ~25ms | Low |
| ZGC Generational (Java 25) | <1ms | <2ms | Medium |
| Shenandoah Generational (Java 25) | <1ms | <2ms | Low-Medium |
| Parallel GC | 100–500ms | 1–2s | Lowest |
Approximate figures from JEP benchmarks — actual numbers depend heavily on workload.
For most latency-sensitive services, both ZGC and Shenandoah deliver equivalent pause times. Choose based on heap size and memory constraints.
Summary
Generational Shenandoah (JEP 516) is a final, production-ready feature in Java 25. It brings the generational collection strategy to Shenandoah, improving throughput on short-lived-object workloads while maintaining Shenandoah’s core sub-millisecond pause guarantee.
Enable it with: -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational
Zero code changes. Zero application changes. Pure JVM-level win.
Next up: AOT Compilation →