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

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:
- JDK 17 to 20:
-XX:+UseZGCgives the original, single-generation ZGC. There is no generational option. - JDK 21 and 22:
-XX:+UseZGCalone still gives the non-generational mode. Generational ZGC needs-XX:+UseZGC -XX:+ZGenerational. - JDK 23: JEP 474 flips the default.
-XX:+UseZGCnow means generational ZGC; passing-XX:+ZGenerationalprints a deprecation warning, and-XX:-ZGenerationalselects the old mode with a second warning that it is deprecated for removal. - JDK 24 and later: the non-generational mode is gone, and
-XX:+UseZGCis the only switch you need.
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:
- No more multi-mapped heap. The original ZGC mapped the same physical memory at several virtual addresses, so tools such as
psortopcould report a resident size of about three times the heap. Generational ZGC uses explicit barrier code instead, and those inflated figures disappear. Monitoring that relied on the old, distorted numbers will look different. - Store barriers. Besides the load barriers that ZGC always had, the generational mode adds barriers on reference stores to track pointers from old to young objects. They cost a little on the application side; for most workloads the saving in GC work outweighs it.
- No compressed object pointers. ZGC always uses 64-bit references, because it keeps metadata bits inside them. A heap that holds many small objects can therefore be larger under ZGC than under G1 with compressed oops, and you should size it accordingly rather than copy the G1 value.
- Little to configure. Generation sizes, tenuring and the number of GC threads are adaptive by design. Most of the options you may know from G1 have no equivalent, and that is intended.
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:
- More heap headroom. Raise
-Xmx, or raiseSoftMaxHeapSizeif you set one too low. This is the cure in most cases. - 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.
- Less allocation. A profiler or JFR allocation events will show where memory is allocated. Reducing a hot allocation site helps every collector, ZGC included.
- 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
- JEP 439: Generational ZGC (JDK 21)
- JEP 474: ZGC: Generational Mode by Default (JDK 23), and JEP 490: ZGC: Remove the Non-Generational Mode (JDK 24)
- The Z Garbage Collector, OpenJDK wiki: configuration and tuning
- The java command reference and the JDK Flight Recorder event reference
Garbage collectionChoosing a garbage collector
Latency, throughput or footprint: what each collector trades, and why your container may be running Serial.
Garbage collectionReading GC logs with -Xlog
One line of configuration, the legacy flag mapping, and how to read pause lines for leaks and undersized heaps.
ProductionThe JVM in containers
How limits are detected, what MaxRAMPercentage really sets, and a budget that keeps the OOM killer away.