Back to Kubernetes: Core Concepts
Q2Question 2 of 16

How the JVM behaves in containers and Kubernetes: UseContainerSupport, MaxRAMPercentage, request=limit, ExitOnOutOfMemoryError: and what went wrong before container awareness

Key Concepts
  • Before container awareness, the JVM sized itself from host memory, causing pods to be OOM-killed when the cgroup limit was exceeded.
  • -XX:+UseContainerSupport (on by default since Java 10, backported to 8u191) makes the JVM read cgroup memory and CPU limits.
  • -XX:MaxRAMPercentage sets the heap as a fraction of the container limit, typically 75% to leave room for non-heap memory.
  • Setting Kubernetes memory request equal to limit gives the pod Guaranteed QoS class and prevents overcommit kills.
  • -XX:+ExitOnOutOfMemoryError terminates the JVM cleanly on OOM so Kubernetes restarts the pod to a known-good state.
  • Always verify at startup that the effective heap reflects the cgroup limit, not the host.

Definition

A container-aware JVM sizes itself from cgroup limits, not the host; set the heap as a percentage of the limit, make request equal limit, and exit on OOM so Kubernetes restarts the pod cleanly.
  • 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.

BASH
# 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.

TEXT
# 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.

BASH
# 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)
JAVA
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.

BASH
# 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
TEXT
# 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.

YAML
# 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
YAML
# 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.

BASH
# 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
JAVA
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:

YAML
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?

BASH
# 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
BASH
# 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
BASH
# 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.

BASH
# 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
JAVA
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

DIAGRAM
 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.

---

Share
Use ← → arrow keys to navigate