Part 10 of 12

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:

GCStrengthsBest for
G1 (default)Balanced throughput + latency, simple tuningMost applications — keep unless you have specific needs
ZGC (generational since Java 21)Sub-ms pauses, scales to TB heapsVery large heaps (>32GB), latency-critical services
Shenandoah (generational since Java 25)Sub-ms pauses, lower memory overhead than ZGCModerate heaps (4–32GB), latency-critical, memory-constrained
Serial / ParallelMaximum throughput, simpleBatch 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:

GCp99 pausep99.9 pauseMemory overhead
G1 (Java 25 default)~8ms~25msLow
ZGC Generational (Java 25)<1ms<2msMedium
Shenandoah Generational (Java 25)<1ms<2msLow-Medium
Parallel GC100–500ms1–2sLowest

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 →