Definition
- UseContainerSupport – on by default since Java 10 (backported to 8u191); the JVM reads cgroup memory and CPU limits instead of the host totals.
- MaxRAMPercentage – sets the heap as a fraction of the detected container limit (e.g., 75%), leaving headroom for Metaspace, thread stacks, and native buffers.
- request = limit – setting the Kubernetes memory request equal to the limit gives the pod Guaranteed QoS, meaning the memory it was sized for is genuinely reserved.
- ExitOnOutOfMemoryError – makes the JVM terminate immediately on OOM so Kubernetes sees the exit and restarts the pod cleanly, instead of limping in a corrupted state.
- Before container awareness – the JVM saw the host's full memory and CPUs, causing default heap and thread pools to be far too large for the container.
Imagine a bank clerk assigned one small room in a large building, but the clerk plans staffing as if the entire building were theirs. They order furniture and hire staff for the whole building, instantly overflow their room, and get evicted. Container awareness gives the clerk the correct room size. Request equals limit reserves that room exclusively. ExitOnOutOfMemoryError means the clerk agrees to leave immediately and be replaced if they ever run out of space.
Understanding the Concepts
What went wrong before container awareness?
Before the JVM was container-aware, it used operating-system calls that reported the physical host's resources. A pod limited to 1 GB on a 64 GB node would see 64 GB, and because the default maximum heap is one quarter of detected RAM, the JVM would try to size the heap at 16 GB. The same happened with CPUs: the JVM saw all host cores and sized the ForkJoinPool, GC threads, and other pools for the whole machine.
# Simulating the old behavior -- JVM sees host memory, not container limit
# On a 64 GB host with a 1 GB container limit:
java -XX:-UseContainerSupport -XshowSettings:vm -version 2>&1 | grep "Max Heap"
# Output: Max Heap Size: 16.00 GB (1/4 of 64 GB host -- way too big for 1 GB container)
The result was pods that either refused to start or grew their heap until resident memory crossed the cgroup limit and the kernel OOM-killed them, often under load and seemingly at random.
# Kubernetes event showing OOM-kill:
Warning OOMKilled Pod bank-transfer-service-7f8d6 exceeded memory limit (1Gi)
Reason: the JVM sized heap to 16 GB on a 64 GB host, ignoring the 1 GB cgroup limit
How does UseContainerSupport fix this?
-XX:+UseContainerSupport makes the JVM read the cgroup memory limit and CPU quota instead of the host values. It is on by default from Java 10 and was backported to late Java 8 updates (8u191+), so any current JVM is container-aware unless you explicitly disable it.
# Container-aware JVM sees the correct limit
docker run --memory=1g eclipse-temurin:21-jre \
java -XshowSettings:vm -version 2>&1 | grep "Max Heap"
# Output: Max Heap Size: 256.00 MB (1/4 of 1 GB container limit -- correct)
package com.bank.core;
public class ContainerMemoryCheck {
public static void main(String[] args) {
long maxHeap = Runtime.getRuntime().maxMemory();
long totalMem = Runtime.getRuntime().totalMemory();
System.out.println("Max heap: " + maxHeap / (1024 * 1024) + " MB");
System.out.println("Current heap: " + totalMem / (1024 * 1024) + " MB");
System.out.println("Available processors: " + Runtime.getRuntime().availableProcessors());
// In a container with --cpus=2, this returns 2, not the host's 32 cores
}
}
How do you size the heap with MaxRAMPercentage?
-XX:MaxRAMPercentage controls the heap as a percentage of the detected limit. Rather than a fixed -Xmx that breaks when the pod limit changes, you set the heap as a fraction, typically 75% for G1, leaving the rest for non-heap memory.
# Percentage-based heap sizing in a container
java -XX:InitialRAMPercentage=75.0 \
-XX:MaxRAMPercentage=75.0 \
-XX:MinRAMPercentage=75.0 \
-jar bank-transfer-service.jar
# With a 2 GB container limit and MaxRAMPercentage=75.0:
Max heap = 2048 MB * 0.75 = 1536 MB
Remaining = 512 MB for Metaspace, thread stacks, code cache, direct buffers
# If the pod limit changes to 4 GB, the heap automatically becomes 3072 MB
# No code or config change needed
| Approach | Pros | Cons |
|----------|------|------|
| Fixed -Xmx=1536m | Explicit, easy to reason about | Breaks when pod limit changes; must update on every resize |
| -XX:MaxRAMPercentage=75.0 | Adapts automatically to any pod limit | Must verify the JVM sees the correct cgroup limit |
Why should request equal limit in Kubernetes?
In Kubernetes, the request is what the scheduler reserves and the limit is the hard cgroup ceiling. If request is lower than limit, the pod can land on an overcommitted node and be killed unpredictably under memory pressure.
# WRONG: overcommitted -- pod can be killed under node pressure
apiVersion: v1
kind: Pod
metadata:
name: bank-transfer-service
spec:
containers:
- name: app
image: bank/transfer-service:1.2.3
resources:
requests:
memory: "512Mi" # scheduler reserves only 512 MB
limits:
memory: "2Gi" # but container can use up to 2 GB
# CORRECT: Guaranteed QoS -- memory is genuinely reserved
apiVersion: v1
kind: Pod
metadata:
name: bank-transfer-service
spec:
containers:
- name: app
image: bank/transfer-service:1.2.3
resources:
requests:
memory: "2Gi" # scheduler reserves 2 GB
limits:
memory: "2Gi" # hard ceiling is also 2 GB
env:
- name: JAVA_OPTS
value: "-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
Setting request equal to limit gives the pod Guaranteed QoS class, meaning it is the last to be evicted under node pressure.
How does ExitOnOutOfMemoryError help?
By default, a heap OutOfMemoryError may be caught and the JVM may stagger on in a degraded state. With -XX:+ExitOnOutOfMemoryError, the JVM terminates immediately on OOM. Kubernetes sees the container exit and restarts it, returning to a known-good state.
# Production flags for clean OOM handling
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/var/log/bank/heapdumps/ \
-XX:+ExitOnOutOfMemoryError \
-XX:MaxRAMPercentage=75.0 \
-jar bank-transfer-service.jar
# On OOM: dump is captured, then JVM exits, then K8s restarts the pod
package com.bank.debug;
import java.util.ArrayList;
import java.util.List;
public class OomInContainerDemo {
public static void main(String[] args) {
List<byte[]> leak = new ArrayList<>();
System.out.println("Simulating unbounded transaction cache...");
while (true) {
leak.add(new byte[1_048_576]); // 1 MB per iteration
}
// With ExitOnOutOfMemoryError: JVM exits immediately
// Without it: JVM may limp with some threads dead
}
}
What is the complete Kubernetes deployment for a JVM service?
Combining all the pieces into a production-ready Kubernetes deployment for a banking service:
apiVersion: apps/v1
kind: Deployment
metadata:
name: bank-transfer-service
spec:
replicas: 3
selector:
matchLabels:
app: bank-transfer-service
template:
metadata:
labels:
app: bank-transfer-service
spec:
containers:
- name: app
image: bank/transfer-service:abc123
resources:
requests:
memory: "2Gi"
cpu: "1000m"
limits:
memory: "2Gi"
cpu: "1000m"
env:
- name: JAVA_OPTS
value: >-
-XX:MaxRAMPercentage=75.0
-XX:+UseG1GC
-XX:MaxMetaspaceSize=256m
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/heapdumps/
-XX:+ExitOnOutOfMemoryError
-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
initialDelaySeconds: 15
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
initialDelaySeconds: 30
What are common mistakes with JVM containers?
# WRONG: using an old JVM without container support
FROM openjdk:8u171-jre # Pre-8u191, no container awareness
# The JVM sees the host's 64 GB, not the container's 1 GB
# CORRECT: use a modern JVM with container support
FROM eclipse-temurin:21-jre # Container-aware by default
# WRONG: disabling container support
java -XX:-UseContainerSupport -jar bank-service.jar
# Now the JVM ignores the cgroup limit and sizes for the host
# CORRECT: leave container support on (default)
java -XX:MaxRAMPercentage=75.0 -jar bank-service.jar
# WRONG: fixed -Xmx that does not match the container limit
java -Xmx4g -jar bank-service.jar # In a 2 GB container -- instant OOM-kill
# CORRECT: percentage-based sizing
java -XX:MaxRAMPercentage=75.0 -jar bank-service.jar
How do you verify the JVM sees the correct container limit?
Always verify at startup rather than trusting the manifest alone.
# Verify effective heap inside the running container
docker exec bank-transfer-service \
jcmd 1 VM.flags | grep -E "MaxHeapSize|UseContainerSupport"
# Or print at startup in the application
java -XX:+PrintFlagsFinal -version 2>&1 | grep MaxHeapSize
package com.bank.core;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryMXBean;
public class StartupMemoryVerifier {
public static void verify() {
MemoryMXBean mem = ManagementFactory.getMemoryMXBean();
long maxHeapMB = mem.getHeapMemoryUsage().getMax() / (1024 * 1024);
System.out.println("Effective max heap: " + maxHeapMB + " MB");
// In a 2 GB container with MaxRAMPercentage=75: expect ~1536 MB
// If you see 16384 MB, container support is broken
}
}
Visual Overview
Before Container Awareness (pre-Java 10 / pre-8u191) ====================================================== ┌───────────────────────────────┐ │ Host: 64 GB RAM │ │ │ │ ┌─────────────────────────┐ │ │ │ Container: 1 GB limit │ │ │ │ │ │ │ │ JVM sees: 64 GB !!! │ │ │ │ Default heap: 16 GB │ │ │ │ Actual limit: 1 GB │ │ │ │ Result: OOM-KILLED │ │ │ └─────────────────────────┘ │ └───────────────────────────────┘ After Container Awareness ========================== ┌───────────────────────────────┐ │ Host: 64 GB RAM │ │ │ │ ┌─────────────────────────┐ │ │ │ Container: 2 GB limit │ │ │ │ │ │ │ │ JVM sees: 2 GB cgroup │ │ │ │ MaxRAMPercentage=75% │ │ │ │ ┌───────────────────┐ │ │ │ │ │ Heap: 1536 MB │ │ │ │ │ ├───────────────────┤ │ │ │ │ │ Non-heap: 512 MB │ │ │ │ │ │ (Meta, stacks, │ │ │ │ │ │ code cache, NIO) │ │ │ │ │ └───────────────────┘ │ │ │ └─────────────────────────┘ │ └───────────────────────────────┘ Kubernetes QoS Classes ======================= request < limit ──▶ Burstable QoS ──▶ evicted under pressure request = limit ──▶ Guaranteed QoS ──▶ last to be evicted
Key Takeaway
Before the JVM was container-aware it sized itself from the physical host, not the container. A pod limited to 1 GB on a 64 GB node would see all the host memory, size the heap at 16 GB, and get OOM-killed by the kernel. Container support fixed this: with UseContainerSupport (on by default since Java 10, backported to 8u191), the JVM reads the cgroup memory and CPU limits and sizes its ergonomics correctly.
On top of container support, size the heap with MaxRAMPercentage rather than a fixed -Xmx, typically at 75% to leave room for Metaspace, thread stacks, and native buffers. Set the Kubernetes memory request equal to the limit so the pod gets Guaranteed QoS and the memory is genuinely reserved. Set ExitOnOutOfMemoryError so a real OOM terminates the JVM immediately and Kubernetes restarts it to a known-good state, paired with HeapDumpOnOutOfMemoryError to capture evidence on the way out.
At National Bank of India we lifted an old transfer service onto Kubernetes on an early Java 8 build and the pods were OOM-killed within minutes because the JVM was sizing for the whole node. Upgrading the JVM, switching to MaxRAMPercentage, making request equal to limit, and adding exit-on-OOM stabilized it completely.
Remember one sentence: in containers, let the JVM read the cgroup limit with MaxRAMPercentage, set request equal to limit for Guaranteed QoS, and exit on OOM for clean restarts.
---