ZGC and Shenandoah: Low-Pause GCs in Java 15

Introduction A garbage collector (GC) frees the memory of objects a program no longer uses. Most collectors must sometimes stop all of the application's threads to do part of their work - a pause. With large heaps, these pauses could last hundreds of milliseconds or even seconds, which is a problem for services that must answer quickly. Java 15Click to read tutorial: Overview of New Features in Java 15 made two low-pause garbage collectors production-ready: ZGC (JEP 377), developed by Oracle, and Shenandoah (JEP 379), developed by Red Hat. Both do almost all their work while the application keeps running, so pauses stay at around a millisecond or less, whatever the size of the heap. This tutorial explains how they achieve this, how they compare with the default G1 collector, and how to enable and observe them, with a runnable comparison. Why garbage collectors pause To free memory, a collector must find the live objects (marking), and then usually move them together so that free memory is in one large block (compaction). Moving an object is the hard part: every reference to it, anywhere in the heap or on any thread's stack, must be updated to point to the new address. The simplest way to do that safely is to stop all application threads while objects are moved. G1, the default collector since Java 9, marks the old generation concurrently, but still moves objects in stop-the-world pauses, whose length grows with the amount of live data being moved. How ZGC and Shenandoah avoid long pauses Both collectors move objects concurrently, while application threads are running. To make that safe, they intercept the application's accesses to object references with small pieces of code called barriers -
  • ZGC uses load barriers and colored pointers: a few bits of each 64-bit reference record the state of the object it points to. When the application loads a reference which points to an object that has been moved, the barrier notices from the color bits and fixes the reference on the spot.
  • Shenandoah also uses load barriers to make sure the application always sees the moved copy of an object, and copies objects concurrently with the application.
The remaining pauses only handle a small, fixed amount of work - such as scanning thread roots - so they do not grow with the heap size. ZGC supports heaps from a few hundred megabytes up to 16 terabytes. The cost is some extra CPU and memory for the concurrent work and the barriers, which can lower the application's overall throughput compared with G1.
Enabling the collectors In Java 15 neither collector needs -XX:+UnlockExperimentalVMOptions any more -
java -XX:+UseZGC -Xmx4g -jar app.jar
java -XX:+UseShenandoahGC -Xmx4g -jar app.jar
Note - Shenandoah is included in most OpenJDK builds, such as Eclipse Temurin, Amazon Corretto and Red Hat's builds, but not in Oracle's JDK builds, where -XX:+UseShenandoahGC fails with "Option -XX:+UseShenandoahGC not supported". Comparing G1, ZGC and Shenandoah The program below allocates objects as fast as it can for two seconds, keeping some of them alive, and then reports what each garbage collector did, using the JVM's GarbageCollectorMXBeans -
Measuring collections and pauses
//GcComparison.java
import java.lang.management.GarbageCollectorMXBean;
import java.lang.management.ManagementFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.Random;

public class GcComparison {
  public static void main(String[] args) {
    List<byte[]> live = new ArrayList<>();
    Random random = new Random(1);
    long allocated = 0, end = System.nanoTime() + 2_000_000_000L;
    while (System.nanoTime() < end) {                  // allocate for 2 seconds
      byte[] block = new byte[1024 + random.nextInt(8192)];
      allocated += block.length;
      if (random.nextInt(100) < 3) {                    // keep about 3% alive for a while
        live.add(block);
        if (live.size() > 20_000) live.subList(0, 10_000).clear();
      }
    }
    System.out.printf("Allocated about %,d MB in 2 seconds%n", allocated / (1024 * 1024));
    for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
      System.out.printf("  %-24s count=%-5d time=%d ms%n", gc.getName(), gc.getCollectionCount(), gc.getCollectionTime());
    }
  }
}
Running it with each collector and a 256 MB heap -
java -Xmx256m GcComparison
java -Xmx256m -XX:+UseZGC GcComparison
java -Xmx256m -XX:+UseShenandoahGC GcComparison
 OUTPUT with G1 (the default)
Allocated about 27,197 MB in 2 seconds
  G1 Young Generation      count=218   time=324 ms
  G1 Concurrent GC         count=92    time=14 ms
  G1 Old Generation        count=0     time=0 ms
 OUTPUT with ZGC
Allocated about 23,471 MB in 2 seconds
  ZGC Minor Cycles         count=333   time=700 ms
  ZGC Minor Pauses         count=1027  time=6 ms
  ZGC Major Cycles         count=47    time=388 ms
  ZGC Major Pauses         count=207   time=1 ms
 OUTPUT with Shenandoah
Allocated about 14,851 MB in 2 seconds
  Shenandoah Pauses        count=469   time=30 ms
  Shenandoah Cycles        count=117   time=435 ms
Explanation of the output
  • These numbers are from one run on a Windows laptop with a recent OpenJDK build (Eclipse Temurin 25), and will differ on your machine and from run to run - but the pattern is typical.
  • For G1, the "Young Generation" time is time spent in stop-the-world pauses. For ZGC and Shenandoah the JVM reports Pauses and Cycles separately: the cycles are the concurrent work, done while the application ran, and only the pause time actually stopped the application. Compare the total pause times: ZGC and Shenandoah stopped the application for a small fraction of G1's pause time, with each pause far shorter on average.
  • The amount allocated shows the other side of the trade-off: in this run G1 got through the most allocation. Low-pause collectors spend CPU on concurrent work and barriers, which can reduce throughput.
  • ZGC's output shows Minor and Major collections because, since Java 21, ZGC is generational - it collects young objects more often than old ones, which made it more efficient. In Java 15, ZGC was not yet generational.
Which collector should you use?
  • G1 (default) - a good balance of throughput and pause times for most applications. Start here.
  • ZGC or Shenandoah - for latency-sensitive services, such as APIs with strict response-time targets, trading systems or interactive applications, and especially with large heaps, where G1's pauses would grow.
  • Parallel GC (-XX:+UseParallelGC) - for batch jobs where only total throughput matters and pauses are acceptable.
Always measure with your own application's workload, using GC logs (-Xlog:gc) or the Java Flight RecorderClick to read tutorial: Java 14 JFR Event Streaming Tutorial with Examples, before and after switching. What happened next Both collectors kept improving after Java 15. ZGC became generational in Java 21 (read about generational ZGCClick to read: read about generational ZGC), generational mode became the default in Java 23, and the old non-generational mode was removed in Java 24. Shenandoah gained an experimental generational mode in Java 24, which became a product feature in Java 25. Summary In this tutorial we saw why garbage collectors pause - moving objects requires every reference to them to be updated - and how ZGC and Shenandoah, production-ready since Java 15, move objects concurrently using load barriers, keeping pauses short regardless of heap size. We enabled them with -XX:+UseZGC and -XX:+UseShenandoahGC, compared them with G1 on the same workload, and saw the trade-off between pause times and throughput. Lastly, we looked at how to choose a collector, and how both collectors evolved later. Next, read about hidden classesClick to read tutorial: Java 15 Hidden Classes Explained with Examples.