G1GC vs. ZGC vs. Shenandoah for Kubernetes 1.35 In-Place Pod Resize
When a pod shrinks in place, the JVM has to hand memory back to the operating system before the new limit applies. I tested how G1, ZGC and Shenandoah do that in a Linux container. All three released memory quickly in my test, but they differ in what triggers the release and in what SoftMaxHeapSize does. G1 accepts that flag and silently ignores it.
The short answer
-
ZGC and Shenandoah:
SoftMaxHeapSizeworks at runtime. Lower it, runGC.run, and memory drops. -
G1:
SoftMaxHeapSizeis accepted byjcmdand has no effect. By default nothing returns memory. Two things did, in my test: an explicitGC.run, andG1PeriodicGCIntervalset to a few seconds. - Release speed: I measured no real difference. Each collector released a 300 MB working set within about 1 to 7 seconds. I did not measure large heaps or busy applications, which is where I'd expect differences, so treat "G1 is slower" as unproven.
-
The real G1 difference is the missing ceiling. Nothing holds G1's heap below a target after you release, so you size
-Xmxfor the peak and accept that it can grow back. - Lowering the limit first killed all three (OOMKilled, exit 137). Release first, then resize.
What each collector gives you
| ZGC | Shenandoah | G1 | |
|---|---|---|---|
SoftMaxHeapSize changes behavior |
Yes | Yes | No (flag accepted, no effect) |
| Returns memory by default | After ZUncommitDelay (300 s by default) |
After ShenandoahUncommitDelay
|
No, not without a trigger |
| What triggers a release |
SoftMaxHeapSize + GC.run, or the uncommit delay |
Same |
GC.run, or G1PeriodicGCInterval
|
| Pause cost of the trigger | Not measured | Not measured |
GC.run is a full, stop-the-world GC. Not measured at scale. |
The test
The setup is the same as in my earlier test of RestartContainer: Eclipse Temurin 21.0.13 in a Linux container on Docker Desktop, cgroup v2, and a small program that allocates and touches 300 MB, holds it for 10 seconds, then drops all of it and idles. docker update --memory=300m lowers the cgroup limit on the running container in place. That is the kernel mechanism a pod resize uses, but there is no kubelet.
For each collector, I ran two cases. Naive: lower the limit straight away. Ordered: set SoftMaxHeapSize=100MB, run GC.run, wait until memory.current falls, then lower the limit.
| Collector |
memory.current before |
Naive: lower limit to 300 MB | Ordered: release first, then lower |
|---|---|---|---|
ZGC (-Xmx600m, limit 900 MB) |
403 MB | OOMKilled, exit 137 | 404 → 89 MB in 3 s, then still running |
Shenandoah (-Xmx500m, limit 700 MB) |
372 MB | OOMKilled, exit 137 | 361 → 64 MB in 1 s, then still running |
G1 (-Xmx600m, 4 MB regions, limit 900 MB) |
456 MB | OOMKilled, exit 137 | 463 → 143 MB in 2 s, then still running |
G1 passes the ordered case, but I want to be exact about why. In that run I set SoftMaxHeapSize too, and it did nothing. The explicit GC.run did the work.
What actually released memory on G1
I tested each G1 lever on its own, starting from about 460 to 530 MB in use:
| Lever on G1 | Result |
|---|---|
| Nothing (default), wait 25 s | 459 → 459 MB. No release. |
SoftMaxHeapSize=100MB only, wait 20 s |
535 → 535 MB. No effect. |
GC.run only |
472 → 150 MB |
G1PeriodicGCInterval=3000 set live, no GC.run
|
518 → 156 MB in 7 s |
SoftMaxHeapSize is listed as a manageable flag on G1 in JDK 21, so jcmd VM.set_flag returns "Command executed successfully". That success message is misleading. Nothing in G1 reads the flag. If your automation sets the flag and then checks that the command succeeded, it will believe it lowered the ceiling on G1 when it did not.
How to make G1 release memory back to the OS
Pick one of these. I tested the first two.
-
Run
GC.runfrom outside.jcmd <pid> GC.runtriggers a full GC, after which G1 shrinks its heap. It is immediate, and it is a stop-the-world collection, so its pause grows with the live set. -
Enable periodic GC.
G1PeriodicGCIntervalis a manageable flag, default0(off). Set it at startup, or live:jcmd <pid> VM.set_flag G1PeriodicGCInterval 3000(milliseconds). In my test, memory fell within 7 seconds on an idle JVM. A related flag,G1PeriodicGCSystemLoadThreshold, is also manageable and, per JEP 346, can hold the periodic GC back while the system is busy. I did not test it. -
Tune
MinHeapFreeRatioandMaxHeapFreeRatio. These are manageable and control how much free heap G1 keeps after a collection. I did not test them here.
What this means for in-place resize
- Release before you resize, on every collector. The failure was identical across all three: lowering the limit while the JVM held committed memory got it killed.
-
Don't trust "command succeeded" on G1. Verify that
memory.currentactually dropped, then resize. In Kubernetes, the kubelet's check also compares current usage to the new limit and holds the resize while usage is higher, although the docs say that check is best-effort. -
Plan the ceiling on G1. With ZGC and Shenandoah,
SoftMaxHeapSizekeeps the heap near your target after release. On G1 nothing does, so a G1 heap that was just released can grow back toward-Xmxbefore the limit drops. I did not test how fast. The practical rule is the one from my earlier article: the smallest limit must exceed-Xmxplus non-heap memory, and on G1 you cannot rely on a soft ceiling to enforce it. -
Mind the delay on ZGC.
ZUncommitDelaydefaults to 300 seconds. I shortened it to 1 second for the test. Without that, a ZGC release can take minutes unless you also lower the soft ceiling and wait for the uncommit.
What I didn't test
- Heap sizes above about 600 MB, live data that doesn't die, or JVMs under load. A full GC on G1 can cost far more at scale, and I haven't measured it.
- Whether G1 re-expands after release.
-
G1PeriodicGCSystemLoadThreshold,MinHeapFreeRatioandMaxHeapFreeRatio. - A real kubelet. I used
docker update. - G1 with the default region size. I set 4 MB regions because my test allocates 1 MB arrays, which are "humongous" objects in G1's default configuration, and the first G1 run ran out of heap.
Reproduce it
You need Docker. Save the files below in an empty folder.
Hold.java:
import java.util.*;
public class Hold {
public static void main(String[] a) throws Exception {
List<byte[]> l = new ArrayList<>();
for (int i = 0; i < 300; i++) { byte[] b = new byte[1024*1024]; Arrays.fill(b,(byte)1); l.add(b); }
System.out.println("allocated 300MB"); System.out.flush();
Thread.sleep(10000);
l.clear(); l = null;
System.out.println("released refs"); System.out.flush();
Thread.sleep(600000);
}
}
run.sh runs the naive and ordered cases for any collector:
#!/bin/bash
# usage: ./run.sh <naive|ordered> <jvm flags...> (run from the folder containing Hold.java)
# Example: LIM=900 XMX=600 ./run.sh ordered -XX:+UseZGC -XX:ZUncommitDelay=1
set -u
mode=$1; shift; n=warden-test
docker rm -f $n >/dev/null 2>&1
docker run --rm -v "$PWD":/w -w /w eclipse-temurin:21-jdk javac Hold.java
docker run -d --name $n --memory=${LIM:-700}m --memory-swap=${LIM:-700}m -v "$PWD":/w -w /w \
eclipse-temurin:21-jdk java "$@" -Xmx${XMX:-500}m Hold >/dev/null
for i in $(seq 1 60); do docker logs $n 2>&1 | grep -q 'released refs' && break; sleep 1; done; sleep 2
mem() { echo $(( $(docker exec $n cat /sys/fs/cgroup/memory.current) / 1048576 )); }
echo "memory.current after the app dropped its data: $(mem) MB"
if [ "$mode" = ordered ]; then
docker exec $n jcmd 1 VM.set_flag SoftMaxHeapSize 104857600 # 100 MB soft ceiling
docker exec $n jcmd 1 GC.run
for i in $(seq 1 30); do [ "$(mem)" -lt 250 ] && break; sleep 1; done
echo "memory.current after SoftMaxHeapSize + GC.run: $(mem) MB"
fi
docker update --memory=300m --memory-swap=300m $n # in-place cgroup change, no restart
sleep 8
docker inspect -f 'running={{.State.Running}} OOMKilled={{.State.OOMKilled}} exit={{.State.ExitCode}}' $n
docker rm -f $n >/dev/null 2>&1
chmod +x run.sh
LIM=900 XMX=600 ./run.sh naive -XX:+UseG1GC -XX:G1HeapRegionSize=4m
LIM=900 XMX=600 ./run.sh ordered -XX:+UseG1GC -XX:G1HeapRegionSize=4m
LIM=900 XMX=600 ./run.sh naive -XX:+UseZGC -XX:ZUncommitDelay=1
LIM=900 XMX=600 ./run.sh ordered -XX:+UseZGC -XX:ZUncommitDelay=1
LIM=700 XMX=500 ./run.sh naive -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000
LIM=700 XMX=500 ./run.sh ordered -XX:+UseShenandoahGC -XX:+UnlockExperimentalVMOptions -XX:ShenandoahUncommitDelay=1000
g1-probe.sh tests each G1 lever on its own:
#!/bin/bash
# Which G1 lever actually returns memory? Run from the folder containing Hold.java.
set -u
docker run --rm -v "$PWD":/w -w /w eclipse-temurin:21-jdk javac Hold.java
start() { docker rm -f $1 >/dev/null 2>&1
docker run -d --name $1 --memory=900m --memory-swap=900m -v "$PWD":/w -w /w eclipse-temurin:21-jdk \
java -XX:+UseG1GC -XX:G1HeapRegionSize=4m -Xmx600m Hold >/dev/null
for i in $(seq 1 60); do docker logs $1 2>&1 | grep -q 'released refs' && break; sleep 1; done; sleep 2; }
mem() { echo $(( $(docker exec $1 cat /sys/fs/cgroup/memory.current) / 1048576 )); }
echo "1) nothing (default), wait 25s"; start g1; b=$(mem g1); sleep 25; echo " $b MB -> $(mem g1) MB"
echo "2) SoftMaxHeapSize=100MB only, wait 20s"; start g1; b=$(mem g1)
docker exec g1 jcmd 1 VM.set_flag SoftMaxHeapSize 104857600 >/dev/null; sleep 20; echo " $b MB -> $(mem g1) MB"
echo "3) GC.run only"; start g1; b=$(mem g1)
docker exec g1 jcmd 1 GC.run >/dev/null; sleep 4; echo " $b MB -> $(mem g1) MB"
echo "4) G1PeriodicGCInterval=3000 set live"; start g1; b=$(mem g1)
docker exec g1 jcmd 1 VM.set_flag G1PeriodicGCInterval 3000 >/dev/null
for i in $(seq 1 40); do [ "$(mem g1)" -lt 250 ] && break; sleep 1; done; echo " $b MB -> $(mem g1) MB (after ${i}s)"
docker rm -f g1 >/dev/null 2>&1
Expect OOMKilled=true exit=137 for naive runs and running=true for ordered ones. The megabyte numbers will differ a little on your machine.
FAQ
Which garbage collector is best for Kubernetes in-place pod resize?
In my test, ZGC and Shenandoah are the easiest, because SoftMaxHeapSize changes their behavior and keeps the heap near a target. G1 also works if something triggers a release, but it has no soft ceiling. Pick the collector your latency requirements need first, and then use the matching release method.
How do I make G1 release memory back to the OS?
Run jcmd <pid> GC.run, or set G1PeriodicGCInterval to a value in milliseconds (default 0, off). In my test both returned memory within a few seconds on a small idle heap.
Does SoftMaxHeapSize work with G1?
No. jcmd VM.set_flag SoftMaxHeapSize succeeds on G1 in JDK 21, but I saw no change in memory over 20 seconds, so I conclude G1 ignores it. It works on ZGC and Shenandoah.
Why was my pod OOMKilled after an in-place memory decrease?
The limit dropped while the JVM still held more committed memory than the new limit allows. In my test this killed the JVM on G1, ZGC and Shenandoah every time. Release memory first, check the resident memory, then lower the limit.
Is GC.run safe to use in production?
It triggers a full collection. On G1 that is stop-the-world, and its pause grows with the live set. I only tested a 600 MB heap on an idle JVM, so measure the pause on your own heap before relying on it.
Do I still need RestartContainer?
Only if the heap ceiling has to move. See my earlier article on RestartContainer for the case where it is and isn't required.
Where Warden fits
I maintain mnemo-jvm-warden, an open-source Kubernetes controller and sidecar that does the release-then-resize order for you. From its code and docs, it adjusts to the collector like this:
- Detection: the agent reads the target's GC beans and records, per collector, whether a soft max and uncommit are supported. ZGC and Shenandoah support both. G1 supports uncommit only. Serial, Parallel and Epsilon are treated as unsupported, and the agent stays read-only for them.
-
Release: it sets
SoftMaxHeapSizeonly where the collector has it, so on G1 that step is skipped instead of being sent and ignored. It then forcesGC.runand polls the committed heap until it drops and holds steady. It checks the process's resident memory before it shrinks the pod. -
Boost on G1: a G1 workload that wants a Boost lease must already launch with
-Xmxcovering the largestheapTargetit will request, because there is no soft ceiling to raise later. The agent rejects a heap target above the launch-time-Xmxfor every collector, and the Boost docs note that G1 has no live soft-max knob, so a G1 Boost only ever confirms that the allocation change landed.
The conclusions above do not depend on any tool. The jcmd commands work without it.
Top comments (0)