Skip to content

Java Heap Size in Containers: -Xmx vs MaxRAMPercentage

Java heap size in a container isn't what -Xmx says. How the JVM derives it from the cgroup limit, why 75% heaps still get OOMKilled, and how to budget the rest.

gc-tuning jvm jvm-memory
Gopi Gorantala
Reading Progress

On This Page

Exit code 137 with a heap that "fit"

$ java -Xmx512m -Xms64m Fill 380 60 150        # cgroup memory limit: 512 MB
exit=137
[  113.489883] oom-kill:constraint=CONSTRAINT_MEMCG,nodemask=(null),cpuset=/,mems_allowed=0,oom_memcg=/lab,task_memcg=/lab,task=java,pid=702,uid=0
[  113.494287] Memory cgroup out of memory: Killed process 702 (java) total-vm:3220484kB, anon-rss:518912kB, file-rss:22496kB

Nobody threw an exception. The JVM didn't get to print a stack trace, a heap summary or a goodbye. The kernel shot it, and the only evidence is an exit code and a line in dmesg that your platform may or may not show you.

I'd set -Xmx equal to the container limit, which felt like the responsible thing to do: use what you're given. The java heap size was 512 MB, the limit was 512 MB, and the process died anyway. Environment for everything in this article: OpenJDK 21.0.12.1 (the Ubuntu build), Linux x86-64, two CPUs, a cgroup v1 memory limit set by hand, collector as the JVM chose it. I'll tell you where that matters.

The same workload with -XX:MaxRAMPercentage=75 in the same 512 MB box didn't get killed. It died with an ordinary java.lang.OutOfMemoryError: Java heap space, which is a much better way to die, because you get a stack trace. That difference is the entire article.

Your limit is a budget, and the heap is one line in it

-Xmx caps the Java heap. The container limit caps the whole process: heap, Metaspace, code cache, thread stacks, direct buffers, GC bookkeeping, the malloc arenas under all of them. The kernel counts every resident page against one number and doesn't care which part of the JVM asked for it.

So "how big should the heap be?" is the wrong first question. The first question is how much of the limit is left once everything that isn't the heap has taken its share, and that share isn't small. In the lab below it was over 100 MB for a toy program with no framework in it.

When you don't set anything, the JVM answers the question for you, using a rule that was written for physical machines. It's a good rule in some places and a bad one in others, and the places where it's bad are mostly small containers.

A cgroup, a 512 MB cap, and three runs

The first run I did was an accident. I launched the JVM with -Xmx512m -Xms512m -XX:+AlwaysPreTouch and forgot to give my test program its arguments. Exit code 137, failcnt=108, and it never reached main. Pre-touch walks every page of the heap at startup so they're all resident, and 512 MB of heap in a 512 MB box is dead before the first line of your code. I wrote it down as a bug in my script. It was the first useful result.

What you need. Linux, root, JDK 21 (I used 21.0.12.1+1-1-24.04.4-Ubuntu; java -version prints openjdk version "21.0.12.1" 2026-08-18). I created the limit by hand in a cgroup v1 memory directory because that's what my sandbox had. On your laptop docker run --memory=512m does the same thing and, on a cgroup v2 host, exercises the v2 code path in the JVM. I did not run Docker and I did not run cgroup v2. Treat those as the same idea, unverified.

The code. Fill.java puts memory in three places on purpose: heap, direct buffers and thread stacks, and prints resident set size (RSS) from /proc as it goes.

import java.nio.ByteBuffer;
import java.nio.file.*;
import java.util.*;

public class Fill {
    static List<byte[]> live = new ArrayList<>();
    static List<ByteBuffer> direct = new ArrayList<>();

    static String rss() throws Exception {
        for (String l : Files.readAllLines(Path.of("/proc/self/status")))
            if (l.startsWith("VmRSS")) return l.replaceAll("\\s+", " ");
        return "?";
    }

    public static void main(String[] a) throws Exception {
        int heapMb = Integer.parseInt(a[0]), directMb = Integer.parseInt(a[1]), threads = Integer.parseInt(a[2]);
        for (int i = 0; i < threads; i++) {                    // each thread gets its own stack, outside the heap
            Thread t = new Thread(() -> { recurse(200); try { Thread.sleep(600_000); } catch (InterruptedException e) {} });
            t.setDaemon(true); t.start();
        }
        for (int i = 0; i < directMb; i++) {                   // direct buffers live outside the heap: -Xmx does not cap them
            ByteBuffer b = ByteBuffer.allocateDirect(1 << 20);
            for (int p = 0; p < (1 << 20); p += 4096) b.put(p, (byte) 1);   // touch each page so it's really resident
            direct.add(b);
        }
        System.out.println("after direct+threads: " + rss());
        for (int i = 0; i < heapMb; i++) {
            byte[] c = new byte[1 << 20];
            Arrays.fill(c, (byte) 1);                          // touch every page so RSS is real
            live.add(c);
        }
        System.out.println("after heap fill (" + heapMb + " MB live): " + rss());
        Runtime r = Runtime.getRuntime();
        System.out.println("heap max=" + r.maxMemory() / (1 << 20) + "MB total=" + r.totalMemory() / (1 << 20) + "MB");
        System.out.println("survived");
        if (a.length > 3) Thread.sleep(Long.parseLong(a[3]));  // optional: stay alive so you can jcmd it
    }
    static int recurse(int n) { return n == 0 ? 0 : 1 + recurse(n - 1); }
}

Step 1: make the cage.

G=/sys/fs/cgroup/memory/lab
mkdir -p $G
echo $((512*1024*1024)) > $G/memory.limit_in_bytes
javac Fill.java
run() { echo 0 > $G/memory.failcnt; echo 0 > $G/memory.max_usage_in_bytes
        bash -c "echo \$\$ > $G/cgroup.procs; exec java $1 Fill $2"
        echo "exit=$?  failcnt=$(cat $G/memory.failcnt)  max_usage=$(( $(cat $G/memory.max_usage_in_bytes)/1048576 ))MB"; }

The echo $$ > cgroup.procs; exec java trick puts the shell into the cgroup and then replaces it with the JVM, so the JVM is the cgroup member. Use $!, not pgrep, whenever you need the PID later (that bit me in two earlier labs on this site).

Step 2: three runs, same workload, 380 MB of live heap, 60 MB of direct buffers, 150 threads.

run "-Xmx512m -Xms64m"                 "380 60 150"   # A: heap == limit
run "-XX:MaxRAMPercentage=75 -Xms64m"  "380 60 150"   # B: 75% of limit
run "-XX:MaxRAMPercentage=60 -Xms64m"  "250 60 150"   # C: 60% of limit, smaller live set

Expected, and what I got:

A: after direct+threads: VmRSS: 116104 kB
   exit=137  failcnt=144  max_usage=512MB
B: after direct+threads: VmRSS: 116140 kB
   Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
        at Fill.main(Fill.java:28)
   exit=1  failcnt=0  max_usage=467MB
C: after direct+threads: VmRSS: 116156 kB
   after heap fill (250 MB live): VmRSS: 379624 kB
   heap max=297MB  total=297MB
   survived
   exit=0  failcnt=0  max_usage=355MB

The line to look at is 116104 kB. Before the heap holds a single object, the 60 MB of direct buffers, 150 thread stacks and the JVM's own machinery already cost 113 MB of a 512 MB budget. A got killed. B ran into its own heap ceiling (75% of 512 is 384 MB; I asked for 380 MB live plus room to allocate) and the JVM stopped itself before the kernel had to. C fit.

Step 3: ask the JVM what it decided. No workload needed.

echo $((1024*1024*1024)) > $G/memory.limit_in_bytes
bash -c "echo \$\$ > $G/cgroup.procs; exec java -Xlog:gc+init -XX:MaxRAMPercentage=75 -version"
[0.004s][info][gc,init] CPUs: 2 total, 2 available
[0.004s][info][gc,init] Memory: 1024M
[0.004s][info][gc,init] Compressed Oops: Enabled (32-bit)
[0.004s][info][gc,init] Heap Min Capacity: 8M
[0.004s][info][gc,init] Heap Initial Capacity: 16M
[0.004s][info][gc,init] Heap Max Capacity: 768M
[0.004s][info][gc,init] Pre-touch: Disabled

Memory: 1024M is the container limit, not the host's RAM. If it says your node's size, container support isn't engaged (see the version table). I left the JVM defaults for the other lines on purpose; you'll want them to compare against.

Step 4: see where the non-heap part goes. Start the JVM sleeping and ask Native Memory Tracking:

bash -c "echo \$\$ > $G/cgroup.procs; exec java -XX:+UseG1GC -XX:MaxRAMPercentage=60 -Xms64m -XX:NativeMemoryTracking=summary Fill 250 60 150 20000" &
sleep 9; P=$(pgrep -n -x java); jcmd $P VM.native_memory summary | grep -E "^Total|Java Heap|Thread|Code|GC |Other|Shared class"

(Here pgrep -n -x java matches the process named exactly java, newest first, which skips the bash wrapper. Fine for a lab, fragile anywhere else.)

My committed numbers, same program, only the collector changed:

                       Serial        G1
Java Heap              578 MB        504 MB
GC (bookkeeping)       1.9 MB        47.6 MB
Thread (150 stacks)    17.3 MB       17.3 MB
Other (my 60 MB direct) 60.0 MB      60.0 MB
Shared class space     12.6 MB       12.6 MB
Code (committed)       7.4 MB        7.4 MB
Total committed        681 MB        653 MB

G1 spent about 46 MB more on its own bookkeeping (remembered sets, card tables, marking structures) than Serial did for a heap that could grow to 616 MB. That's cheap on a 16 GB heap and not cheap in a 256 MB container, which is one reason the JVM picks Serial for small machines. Note the "reserved" column in NMT is much bigger than "committed" (Metaspace and the class space reserve about 1 GB of address space and commit under 1 MB). Only committed and resident count against the limit.

Forcing levers. The 512 MB limit, 380 MB live set and 60 MB direct buffers are scaled down so the failure takes two seconds. Production pods are gigabytes. 150 threads is a plausible small web service.

The confirmation signal. A should end with exit=137 and failcnt greater than zero, and dmesg | tail should show the Memory cgroup out of memory line. If A instead ends in a Java OOM, your machine's JVM left more headroom than mine; raise the direct buffers or the thread count to 200. If neither A nor B fails, your memory.limit_in_bytes isn't in force: check -Xlog:os+container=trace prints Memory Limit is: 536870912 for the JVM.

Environment differences. On cgroup v2 the files are memory.max and memory.peak/memory.events instead, and the JVM reads them through the v2 path. Docker or Kubernetes limits arrive as the same kernel mechanism. With a limit above the host's physical RAM the JVM uses the physical value; I set a 200 GB limit on an 8 GB box, and the defaults came out as if the box were 8 GB.

Cleanup. rmdir $G when no process is in it; delete Fill.class. Nothing here writes dumps.

How the JVM turns a limit into a heap size

The JVM does five things, in this order.

  1. It reads the limit. Since the container work in JDK 10 (JDK-8146115), os::physical_memory() returns the cgroup limit when there is one. After that, ergonomics treat the container as a small physical machine. They don't know it's a container.
  2. It picks a maximum heap from that number. By default it's 25% of it (MaxRAMPercentage, double, product, default 25.0 on 21.0.12.1).
  3. It picks an initial heap: 1/64th of it by default (InitialRAMPercentage, product, 1.5625). Both are ignored when you pass -Xmx or -Xms.
  4. It picks a collector. I covered that in the G1 article: with fewer than 2 CPUs or not enough memory, you get Serial.
  5. It checks the compressed-oops limit and may quietly change something. More on that in the traps section.

Here's what step 2 produced across limits on my 2-CPU lab box:

limit      max heap   share    collector chosen
128 MB       64 MB     50%     Serial
192 MB       96 MB     50%     (Serial)
256 MB      126 MB     49%     Serial
280-450 MB  126 MB     45-28%  (Serial)
512 MB      128 MB     25%     Serial
600 MB      150 MB     25%     (Serial)
1 GB        256 MB     25%     Serial
2 GB        512 MB     25%     G1

Parenthesised collectors I inferred from the neighbouring rows; I only printed Using ... for the unparenthesised ones. The share column is the thing to stare at. It is not 25% below 512 MB. It's 50% at the bottom, then a flat 126 MB from 256 up to about 450 MB, then 25%. Initial heap was 8 MB up to 512 MB, 16 MB at 1 GB, 32 MB at 2 GB.

I spent a while on the flat stretch, because the one article I found that mentions this (a gceasy post on the three percentage flags) says MinRAMPercentage kicks in below "approximately 250 MB" with no source, and my numbers say it kicks in below 256 MB and something else governs from there to about 450. That something else is in arguments.cpp.

What the source says, and how container support got here

I could not read the file directly. The fetch tool summarises, so what follows is a summary of Arguments::set_heap_size() at jdk-21+35, checked against the numbers above, and it matches all of them. The logic: compute reasonable_max = phys_mem * MaxRAMPercentage and reasonable_min = phys_mem * MinRAMPercentage (default 50). If reasonable_min is smaller than the compiled-in default of MaxHeapSize, the machine counts as small and the max heap becomes reasonable_min. Otherwise the max heap is the larger of reasonable_max and that default MaxHeapSize.

That second clause is the flat stretch. Between 256 and about 450 MB, 25% of the limit is smaller than the default MaxHeapSize constant, so the constant wins. The observed value is 126 MB; I believe the constant is 128 MB on 64-bit but I did not read globals.hpp, and I can't tell you where the 2 MB went. Treat 126 as the measurement and 128 as the guess.

JDK 8 (before 8u191)  JVM sizes from host RAM; the container limit is invisible
JDK 8u191 / JDK 10    Container support (JDK-8146115): limit becomes os::physical_memory(),
                      UseContainerSupport on by default, -Xlog:os+container
JDK 11.0.16 / 8u372   cgroup v2 support backported (JDK-8230305); JDK 15 is the main-line
                      release as I remember it, the issue page doesn't show the fix version
JDK 21                what I ran: 25% / 50% / 1.5625% defaults above, UseContainerSupport=true
JDK 25 and later      NOT run. I found no JEP changing these defaults; check
                      -XX:+PrintFlagsFinal before assuming

There is a live debate about the 25%. In an April 2022 thread on hotspot-dev (RFC for JDK-8285277), the participants agree 25% is wasteful for a container that runs one process: a 500 MB limit gets a heap around 126 MB, and the 128 MB container in the thread gets 64 MB. The obstacle they keep returning to is that the JVM can't reliably tell a container from a plain Linux host (JDK-8261242), so it can't change the default only for containers. I didn't find a change that landed from that. The default is still 25 on the build I ran.

The other piece of version history that matters: JDK 8 before 8u191 reports the host's RAM. If you inherit an old service with a 4 GB container and a default heap, it may be sizing itself from a 64 GB node.

Why a 75% heap still gets killed, and other traps

Heap equals limit. What killed A. There's no room for anything else, and -Xms equal to -Xmx makes it worse only if you pre-touch (below). The kernel kills at the limit; the JVM never sees the limit coming, and no OutOfMemoryError fires. When your pod restarts with exit code 137 and a healthy-looking heap graph, this is the first thing to check.

75% is a starting point, and sometimes it's too much. (Red Hat's UBI9 images ship 80%, per their 2023 article, and they say so because those images usually carry only the one process.) B's ceiling was 384 MB, and the kernel's high-water mark was 467 MB for an app whose non-heap needs were ~116 MB. That's survivable and tight. Add 300 threads or a bigger code cache or a native library and 75% on a 512 MB limit gets killed. On a 4 GB limit the same 75% leaves 1 GB of room, which is generous. Percentages don't scale with the non-heap part; the non-heap part is roughly a constant plus per-thread and per-library costs. I use about 50-60% below 1 GB, 70-75% from 2 GB, and then I measure.

-XX:MinRAMPercentage=75 to "give the heap more". It looks right and does nothing for you. At a 1 GB limit I got MaxHeapSize 256 MB with it set, same as without. MinRAMPercentage only applies in the small-machine branch above. If you copied this flag from a blog that said it raises the maximum, delete it.

-Xms equal to -Xmx and -XX:+AlwaysPreTouch. Equal -Xms/-Xmx commits the address range but pages aren't resident until touched. I measured it: -Xms512m -Xmx512m sat at 46 MB RSS idle. Add AlwaysPreTouch and the same JVM sits at 584 MB RSS from the first second, and the startup has to touch all of it. That's a real trade, not a mistake: you buy no page-fault stalls later and you pay your whole heap in the container's working set immediately. Do it on purpose, with a limit that already counts it. That was run one of this lab.

The big-heap trap: the percentage flags turn off compressed oops. This is the one I'd never have guessed. Set only -Xmx and the JVM keeps compressed oops up to about 31 GB, and silently turns them off above 32 GB (the 32 GB cliff). But when you set MaxRAMPercentage (or MaxRAM) explicitly, the source summary says the compressed-oops limit is handled differently: if the percentage-derived heap exceeds it, the JVM disables compressed oops rather than capping the heap. I simulated large limits with -XX:MaxRAM, since my box has 8 GB:

-XX:MaxRAM=64g                              MaxHeapSize 16.00 GB   UseCompressedOops true
-XX:MaxRAM=64g -XX:MaxRAMPercentage=75      MaxHeapSize 48.00 GB   UseCompressedOops false
-XX:MaxRAM=40g -XX:MaxRAMPercentage=74      MaxHeapSize 29.61 GB   UseCompressedOops true
-XX:MaxRAM=40g -XX:MaxRAMPercentage=75      MaxHeapSize 30.00 GB   UseCompressedOops false
-Xmx31g                                     MaxHeapSize 31.00 GB   UseCompressedOops true
-Xmx32g                                     MaxHeapSize 32.00 GB   UseCompressedOops false

Look at the fourth row. A 30 GB heap lost compressed oops, and -Xmx31g kept them. The percentage path has a lower cutoff than the flag path; 29.61 GB fit and 30.00 GB didn't. The summary I read says the check subtracts HeapBaseMinAddress (2 GB by default as far as I know), which would explain 30 GB being just over, but I haven't verified that. On a 40 GB node with a 75% rule you can lose pointer compression without changing a flag. If your limit is above ~40 GB, use an explicit -Xmx between 24 and 30 GB or switch to a much bigger heap on purpose.

Watching heap graphs and calling it memory. Your dashboard shows jvm.memory.used{area=heap} flat at 60% and the pod has restarted three times this week. The heap isn't the process. That is the whole point of Native Memory Tracking.

What you trade when you turn it

Flag                         Default (21.0.12.1)   Status    What it does / what it costs
-Xmx / -Xms                  ergonomic             product   absolute sizes; ignore the container; bad if the limit changes
MaxRAMPercentage             25.0                  product   max heap = this % of the limit. Use this in containers.
InitialRAMPercentage         1.5625                product   starting heap; low start means more early resizing
MinRAMPercentage             50.0                  product   only applies when the machine counts as small (<~256 MB limit)
AlwaysPreTouch               false                 product   whole heap resident at start: no later page faults, bigger working set
UseContainerSupport          true                  product   read cgroup limits; turn off only to debug
ActiveProcessorCount         unset                 product   overrides CPU count; changes GC thread counts and the Serial/G1 pick
MaxMetaspaceSize             unlimited             product   cap on class metadata; turns a Metaspace leak into an OOM rather than a kill
MaxDirectMemorySize          0 (= about max heap)  product   caps direct buffers (see native memory article)
SoftMaxHeapSize              = max heap            manageable (21)  prints as manageable here; I did not test it, and I believe only ZGC acts on it

Why MaxRAMPercentage and not -Xmx: the limit in your manifest changes (someone bumps a pod from 2 GB to 4 GB at 2 a.m.), and a fixed -Xmx stays behind. A percentage follows. The cost is that you can't read the heap size from the config file anymore. Log it (-Xlog:gc+init).

Order I'd try things:

  1. Measure the non-heap part first. NMT summary (steps above) at steady state, plus the thread count times 1 MB (-XX:ThreadStackSize, default 1024 KB on x86-64) as a sanity check. If it's big, the cheapest fix is code: fewer threads, smaller direct buffers.
  2. Pick MaxRAMPercentage from the budget, not from a rule: (limit - non-heap - safety) / limit, with safety at about 10% of the limit for page cache, jcmd, and the things you forgot.
  3. Leave -Xms low unless startup latency matters, and AlwaysPreTouch off unless you need predictable latency more than you need a small working set.
  4. Cap what can grow unbounded: MaxMetaspaceSize, MaxDirectMemorySize if you have a framework that allocates direct buffers. A capped area throws a Java error; an uncapped one gets you a 137.
  5. Collector flags last. On my 2-CPU box a 1 GB limit got Serial and a 2 GB limit got G1, and one CPU gets Serial at any size, so unless you say otherwise a small pod is on Serial. Whether that's good is a different article: Serial and Parallel, then choosing a collector.

Not a fix: raising the container limit until the kills stop, which works and hides the leak; and setting -Xmx to 90%.

What to graph, and what changes when you upgrade

Leave this on in production:

-Xlog:gc+init,os+container=info:file=jvm-start.log
-XX:NativeMemoryTracking=summary      # I did not measure its overhead; measure it on your own service before leaving it on
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps

Make sure that dump path has room, or you'll turn a Java OOM into a full disk.

Metrics, with the thresholds I'd use:

  • Container working set vs limit. The number the kernel kills on is not the one in your heap panel. Alert above 85% of limit sustained; the kill is the failure you can't recover from.
  • jvm.memory.max for heap, jvm.memory.committed for all areas (Micrometer names as I know them; I didn't verify them on this run). Heap max should equal what you computed. If it doesn't, an old JAVA_OPTS is overriding it.
  • Non-heap committed trend after warm-up. A steady climb is a leak in Metaspace, direct memory or threads, which no heap setting will fix.
  • Restarts with exit code 137 and reason: OOMKilled. That's the container-level signal; it never shows up in a Java log.

Upgrades: I ran 21 only. The behaviours to re-check when you move between LTS lines are: (1) the printed Heap Max Capacity against the line you expect; (2) which collector Using ... reports (JDK 27, a non-LTS feature release that reached GA in September 2026 according to Phoronix, makes G1 the default everywhere via JEP 523, so a 1-CPU container that was Serial on 21 and 25 changes collector; see the G1 article); (3) the compressed-oops line for any limit above 30 GB. Pin the collector and the percentage the day before you upgrade. This took me most of a day and a half to pin down, mostly because the flat 126 MB stretch looked like a bug in my script, and I'd like you to not repeat that.

Keep these

  • -Xmx is the heap, the limit is the process. A heap equal to the limit is a guaranteed 137.
  • Defaults come from the cgroup limit: 25% max, 1.5625% initial. Under about 450 MB they aren't 25% (50% at the bottom, then a flat ~126 MB).
  • Use MaxRAMPercentage, size it from a measured non-heap number, and start near 50-60% for small limits, 70-75% from 2 GB.
  • MinRAMPercentage doesn't raise your maximum. The percentage flags can switch off compressed oops where -Xmx wouldn't.
  • G1 costs real bookkeeping memory (about 48 MB vs 2 MB in my lab at a 616 MB max heap). Small containers get Serial for a reason.

Questions that come up about Java heap size

What is the default Java heap size? One quarter of physical memory (or the container limit) as the maximum, one sixty-fourth as the initial size, on JDK 21.0.12.1. Below roughly 256 MB the maximum is 50% of memory, and between about 256 and 450 MB it sat at 126 MB in my tests.

Should I set -Xmx or MaxRAMPercentage in Docker or Kubernetes? I'd use -XX:MaxRAMPercentage, because the JVM re-derives the heap from whatever limit the pod has. Use -Xmx when you need an exact value that must not move with the limit.

Why does my Java container get OOMKilled when the heap looks fine? The heap isn't the whole process. Thread stacks, Metaspace, code cache, direct buffers and GC structures count against the limit and don't appear in a heap graph. In my lab 60 MB of direct buffers, 150 threads and JVM overhead used 113 MB before any heap data. See native memory and the OutOfMemoryError catalogue.

Is -Xms equal to -Xmx a good idea? It avoids heap resizing pauses, but it only makes the memory resident if you add -XX:+AlwaysPreTouch. On its own it committed 512 MB of address space and used 46 MB of RSS idle in my test.

gc-tuningjvmjvm-memory

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments