Guide contents

Lesson 46 of 71

ZGC and Shenandoah

ZGC and Shenandoah are the two low-pause collectors in OpenJDK. Both move objects while application threads keep running, so pause times stay short and do not grow with heap size or live-set size. This lesson builds on GC Fundamentals (marking, compaction, barriers, SATB) and G1 in Depth (regions, evacuation). After it, a reader can explain how concurrent compaction works, what still stops the application, and how to choose between G1, ZGC, and Shenandoah.

Idea Collector
Concurrent compaction ZGC, Shenandoah
Colored pointers, load barriers, remapping ZGC
Generational mode (only mode since Java 24) ZGC
Allocation stalls and heap headroom ZGC (Shenandoah has a similar fallback)
Load-reference barriers, forwarding pointers Shenandoah
satb and generational modes Shenandoah

All examples fix the collector and the heap with jvm= options. Values that depend on timing are printed as checks.

Concurrent compaction

G1 marks the heap concurrently but copies live objects (evacuation) inside a stop-the-world pause. The pause length grows with the amount of live data in the collection set. Concurrent compaction is copying live objects to new memory while application threads run. It raises a problem: a thread can hold a reference to the old copy of an object that the collector has moved. Each collector solves this with a barrier, a small piece of code the JIT compiler inserts around reference accesses.

Collector Barrier on reference load How a stale reference is fixed
ZGC Load barrier checks color bits in the pointer Barrier looks up the forwarding table, returns the new address, and repairs the field
Shenandoah Load-reference barrier checks whether the object is in the collection set Barrier reads the forwarding pointer, copying the object first if needed

Example 1: running ZGC

-XX:+UseZGC selects ZGC. Its GarbageCollectorMXBeans separate cycles (the whole concurrent collection) from pauses (the short stop-the-world parts).

Java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.util.Arrays;
import java.util.TreeSet;

public class ZgcBeans {
    public static void main(String[] args) {
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println("collector: " + gc.getName()
                    + " pools=" + Arrays.toString(gc.getMemoryPoolNames()));
        }
        TreeSet<String> heapPools = new TreeSet<>();
        for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
            if (pool.getType() == MemoryType.HEAP) heapPools.add(pool.getName());
        }
        System.out.println("heap pools: " + heapPools);
    }
}
Output
collector: ZGC Minor Cycles pools=[ZGC Young Generation, ZGC Old Generation]
collector: ZGC Minor Pauses pools=[ZGC Young Generation, ZGC Old Generation]
collector: ZGC Major Cycles pools=[ZGC Young Generation, ZGC Old Generation]
collector: ZGC Major Pauses pools=[ZGC Young Generation, ZGC Old Generation]
heap pools: [ZGC Old Generation, ZGC Young Generation]

There are two heap pools, young and old, because ZGC is generational. A minor collection collects the young generation only. A major collection collects both generations. Each has one bean for cycles and one for pauses, so pause statistics are not mixed with the much longer concurrent work.

Colored pointers and load barriers

ZGC stores metadata in some bits of every heap reference. These are colored pointers: the color bits say whether the reference has been checked in the current phase (marked, remapped). Every time application code loads a reference from a heap field, a load barrier tests the color bits. In the common case the color is "good" and the barrier costs a test and a branch. When the color is "bad", the barrier takes a slow path: it marks the object, or finds where the object moved, then writes the corrected reference with a good color back into the field it loaded from. This repair is called self-healing: each stale field is fixed at most once.

Colored pointers have consequences:

  • ZGC does not support compressed object pointers, so references are 8 bytes in ZGC heaps. Object layout is described in Object Layout and Allocation.
  • The barrier runs on loads, not only on stores. Throughput is lower than G1 for many workloads because reference loads are more frequent than stores.
  • Phases flip by changing which color is "good". No thread has to update all references at once.

Relocation and remapping

A ZGC cycle has these steps:

  1. Mark live objects concurrently, starting from roots found in a short pause.
  2. Select a relocation set: the regions (ZGC calls them pages) with the least live data.
  3. Relocate: copy live objects out of those pages concurrently. A forwarding table per page records old address to new address. If an application thread loads a reference to an object that has not moved yet, its load barrier copies the object itself.
  4. Remap: update remaining references to point to new copies. ZGC does this lazily: load barriers repair references as they are touched, and the next marking pass repairs the rest. After that the forwarding tables are freed.

The pages in the relocation set become free as soon as their live objects are copied, before every reference is updated, because the forwarding table remembers where each object went.

Example 2: concurrent compaction keeps objects intact

This program builds a fragmented heap: it keeps every eighth array and drops the rest. ZGC must relocate the survivors to free whole pages. The checksum shows the data survived moving, and the bean counts show cycles happened.

Java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class Relocation {
    public static void main(String[] args) {
        List<int[]> kept = new ArrayList<>();
        for (int round = 0; round < 50; round++) {
            for (int i = 0; i < 8_000; i++) {
                int[] a = new int[64];
                a[0] = i;
                if (i % 8 == 0) kept.add(a);          // 1 of 8 survives
            }
            if (kept.size() > 20_000) kept.subList(0, 10_000).clear();
        }
        System.gc();

        long sum = 0;
        for (int[] a : kept) sum += a[0];
        System.out.println("kept arrays: " + kept.size());
        System.out.println("checksum: " + sum);

        long cycles = 0, pauses = 0;
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            if (gc.getName().endsWith("Cycles")) cycles += gc.getCollectionCount();
            else pauses += gc.getCollectionCount();
        }
        System.out.println("cycles > 0: " + (cycles > 0));
        System.out.println("at least 3 pauses per cycle: " + (pauses >= 3 * cycles));
    }
}
Output
kept arrays: 20000
checksum: 79920000
cycles > 0: true
at least 3 pauses per cycle: true

The relocated arrays keep their contents, because relocation copies the whole object and every later load goes through the barrier to the new copy. The pause count is at least three times the cycle count: each young collection has three short pauses (Pause Mark Start, Pause Mark End, Pause Relocate Start), and a major collection adds two more for the old generation.

Generational ZGC

ZGC was first a single-generation collector. Generational ZGC was added in Java 21 behind -XX:+ZGenerational, became the default ZGC mode in Java 23, and the non-generational mode was removed in Java 24. On JDK 25 the flag is accepted and ignored with a warning:

Text
$ java -XX:+UseZGC -XX:-ZGenerational -version
OpenJDK 64-Bit Server VM warning: Ignoring option ZGenerational; support was removed in 24.0
openjdk version "25.0.4.1" 2026-08-18

The generational hypothesis from GC Fundamentals applies: young collections reclaim most garbage while scanning little memory, so the old generation is marked less often. Young and old collections can run at the same time. ZGC uses store barriers for the remembered set that tracks old-to-young references, in addition to load barriers.

Example 3: minor and major cycles

Short-lived allocation triggers minor collections. System.gc() requests a major collection. The GarbageCollectionNotificationInfo listener from G1 in Depth reports each cycle's cause.

Java
import com.sun.management.GarbageCollectionNotificationInfo;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentSkipListSet;
import javax.management.NotificationEmitter;
import javax.management.openmbean.CompositeData;

public class ZgcCycles {
    static volatile byte[] last;         // a field write keeps the JIT from removing the allocation

    public static void main(String[] args) throws Exception {
        Set<String> seen = new ConcurrentSkipListSet<>();
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            ((NotificationEmitter) gc).addNotificationListener((n, h) -> {
                var info = GarbageCollectionNotificationInfo.from((CompositeData) n.getUserData());
                if (info.getGcName().endsWith("Cycles")
                        && info.getGcCause().equals("System.gc()")) {
                    seen.add(info.getGcName() + " caused by System.gc()");
                }
            }, null, null);
        }

        long sink = 0;
        for (int i = 0; i < 2_000_000; i++) {
            last = new byte[256];                // replaced next iteration, so it dies at once
            sink += last.length;
        }
        System.gc();
        Thread.sleep(500);                        // notifications are delivered asynchronously

        TreeSet<String> counts = new TreeSet<>();
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            counts.add(gc.getName() + " > 0: " + (gc.getCollectionCount() > 0));
        }
        counts.forEach(System.out::println);
        seen.forEach(System.out::println);
        System.out.println("allocated MB: " + sink / 1_000_000);
    }
}
Output
ZGC Major Cycles > 0: true
ZGC Major Pauses > 0: true
ZGC Minor Cycles > 0: true
ZGC Minor Pauses > 0: true
ZGC Major Cycles caused by System.gc()
allocated MB: 512

About 512 MB of garbage passes through a 256 MB heap. The first cycles after startup are major "Warmup" cycles; once ZGC has measured the allocation rate it switches to minor cycles. Only the major cycle reports System.gc() as its cause, which shows that an explicit request collects both generations.

Example 4: what still stops the application

ZGC pauses do only work that does not grow with the heap: flipping colors, scanning thread stacks (done mostly concurrently using stack watermarks), and synchronizing phases. The pause beans report the total time spent in those pauses.

Java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;

public class ZgcPauses {
    public static void main(String[] args) {
        List<long[]> live = new ArrayList<>();
        for (int i = 0; i < 200_000; i++) live.add(new long[64]);   // about 100 MB live
        long sink = 0;
        for (int i = 0; i < 3_000_000; i++) sink += new long[32].length;
        System.gc();

        long pauseCount = 0, pauseMillis = 0, cycleMillis = 0;
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            if (gc.getName().endsWith("Pauses")) {
                pauseCount += gc.getCollectionCount();
                pauseMillis += gc.getCollectionTime();
            } else {
                cycleMillis += gc.getCollectionTime();
            }
        }
        System.out.println("live arrays: " + live.size() + ", sink > 0: " + (sink > 0));
        System.out.println("pauses > 0: " + (pauseCount > 0));
        System.out.println("average pause below 1 ms: " + (pauseMillis < pauseCount));
        System.out.println("pause time below cycle time: " + (pauseMillis < cycleMillis));
    }
}
Output
live arrays: 200000, sink > 0: true
pauses > 0: true
average pause below 1 ms: true
pause time below cycle time: true

With 100 MB live, the pauses are still far below a millisecond on average; the concurrent cycle time is much larger. getCollectionTime is in whole milliseconds, so the check compares totals. What can still delay a thread under ZGC:

Source Cause
GC pauses Short safepoints, typically well below 1 ms
Time to safepoint A thread in a long loop reaches the safepoint late (Safepoints, Warmup, and Deoptimization)
Allocation stall The heap fills before the cycle frees memory (Example 5)
Barrier slow paths A thread copies or remaps an object itself during relocation
CPU contention GC threads compete with application threads for cores

Heap headroom and allocation stalls

A concurrent collector frees memory while the application allocates. If the application allocates faster than the collector can free, the heap fills. ZGC then blocks the allocating thread until the cycle frees memory. This is an allocation stall, and the GC log reports it per thread. A representative excerpt from a run here with -Xlog:gc:

Text
[0.487s][info][gc] GC(7) Minor Collection (Allocation Stall)
[0.491s][info][gc] Allocation Stall (Thread-0) 4.780ms
[0.491s][info][gc] Allocation Stall (Thread-1) 4.843ms

A stall is not a GC pause, so the pause beans do not count it, but the thread waits as if it were paused. The remedy is headroom: a heap large enough that the allocation rate times the cycle duration fits in the free space. ZGC starts cycles early based on the measured allocation rate (ZAllocationSpikeTolerance, default 2.0 in JDK 25) and on SoftMaxHeapSize, a soft target that ZGC tries to stay under while -Xmx stays the hard limit.

Example 5: provoking stalls with a small heap

Four threads keep about 24 MB live and allocate at a high rate into a 64 MB heap. The notification cause Allocation Stall shows that ZGC had to start collections because threads were blocked.

Java
import com.sun.management.GarbageCollectionNotificationInfo;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.management.NotificationEmitter;
import javax.management.openmbean.CompositeData;

public class AllocationStall {
    public static void main(String[] args) throws Exception {
        AtomicBoolean stalled = new AtomicBoolean();
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            ((NotificationEmitter) gc).addNotificationListener((n, h) -> {
                var info = GarbageCollectionNotificationInfo.from((CompositeData) n.getUserData());
                if (info.getGcCause().equals("Allocation Stall")) stalled.set(true);
            }, null, null);
        }

        Object[] keep = new Object[24_000];               // about 24 MB live
        Runnable work = () -> {
            for (int i = 0; i < 2_000_000; i++) keep[i % keep.length] = new byte[1024];
        };
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) (threads[i] = new Thread(work)).start();
        for (Thread t : threads) t.join();
        Thread.sleep(500);

        System.out.println("completed allocations: " + 4 * 2_000_000);
        System.out.println("stall-caused collections seen: " + stalled.get());
    }
}
Output
completed allocations: 8000000
stall-caused collections seen: true

The program completes, so the stalls slowed it but did not fail it. With a 40 MB heap the same pattern ends with OutOfMemoryError in a run here, because ZGC throws it when a stalled allocation still cannot be satisfied after a collection.

Shenandoah

Shenandoah is a concurrent compacting collector developed by Red Hat. JDK 25 from this distribution includes it: java -XX:+UseShenandoahGC -version starts without error and needs no unlock flag. Some vendor builds of OpenJDK leave it out; on such builds the flag is rejected.

Shenandoah's first design used Brooks pointers: an extra word before every object pointing to the object itself, or to its new copy after relocation. Every access went through that word. Later versions replaced it with a forwarding pointer stored in the object header only while the object is being moved, and with a load-reference barrier (LRB): when a reference is loaded and points into the collection set, the barrier returns the forwarded copy, copying the object first if no copy exists yet. That is the same idea as ZGC's self-healing load barrier, but without color bits, so Shenandoah works with compressed object pointers.

A Shenandoah cycle has short pauses at Init Mark and Final Mark, concurrent evacuation, then a concurrent update-references phase bracketed by short Init Update Refs and Final Update Refs pauses. Representative excerpt from -Xlog:gc here:

Text
[0.498s][info][gc] GC(0) Pause Init Mark (unload classes) 0.013ms
[0.522s][info][gc] GC(0) Concurrent marking (unload classes) 17.688ms
[0.522s][info][gc] GC(0) Pause Final Mark (unload classes) 0.206ms
[0.524s][info][gc] GC(0) Concurrent class unloading 0.972ms

When allocation outruns the collector, Shenandoah finishes the cycle in a stop-the-world degenerated GC, and if that is not enough, a full GC. Both appear as pause actions in the notifications, so they can be alerted on.

Example 6: Shenandoah beans and fallback pauses

The default mode satb (the ShenandoahGCMode default in JDK 25) is single-generation, so there is one heap pool. The heap here is too small for the allocation rate on purpose, to show the pause actions.

Java
import com.sun.management.GarbageCollectionNotificationInfo;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentSkipListSet;
import javax.management.NotificationEmitter;
import javax.management.openmbean.CompositeData;

public class ShenandoahBeans {
    public static void main(String[] args) throws Exception {
        Set<String> actions = new ConcurrentSkipListSet<>();
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println("collector: " + gc.getName());
            ((NotificationEmitter) gc).addNotificationListener((n, h) -> {
                var info = GarbageCollectionNotificationInfo.from((CompositeData) n.getUserData());
                if (info.getGcName().endsWith("Pauses")) actions.add(info.getGcAction());
            }, null, null);
        }
        TreeSet<String> pools = new TreeSet<>();
        for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
            if (p.getType() == MemoryType.HEAP) pools.add(p.getName());
        }
        System.out.println("heap pools: " + pools);

        Object[] keep = new Object[24_000];
        Runnable work = () -> {
            for (int i = 0; i < 2_000_000; i++) keep[i % keep.length] = new byte[1024];
        };
        Thread[] threads = new Thread[4];
        for (int i = 0; i < threads.length; i++) (threads[i] = new Thread(work)).start();
        for (Thread t : threads) t.join();
        Thread.sleep(500);

        System.out.println("concurrent pauses seen: "
                + (actions.contains("Init Mark") && actions.contains("Final Mark")));
        System.out.println("fallback seen: "
                + (actions.contains("Degenerated GC") || actions.contains("Full GC")));
    }
}
Output
collector: Shenandoah Pauses
collector: Shenandoah Cycles
heap pools: [Shenandoah]
concurrent pauses seen: true
fallback seen: true

Example 7: Shenandoah generational mode

Generational Shenandoah was added as an experimental mode in Java 24. On JDK 25 here, -XX:ShenandoahGCMode=generational is accepted without -XX:+UnlockExperimentalVMOptions. The other mode names are checked by the JVM: iu (incremental update, removed) fails with Unknown -XX:ShenandoahGCMode option, and passive needs -XX:+UnlockDiagnosticVMOptions.

Java
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.util.Arrays;
import java.util.TreeSet;

public class ShenandoahGenerational {
    public static void main(String[] args) {
        var diag = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        System.out.println("mode: " + diag.getVMOption("ShenandoahGCMode").getValue());
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            System.out.println(gc.getName() + " " + Arrays.toString(gc.getMemoryPoolNames()));
        }
        TreeSet<String> pools = new TreeSet<>();
        for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
            if (p.getType() == MemoryType.HEAP) pools.add(p.getName());
        }
        System.out.println("heap pools: " + pools);
    }
}
Output
mode: generational
Shenandoah Pauses [Shenandoah Young Gen, Shenandoah Old Gen]
Shenandoah Cycles [Shenandoah Young Gen, Shenandoah Old Gen]
heap pools: [Shenandoah Old Gen, Shenandoah Young Gen]

The release in which the generational mode stops being experimental upstream is not stated here; the flag check above is the reliable test for a given build.

Example 8: one report for any collector

Production services often export GC data as metrics. This report groups beans by suffix and works unchanged on G1, ZGC, and Shenandoah, because it uses only the standard MXBeans. Run here with ZGC.

Java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryType;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;

public class GcReport {
    static volatile byte[] last;

    record PoolUse(String name, long usedMb, long maxMb) {}

    static String collectorFamily() {
        String first = ManagementFactory.getGarbageCollectorMXBeans().get(0).getName();
        if (first.startsWith("ZGC")) return "ZGC";
        if (first.startsWith("Shenandoah")) return "Shenandoah";
        if (first.startsWith("G1")) return "G1";
        return "other";
    }

    public static void main(String[] args) {
        List<byte[]> cache = new ArrayList<>();
        for (int i = 0; i < 40_000; i++) cache.add(new byte[1024]);     // about 40 MB kept
        for (int i = 0; i < 4_000_000; i++) last = new byte[512];          // short-lived

        System.out.println("family: " + collectorFamily());
        Map<String, Long> counts = new TreeMap<>();
        for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
            counts.put(gc.getName(), gc.getCollectionCount());
        }
        counts.forEach((name, n) -> System.out.println(name + " ran: " + (n > 0)));

        List<PoolUse> pools = new ArrayList<>();
        for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
            if (p.getType() != MemoryType.HEAP) continue;
            pools.add(new PoolUse(p.getName(), p.getUsage().getUsed() >> 20, p.getUsage().getMax() >> 20));
        }
        long totalUsed = pools.stream().mapToLong(PoolUse::usedMb).sum();
        System.out.println("heap pools: " + pools.size());
        System.out.println("used at least 40 MB: " + (totalUsed >= 40));
        System.out.println("cache size: " + cache.size());
    }
}
Output
family: ZGC
ZGC Major Cycles ran: true
ZGC Major Pauses ran: true
ZGC Minor Cycles ran: true
ZGC Minor Pauses ran: true
heap pools: 2
used at least 40 MB: true
cache size: 40000

G1, ZGC, and Shenandoah compared

Property G1 ZGC Shenandoah
Default in JDK 25 Yes (server-class machines) No No
Compaction In pauses (evacuation) Concurrent Concurrent
Typical pause Tens of ms, target MaxGCPauseMillis (200 default) Well below 1 ms Few ms or below
Pause grows with Live data in collection set Number of threads, roots Roots
Barriers Write (SATB plus card) Load plus store Load-reference plus SATB
Compressed oops Yes No Yes
Generational Yes Yes (only mode since 24) Optional mode
Overload behavior Longer pauses, full GC Allocation stalls Degenerated GC, full GC
Throughput Highest of the three Lower (barrier cost) Lower (barrier cost)
Heap headroom needed Moderate Large Large

Criteria for choosing:

  • Pause target above about 50 ms and throughput matters: G1.
  • Tail latency must stay in the low milliseconds or below and spare CPU and memory exist: ZGC or Shenandoah.
  • Heaps of hundreds of gigabytes with a latency target: ZGC, whose pauses do not grow with heap size.
  • Small heaps where compressed oops save memory and low pauses are required: Shenandoah, when the build includes it.

Interview points

  • ZGC and Shenandoah move objects concurrently; G1 moves them in pauses. That is why their pauses do not grow with the live set.
  • ZGC keeps metadata in color bits of each reference; load barriers check the color and self-heal stale references.
  • Relocation uses per-page forwarding tables; remapping happens lazily through barriers and the next marking.
  • Generational ZGC: Java 21 opt-in, default ZGC mode in Java 23, the only mode from Java 24. -XX:-ZGenerational is ignored with a warning on 25.
  • ZGC has three pauses per young cycle; time-to-safepoint and allocation stalls still delay threads.
  • An allocation stall is not counted in pause statistics; check the Allocation Stall log lines or notification cause.
  • Shenandoah started with Brooks pointers; it now uses load-reference barriers and keeps compressed oops.
  • Shenandoah falls back to degenerated GC and then full GC when allocation outpaces it.
  • Low-pause collectors trade throughput and memory headroom for latency.

Summary

  • ZGC beans on JDK 25: ZGC Minor Cycles, ZGC Minor Pauses, ZGC Major Cycles, ZGC Major Pauses; pools ZGC Young Generation and ZGC Old Generation.
  • Shenandoah beans: Shenandoah Pauses, Shenandoah Cycles; one pool Shenandoah in satb mode, Shenandoah Young Gen and Shenandoah Old Gen in generational mode.
  • This JDK 25 build includes Shenandoah; ShenandoahGCMode defaults to satb, and generational is accepted without an unlock flag.
  • ZGC does not use compressed oops; Shenandoah does.
  • ZAllocationSpikeTolerance defaults to 2.0; SoftMaxHeapSize is a soft target below -Xmx.
  • Headroom (free heap during a cycle) prevents stalls; size it from allocation rate times cycle duration.