--memory=512m or a Kubernetes limits.memory โ at which point it either gets killed by the kernel for no exception you can catch, or runs on a garbage collector you never chose, or reports a heap size that has nothing to do with the number in your manifest. None of this throws an error. The JVM is doing exactly what its flags tell it to; the flags just do not mean what most people assume once a cgroup is involved.
This post is the flag-by-flag reference for that gap, built on JDK 25 (the current LTS) and JDK 27 (the newest feature release, where two of the defaults in this post change again), both run inside real Docker containers with real --memory and --cpus limits โ not read about. Every number below is quoted from a committed, unedited transcript in the companion module, which also ships the Spring Boot app, the MemoryHog and HeaderSizeDemo tools, and every Dockerfile used to produce them.
Versions this was verified against. Temurin JDK 25.0.4.1+1 (LTS) and Temurin 27+35 (GA 2026-09-15, no official Docker image yet โ the GA tarball installed by hand, see docker/Dockerfile.jdk27). Spring Boot 4.1.1, Spring Framework 7.0.x via its BOM. Docker Engine 29.4.3. JEPs exercised directly: JEP 523 (G1 as the default collector in every environment, JDK 27), JEP 534 (compact object headers on by default, JDK 27), and JEP 519 (compact object headers as a full product feature, JDK 25).
The JVM does not read your manifest โ it reads the cgroup
A container’s--memory or Kubernetes limits.memory is not a JVM setting; it is a Linux cgroup limit enforced by the kernel. Since -XX:+UseContainerSupport became the default in JDK 10, and matured through JDK 11, the JVM reads that cgroup โ not /proc/meminfo, which still reports the host’s full memory and CPU count from inside the container โ and derives its own flags from it: how many processors it thinks it has, which garbage collector to default to, and how big the heap should be.
/internal/jvm-flags, that prints exactly what the running JVM resolved these three things to, live โ the same facts -XX:+PrintFlagsFinal prints, through the Java Management API instead:
HotSpotDiagnosticMXBean diag =
ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
for (String name : FLAGS_OF_INTEREST) {
flagView.put(name, diag.getVMOption(name).getValue());
}
out.put("resolvedFlags", flagView);
Full controller in JvmDiagnosticsController.java. Every transcript in the rest of this post is the -XX:+PrintFlagsFinal version of the same check, run from outside the container against images built by scripts/run-all.sh.
Delete /internal/jvm-flags before shipping. It leaks heap sizing and cgroup limits to anyone who can reach it โ it exists here purely so this post and its README could show live, real resolved flags instead of a description of them.
- Where container awareness actually originated โ not a single JEP, but a tracked bug fixed incrementally from JDK 9 through JDK 11: JDK-8146115: Improve docker container detection and resource configuration usage
- Current flag list and defaults: the
javalauncher reference
JDK 25 picks Serial below roughly two CPUs and 1.8 GB โ JDK 27 does not
HotSpot’s ergonomic collector choice has a cliff edge most people never hit on a laptop, because a laptop looks nothing like a 512 MB, 1-CPU pod. Six memory/CPU combinations, same image, JDK 25:--- mem=512m cpus=1 ---
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=1800m cpus=1 ---
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=512m cpus=2 ---
bool UseG1GC = false {product} {default}
bool UseSerialGC = true {product} {ergonomic}
--- mem=1800m cpus=2 ---
bool UseG1GC = true {product} {ergonomic}
bool UseSerialGC = false {product} {default}
Full six-combination matrix in docs/output/01-gc-selection-jdk25.txt. Two CPUs and roughly 1.8 GB are both required before G1 turns on โ 1800 MB with 1 CPU stays Serial, 512 MB with 2 CPUs stays Serial, only 1800 MB with 2 CPUs gets G1. A typical 512–1024 MB Spring Boot pod on JDK 25 is running SerialGC, a single-threaded collector, without anyone deciding that.
The identical matrix on JDK 27 โ same six combinations, down to the smallest:
--- mem=256m cpus=1 ---
bool UseCompactObjectHeaders = true {product lp64_product} {default}
bool UseG1GC = true {product} {ergonomic}
bool UseSerialGC = false {product} {default}
Full matrix in docs/output/02-gc-selection-jdk27.txt โ every one of the six combinations gets G1, including the smallest: 256 MB, one CPU. JEP 523 does not raise the SerialGC threshold, it removes SerialGC as the default outcome of any sizing decision โ HotSpot will now always choose G1 unless you ask for something else.
Going deeper: why G1 needed a CPU and memory floor at all
G1 runs background concurrent marking and remembered-set maintenance threads that Serial does not; on a single CPU with little memory to spare, those threads compete with the application for the one core available, and G1’s fixed per-region bookkeeping overhead is proportionally larger on a small heap. The ergonomic floor existed to avoid handing a resource-starved container a collector that would spend more of its one CPU on GC housekeeping than Serial would. JEP 523’s argument is that on modern hardware and with G1’s own overhead reduced over several releases, that trade no longer holds often enough to justify the exception โ see the JEP’s own rationale for the data behind that call.
- What changes for an existing Spring Boot service moving to JDK 27 with no flag changes: it gets G1 and compact headers whether or not you asked, see the next section for the second half of that change
- JEP 523: Make G1 the Default Garbage Collector in All Environments
Compact object headers: the same object, measured 33% smaller
JEP 519 shipped compact object headers as a full product feature in JDK 25 (opt-in); JEP 534 turns it on by default in JDK 27. Rather than quote the JEP’s own percentage,HeaderSizeDemo allocates two million instances of a trivial two-int object and a real jcmd <pid> GC.class_histogram counts the actual bytes:
static final class Pair { int a; int b; Pair(int a, int b) { this.a = a; this.b = b; } }
public static void main(String[] args) throws Exception {
int count = args.length > 0 ? Integer.parseInt(args[0]) : 2_000_000;
List<Pair> pairs = new ArrayList<>(count);
for (int i = 0; i < count; i++) {
pairs.add(new Pair(i, i));
}
System.gc();
--- JDK 25, default (UseCompactObjectHeaders=false), flags-demo:jdk25-jdk ---
1: 2000000 48000000 HeaderSizeDemo$Pair
--- JDK 25, -XX:+UseCompactObjectHeaders ---
1: 2000000 32000000 HeaderSizeDemo$Pair
--- JDK 27, default (UseCompactObjectHeaders=true), flags-demo:jdk27 ---
1: 2000000 32000000 HeaderSizeDemo$Pair
--- JDK 27, -XX:-UseCompactObjectHeaders ---
1: 2000000 48000000 HeaderSizeDemo$Pair
Full output, including the symmetry check in both directions on both JDKs, in docs/output/04-compact-object-headers.txt. Forty-eight million bytes versus thirty-two million for the identical two million objects โ 24 bytes an instance with the legacy header, 16 bytes compact, a 33% reduction for this specific tiny object. The legacy object header is two machine words (a mark word plus a class pointer, 16 bytes on a 64-bit JVM) before a single field is counted; compact headers fold both into one 8-byte word.
33% here is not the number for your heap. It is the floor case โ a minimal object with no inherited fields. The saving shrinks as objects get bigger relative to their header and grows for header-dominated collections of small objects (hash map nodes, linked-list cells, small DTOs). The sibling jdk27-memory module measured this on a real 2,000,000-row Spring Boot service rather than a synthetic object โ see JDK 27 Memory Changes: Compact Object Headers by Default and G1 Everywhere for that number.
- Full byte-count transcript for both JDKs, both directions: docs/output/04-compact-object-headers.txt
- Real-service measurement, not a synthetic object: JDK 27 Memory Changes
ActiveProcessorCount lies convincingly, and the JVM believes it
-XX:ActiveProcessorCount overrides what the JVM thinks its own CPU quota is โ and nothing checks the override against the container’s real limit. One real CPU, told there are eight:
--- JDK27, --cpus=1, no override (correct) ---
int ActiveProcessorCount = -1 {product} {default}
uint ConcGCThreads = 1 {product} {ergonomic}
uint ParallelGCThreads = 1 {product} {default}
--- JDK27, --cpus=1, -XX:ActiveProcessorCount=8 (lying) ---
int ActiveProcessorCount = 8 {product} {command line}
uint ConcGCThreads = 2 {product} {ergonomic}
uint ParallelGCThreads = 8 {product} {default}
Full output in docs/output/03-activeprocessorcount-lie.txt. ParallelGCThreads goes from 1 to 8, derived straight from the lie. On a container actually limited to one CPU’s worth of CFS quota, those eight threads do not run in parallel โ they take turns inside the same 100 ms-per-period budget every other thread in the container shares, which is precisely the throttling mechanism measured on a real cluster in Deploying Spring Boot 4 on Kubernetes: forcing ActiveProcessorCount=2 and G1 onto a 1-CPU quota there measured 32% fewer completed requests than leaving the ergonomic default alone, because the extra GC threads were “taking turns” for the same CPU time rather than adding capacity.
“Force a proper collector onto a small container” is advice that measurably backfires. Never set -XX:ActiveProcessorCount above the container’s real CPU quota, and be suspicious of any guide that tells you to in order to get G1 onto a small pod โ on JDK 27 you get G1 anyway, correctly sized, without touching this flag at all.
- Full throttling measurement, five resource shapes, real cluster: Deploying Spring Boot 4 on Kubernetes, part 3
A correctly set heap fails safely; a hardcoded one gets the container killed
This is the distinction the rest of this post is building toward.MemoryHog allocates and retains fixed-size chunks forever, touching every page so it is genuinely resident, not just reserved:
byte[] chunk = new byte[chunkMb * 1024 * 1024];
for (int i = 0; i < chunk.length; i += 4096) {
chunk[i] = 1; // touch each page so it is actually resident, not just reserved
}
held.add(chunk);
In a 256 MB container with -XX:MaxRAMPercentage=75 โ a heap that is a correct fraction of the container’s own limit:
[0.962s][info][gc] GC(4) Pause Young (Allocation Failure) 98M->97M(147M) 23.535ms
held=120MB heapUsed=122MB heapMax=185MB freeHeap=24MB
[1.633s][info][gc] GC(6) Pause Full (Allocation Failure) 170M->169M(185M) 5.895ms
[1.649s][info][gc] GC(7) Pause Full (Allocation Failure) 169M->169M(185M) 15.822ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemoryHog.main(MemoryHog.java:21)
exit=1
Full transcript in docs/output/05-maxrampercentage-correct-oom.txt. G1 tries a Full GC twice to find space, fails, and throws a catchable OutOfMemoryError. The process exits cleanly with code 1; the container never needed to be killed.
The identical workload, identical 256 MB container, with a hardcoded -Xmx1g instead โ a number that has nothing to do with the container’s actual limit:
[1.634s][info][gc] GC(6) Pause Young (Allocation Failure) 170M->169M(251M) 34.003ms
held=192MB heapUsed=194MB heapMax=989MB freeHeap=56MB
held=216MB heapUsed=218MB heapMax=989MB freeHeap=32MB
container exit code: 137
error: no such object: hogB
Full transcript in docs/output/06-hardcoded-xmx-oomkilled.txt. heapMax reports 989 MB because that is what -Xmx1g asked for โ the JVM has no reason to doubt it. The process RSS crosses 256 MB while the heap still thinks it has 700 MB of headroom left, and the kernel’s cgroup OOM killer ends the container with SIGKILL (exit 137) before the JVM ever gets a chance to notice anything is wrong, let alone throw a catchable exception.
“exit 137” with no stack trace is the fingerprint of this exact mistake. If a container dies with no Java exception, no log line, nothing โ check whether any-Xmx/-Xmsis hardcoded above the container’s memory limit before suspecting anything else.docker inspect <id> --format '{{.State.OOMKilled}}'confirms it was the kernel, not the JVM.
- Never set an absolute
-Xmx/-Xmsin a container unless it is comfortably below the memory limit with margin for non-heap memory โ see native memory tracking below for what that margin needs to cover
-XX:-UseContainerSupport: the flag that makes the JVM blind to its own container
Turning container support off entirely produces the same SIGKILL, through a different mechanism โ the JVM falls back to sizing itself off the host’s memory, which from inside the container it can still see via/proc/meminfo:
--- what the flag alone resolves heap to ---
size_t MaxHeapSize = 2105540608 {product} {ergonomic}
bool UseContainerSupport = false {product} {command line}
--- the hog run ---
[0.004s][info][gc] Using G1
held=192MB heapUsed=201MB heapMax=2008MB freeHeap=45MB
held=216MB heapUsed=226MB heapMax=2008MB freeHeap=45MB
container exit code: 137
Full transcript in docs/output/07-usecontainersupport-false-oomkilled.txt. heapMax=2008MB โ roughly a quarter of this sandbox host’s RAM, the same 25% default applied to the wrong total โ inside a container limited to 256 MB. Same ending as the previous section: RSS crosses the real limit long before the JVM’s own heap accounting would ever notice, and the kernel kills the container.
- Never set
-XX:-UseContainerSupportin a container, full stop โ there is no version of a containerized deployment where sizing off the host’s memory is correct - Same underlying failure as the hardcoded
-Xmxcase above, reached a different way
The one flag that makes an OOM actually useful afterward
The two kernel-killed cases above have something in common: SIGKILL gives the JVM no chance to run any shutdown code, including a heap dump. Only the correctly-contained case โ the one that reaches a real, catchableOutOfMemoryError โ can ever produce one:
$ docker run --rm --memory=256m ... --entrypoint java flags-demo:jdk25-jdk \
-XX:MaxRAMPercentage=75 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/heap.hprof \
-cp /memoryhog.jar MemoryHog 24 200
java.lang.OutOfMemoryError: Java heap space
Dumping heap to /dumps/heap.hprof ...
Heap dump file created [180390818 bytes in 1.028 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at MemoryHog.main(MemoryHog.java:21)
exit=1
-rw------- 1 root root 180390818 Oct 1 04:29 heap.hprof
Full transcript in docs/output/08-heapdumponoutofmemoryerror.txt. A 172 MB, fully usable .hprof file, written in just over a second, ready to open in any heap analyzer. This is the entire argument for getting MaxRAMPercentage right rather than just living with whatever a hardcoded -Xmx happens to do: it is not only about avoiding the SIGKILL, it is about which failure mode leaves you evidence afterward.
Set -XX:HeapDumpPath to a mounted volume, not the container’s writable layer. A dump written inside an ephemeral container filesystem disappears the moment the container is removed โ which, after a crash, is usually immediately. The flag is only useful if something outside the container can still read the file afterward.
- Leave
-XX:+HeapDumpOnOutOfMemoryErroron in every production JVM; the cost is one dump, once, only on an actual OOM
What the 25%/75% split does not account for: native memory
MaxRAMPercentage governs the heap. It says nothing about metaspace, thread stacks, the JIT’s code cache, GC bookkeeping structures, or direct byte buffers โ all of which are real, resident, native memory that counts against the same cgroup limit the heap is trying to stay inside. -XX:NativeMemoryTracking=summary plus jcmd <pid> VM.native_memory summary against the Spring Boot app itself, not the synthetic allocator:
Total: reserved=1824154KB, committed=115878KB
malloc: 34018KB #144561, peak=35841KB #140626
mmap: reserved=1790136KB, committed=81860KB
- Java Heap (reserved=393216KB, committed=23532KB)
- Class (reserved=1049299KB, committed=5075KB)
( Metadata: )
( reserved=65536KB, committed=27072KB)
- Thread (reserved=17468KB, committed=1380KB)
(threads #17)
- Code (reserved=252158KB, committed=16014KB)
- Metaspace (reserved=65672KB, committed=27208KB)
Full summary, every category, in docs/output/09-native-memory-tracking.txt. Heap committed here is 23,532 KB โ but Class/Metaspace alone commits another 27,208 KB, Code another 16,014 KB, and seventeen threads their own stacks on top. None of that is the 75% MaxRAMPercentage was asked to cover; all of it draws from the same 512 MB container limit in this run.
75% is a starting point, not a universal constant. A service with a large thread pool, a big code cache from a lot of JIT-compiled code, or native libraries needs more headroom left over; one that is mostly heap-bound can push closer to 85–90%. -XX:NativeMemoryTracking=summary left on (its own overhead is small) is how you find out which regime your service is in before an OOM tells you the hard way.
- Full NMT categories and what each one holds:
jcmdreference, VM.native_memory
Should you set a CPU limit at all?
Everything above assumes a CPU limit exists; it is worth asking whether it should. A memory limit is close to mandatory โ memory is not compressible, and a container that can grow without bound will eventually take down its node. A CPU limit is a different trade entirely: it is a hard quota enforced by the kernel’s CFS bandwidth controller, and every GC thread, JIT compiler thread, and request-handling thread in the container shares it. Deploying Spring Boot 4 on Kubernetes measured a pod throttled for 37 of 60 seconds at a 500m CPU limit under GC pressure, with its single collector thread receiving only half the wall-clock time its own pauses needed โ pauses that stretched to double length purely from waiting for quota, not from doing more work. A CPU request, with no corresponding limit, still guarantees a fair share under contention and never throttles on an otherwise idle node. For a GC-sensitive, latency-sensitive service, that combination is frequently the better default than a limit sized “safely” above steady state โ the memory limit is still doing the job a limit is actually needed for.- Full CPU-limit-vs-throughput measurement, five resource shapes: Deploying Spring Boot 4 on Kubernetes, part 3
- Latency-tail argument for choosing a collector once CPU is no longer the constraint: Generational ZGC on JDK 25: Benchmarks vs G1
The production cheat sheet
| Flag | Do | Fingerprint if you get it wrong |
|---|---|---|
-XX:MaxRAMPercentage | Set explicitly (75–90, service-dependent). Never rely on the 25% default for a web service. | Heap far smaller than expected; frequent young GCs on a container with plenty of free memory |
-Xmx / -Xms | Do not hardcode in a container unless comfortably below the memory limit with margin. | Exit 137, no Java exception, no stack trace |
-XX:-UseContainerSupport | Never set it. | Heap sized off the host’s RAM; exit 137 in a small container |
-XX:ActiveProcessorCount | Never set it above the container’s real CPU quota. | More GC threads than the quota can run in parallel; measurably worse throughput under load |
-XX:+HeapDumpOnOutOfMemoryError | Leave on, with -XX:HeapDumpPath pointed at a mounted volume. | An OOM with no dump to diagnose it afterward |
-XX:NativeMemoryTracking=summary | Leave on; check it before pushing MaxRAMPercentage close to 100. | Container OOM-killed with heap usage nowhere near its max |
limits.cpu | Decide deliberately, per service โ it is not automatically correct to set one. | GC pauses that double in length for no algorithmic reason; container_cpu_cfs_throttled_periods_total climbing |
Should you even tune any of this by hand? On JDK 27, less than on JDK 25 โ G1 everywhere and compact headers by default close two of the seven rows above before you write a single flag. What remains is the heap-sizing pair (MaxRAMPercentagevs a hardcoded-Xmx) and the CPU-limit decision, and both of those are decisions about your service’s actual resource shape that no JDK release can make for you. Start from the table, verify with/internal/jvm-flagsor-XX:+PrintFlagsFinalagainst your own image, and only then decide you need something more specific than the defaults.
Reproducing this
Every transcript in this post comes from a real Docker container, committed unedited underdocs/output/.
git clone https://ankurm.com/git.app/asmhatre/zgc-jdk25-benchmarks.git
cd zgc-jdk25-benchmarks/flags
mvn -q -B clean package -DskipTests
scripts/run-all.sh
Further reading
- zgc-jdk25-benchmarks/flags โ this companion module: the Spring Boot app, three Dockerfiles,
MemoryHog,HeaderSizeDemo, and nine captured-output transcripts - JDK 27 Memory Changes: Compact Object Headers by Default and G1 Everywhere โ the deep dive on a real 2,000,000-row service, container sizing sweep, and kernel-level OOM forensics
- Deploying Spring Boot 4 on Kubernetes โ the CPU-limit-throttles-the-GC measurement and the
ActiveProcessorCountovershoot in full, on a real cluster - Generational ZGC on JDK 25: Benchmarks vs G1 โ what to reach for once G1’s defaults are no longer the question
- JEP 523: Make G1 the Default Garbage Collector in All Environments โ OpenJDK
- JEP 534: Compact Object Headers by Default โ OpenJDK
- JEP 519: Compact Object Headers โ OpenJDK
- The
javalauncher reference โ Oracle, JDK 25
No Comments yet!