Garbage collection

Running generational ZGC in production: heap headroom, SoftMaxHeapSize and allocation stalls

Garbage collection · · Published 2026-11-02

Server racks with bundles of yellow network cables next to a power distribution cabinet in a data centre
ZGC works alongside the application, so it needs spare memory and spare CPU to do its job. Photo: D Coetzee, Flickr, CC0.

ZGC does almost all of its work while the application keeps running, which is why its pauses stay tiny whatever the heap size. The price is that the collector has to finish each cycle before the application runs out of free memory. Running ZGC well is mostly about that race: giving it enough headroom, enough CPU, and noticing when it loses.

Generational or not: check your JDK first

ZGC has existed in two forms, and the command line that selects each has changed across releases. As summarised in choosing a garbage collector, ZGC became a product feature in JDK 15, and the generational version arrived in JDK 21 with JEP 439. What you get depends on the release:

The practical consequence is easy to miss during an upgrade. A service that ran -XX:+UseZGC on JDK 21 was on single-generation ZGC; the same flag on JDK 23 or later changes the collector's behaviour, its log output and the data it exposes through management APIs. Dashboards and alerts built on GC metrics deserve a check after the move. If you are still on 21 and have chosen ZGC, adding -XX:+ZGenerational is usually the better choice; the JEP's stated aims include fewer allocation stalls, lower heap overhead and lower GC CPU use than the single-generation mode.

What generational ZGC changes

The original ZGC treated the whole heap as one space and marked all live objects in every cycle. That is wasteful for typical Java programs, where most objects die young. Generational ZGC splits the heap into a young and an old generation and collects them independently, so the frequent young collections only look at the region where most garbage is created.

A few implementation changes from JEP 439 matter in operations:

The main knob: the maximum heap

The ZGC project page on the OpenJDK wiki is blunt: the main tuning knob is the maximum heap size. The heap must hold the live set, and it must also leave room for the allocations that happen while a collection is running. Because ZGC collects concurrently, the application keeps allocating during the whole cycle. If the free memory runs out before the cycle ends, the threads that need memory have to wait.

So the right -Xmx is not "the live set plus a little". It is the live set plus what the application allocates during a GC cycle, plus a margin for bursts. To estimate it, run with GC logging on a representative load, as described in reading GC logs, and note the heap occupancy just after old collections (an approximation of the live set) and the allocation rate the log reports. Then add headroom and measure again. If memory is tight, it is generally better to give ZGC more heap than to tune anything else.

For latency-critical services the wiki also suggests setting -Xms equal to -Xmx and adding -XX:+AlwaysPreTouch, so that the operating system commits and touches every page of the heap at start-up instead of during the first busy minutes. Start-up becomes slower; the first requests become more predictable.

SoftMaxHeapSize: a target below the ceiling

-XX:SoftMaxHeapSize sets a soft limit that ZGC's heuristics try to stay under. With -Xmx5g -XX:SoftMaxHeapSize=4g, the collector plans its cycles to keep the heap around 4 GB, but it may grow towards 5 GB rather than stall the application. It gives you a buffer that is only used when allocation spikes beyond what the heuristics expected.

This is useful when you want a predictable footprint most of the time without the risk of a hard ceiling. It is not a substitute for a sensible -Xmx: if the soft limit is set so low that the collector runs constantly, you pay in CPU.

Returning memory, and memory in containers

By default ZGC uncommits memory it has not used for a while and returns it to the operating system. The delay is set by -XX:ZUncommitDelay, in seconds, with a default of 300; -XX:-ZUncommit turns the feature off. Memory is never uncommitted below -Xms, so with -Xms equal to -Xmx uncommit has no effect. Because committing memory again costs time, the wiki recommends disabling uncommit for extremely latency-sensitive workloads.

In a container, remember that the heap is only part of the process. Thread stacks, the code cache, Metaspace, direct buffers and GC structures all count against the memory limit. The budgeting method in the JVM in containers applies unchanged to ZGC; only the heap figure inside it differs.

Large pages

ZGC can use explicit huge pages with -XX:+UseLargePages, which reduces TLB pressure on large heaps. Setting them up usually requires root access to reserve pages in the kernel. The alternative is transparent huge pages, enabled with -XX:+UseLargePages -XX:+UseTransparentHugePages. The wiki notes that ZGC backs the heap with shared memory, so the kernel settings that matter are /sys/kernel/mm/transparent_hugepage/enabled set to madvise and shmem_enabled set to advise. It also warns that distributions which force transparent huge pages to always can distort comparisons between collectors. Measure before and after; do not assume a gain.

Allocation stalls: when the collector loses the race

An allocation stall is ZGC's characteristic failure mode. When a thread asks for memory and none is free, it blocks until the running collection reclaims some. The GC pauses in the log stay short, yet request latency jumps, which can be confusing if you only look at pause times.

Stalls show up in two places. With -Xlog:gc enabled, ZGC writes lines mentioning "Allocation Stall" together with the thread name and the time it waited; the exact format varies between releases. In a Flight Recorder recording, the jdk.ZAllocationStall event records the same information and can be lined up with request latency in JDK Mission Control. Any stall in production deserves attention, because each one is application time lost.

The fixes, in the order to try them:

  1. More heap headroom. Raise -Xmx, or raise SoftMaxHeapSize if you set one too low. This is the cure in most cases.
  2. More CPU for the collector. ZGC's threads run alongside the application. If the host or the container's CPU quota is saturated, they fall behind. The wiki advises keeping CPU utilisation below about 70 % for low-latency systems.
  3. Less allocation. A profiler or JFR allocation events will show where memory is allocated. Reducing a hot allocation site helps every collector, ZGC included.
  4. Generational mode on JDK 21 or 22. If you still run single-generation ZGC there, switching is one of the cheapest ways to reduce stalls.

Avoid reaching for -XX:ConcGCThreads first. ZGC sizes its thread count dynamically, and the wiki states that the generational mode rarely needs this option. Raising it takes CPU from the application; lowering it makes stalls more likely.

A minimal production command line

java -XX:+UseZGC -Xms8g -Xmx8g -XX:+AlwaysPreTouch \
     -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50m \
     -jar app.jar

On JDK 21 or 22, add -XX:+ZGenerational. The heap size here is an example: derive yours from the live set and allocation rate. Everything else can usually stay at its default until a log or a recording shows a reason to change it.

Sources