Skip to main content

JVM Flags for Spring Boot in Containers (JDK 25/27): The Production Cheat Sheet

A flag-by-flag reference for running Spring Boot in containers on JDK 25 and JDK 27, measured in real Docker containers: how UseContainerSupport derives CPU count, collector and heap size from the cgroup, why MaxRAMPercentage fails safely while a hardcoded -Xmx gets the container SIGKILLed, the ActiveProcessorCount lie that inflates GC threads, native memory the heap percentage doesn’t cover, and what JEP 523 and JEP 534 change by default on JDK 27.

A Spring Boot image, built and tested on your laptop, runs correctly until it is deployed with --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.
docker run –memory=512m–cpus=1 Linux cgroup limitenforced by the kernel -XX:+UseContainerSupportdefault since JDK 10 ActiveProcessorCount default GC (Serial/G1) MaxHeapSize (25% default) /proc/meminfo inside the container still reports the HOST’s full memory and CPU count — it is the cgroup, not /proc, that UseContainerSupport reads. Every row below is one of these three derived values going wrong.
The companion app carries a diagnostic endpoint, /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.

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.
JDK 25: collector depends on pod shape 512m/1cpu 1800m/1cpu 512m/2cpu 1800m/2cpu 4g/2cpu JDK 27: G1 everywhere, no exceptions (JEP 523) 256m/1cpu 512m/1cpu 1800m/1cpu 512m/2cpu 1800m/2cpu 4g/2cpu Red = SerialGC. Green = G1. Every box on the JDK 25 row below the 2-CPU/1.8GB line is red; every box on JDK 27 is green.
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.

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.
Legacy header: 24 bytes total mark word + klass ptr (16B) a,b (8B) Compact header: 16 bytes total combined (8B) a,b (8B) Same object, same two fields — only the header shrinks. The field layout never changes.
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.

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.
Real CFS quota: 1 CPU (100ms per 100ms period) 8 GC threads believe they have 8 CPUs, all sharing the same quota above Measured on a real cluster: forcing this combination cut completed requests by 32% versus the ergonomic default.
“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.

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.
-XX:MaxRAMPercentage=75 (heap tracks the container limit) heap fills → JVM notices → Full GC → catchable OutOfMemoryError → exit 1, container survives -Xmx1g in a 256m container (heap is detached from the container limit) RSS crosses 256m while heap still thinks it has room → kernel SIGKILLs the container, exit 137 Same workload, same container limit. The only difference is whether the JVM’s own idea of “full” agrees with the kernel’s. When it does not, the kernel decides, with no catchable warning at all.
“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/-Xms is 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/-Xms in 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:-UseContainerSupport in 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 -Xmx case 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, catchable OutOfMemoryError โ€” 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:+HeapDumpOnOutOfMemoryError on 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.
One container memory limit, shared Heap — governed by MaxRAMPercentage Metaspace + threads + code + NMT A MaxRAMPercentage near 100 leaves this right-hand share nowhere to go — the same kernel OOM kill as a hardcoded -Xmx follows, just reached through native memory growth instead of heap growth.
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.

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.

The production cheat sheet

FlagDoFingerprint if you get it wrong
-XX:MaxRAMPercentageSet 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 / -XmsDo not hardcode in a container unless comfortably below the memory limit with margin.Exit 137, no Java exception, no stack trace
-XX:-UseContainerSupportNever set it.Heap sized off the host’s RAM; exit 137 in a small container
-XX:ActiveProcessorCountNever 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:+HeapDumpOnOutOfMemoryErrorLeave on, with -XX:HeapDumpPath pointed at a mounted volume.An OOM with no dump to diagnose it afterward
-XX:NativeMemoryTracking=summaryLeave on; check it before pushing MaxRAMPercentage close to 100.Container OOM-killed with heap usage nowhere near its max
limits.cpuDecide 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 (MaxRAMPercentage vs 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-flags or -XX:+PrintFlagsFinal against 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 under docs/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

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.