Deploying Java applications inside Docker containers and running them on orchestrators like Kubernetes is standard practice for modern cloud architectures. However, developers frequently encounter an frustrating issue: their Spring Boot application unexpectedly terminates with an exit code of 137. This specific exit code indicates that the operating system's kernel terminated the container because it exceeded its memory limit—a process known as an Out-Of-Memory (OOM) kill. To resolve this, you must understand how the Java Virtual Machine (JVM) interacts with container resource limits and how to configure JVM memory settings correctly.
The Host vs. Container Memory Mismatch
Historically, the JVM was designed to run directly on bare-metal servers or virtual machines. At startup, the JVM queries the operating system to determine the available physical memory and CPU cores, and then allocates default resource limits (like maximum heap size) accordingly.
Inside a Docker container, the environment is constrained using Linux cgroups (control groups). However, older versions of Java (pre-Java 8u191 and Java 10) were not aware of these container constraints.
- The Bug: If you ran a Java 8u131 application inside a container limited to 512MB on a host machine with 64GB of RAM, the JVM ignored the 512MB container limit. It saw 64GB of total RAM and calculated its default maximum heap size as 25% of the host memory—which is 16GB!
- The Crash: As soon as the Java heap allocation crossed the 512MB container limit, the Linux kernel stepped in and terminated the container with an OOM kill.
Modern JVM Container Awareness
Starting from Java 8u191 and Java 10+, the JVM became container-aware by default. The JVM now checks /sys/fs/cgroup/ to determine if it is running inside a container and respects cgroup constraints.
If you start a modern JVM container with a memory limit of 1GB, the JVM will calculate its default maximum heap size based on that 1GB limit, preventing immediate OOM crashes.
JVM Memory Allocation Parameters: MaxRAMPercentage
In modern Java, you should avoid hardcoding the maximum heap size using the old -Xmx parameter (e.g., -Xmx768m). Using absolute values is problematic because if you scale your Kubernetes container resources up or down, you must also update your JVM flags.
Instead, use fraction-based configuration flags:
-XX:MaxRAMPercentage: The percentage of the container's RAM allocated to the JVM heap.-XX:InitialRAMPercentage: The starting heap size percentage.-XX:MinRAMPercentage: Used to calculate heap size if memory limits are very low.
For example, if you set -XX:MaxRAMPercentage=75.0 on a container with 2GB of memory limit, the JVM will calculate the maximum heap size as 1.5GB dynamically.
Why is 70% to 75% the Sweet Spot for MaxRAMPercentage?
A common mistake is setting MaxRAMPercentage to 90.0 or 100.0 in the belief that the JVM should use all container memory for heap. Doing this will almost certainly trigger an OOM kill.
The JVM requires significant memory outside of the garbage-collected Java heap. This is known as Off-Heap Memory (Non-Heap Memory):
- Metaspace: Stores class definitions and metadata (typically requires 100MB - 300MB).
- Thread Stacks: Each Java thread requires its own stack space (typically 1MB per thread). With hundreds of threads, this adds up.
- Garbage Collector Overhead: G1GC or ZGC require additional native memory buffers to perform concurrent memory cleaning.
- Direct Byte Buffers: Used by non-blocking networking libraries (like Netty, Tomcat, and WebFlux) for direct I/O.
- Code Cache: For JIT compiler optimizations.
By setting MaxRAMPercentage to 70-75%, you reserve 25-30% of the container memory for off-heap allocations, ensuring the entire container remains safely below the cgroup limit.
Recommended Dockerfile and Kubernetes Configurations
Let's look at how to structure a production-ready Dockerfile for a Spring Boot application using multi-stage builds and non-root users:
# Stage 1: Build stage
FROM maven:3.9.6-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests
# Stage 2: Runtime stage
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/job-listing-service.jar app.jar
# Run as non-root user for security
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring
# Inject JVM parameters using environment variables
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:+UseG1GC"
ENTRYPOINT ["sh", "-c", "java \$JAVA_OPTS -jar app.jar"]
In your Kubernetes deployment YAML, specify the memory resource limits matching your percentage constraints:
apiVersion: apps/v1
kind: Deployment
metadata:
name: job-listing-service
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: job-listing-service:latest
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "1Gi"
cpu: "1000m"
Under this configuration, the JVM dynamically calculates its heap size as 768MB (75% of 1GB), leaving 256MB of room for off-heap buffers.
Interview Strategy for Indian Software Engineers
If you are interviewing for DevOps, Cloud Engineer, or Lead Backend Developer roles in India:
- Explain the cgroup bug: Demonstrating that you know how older JDKs misread physical host memory is a huge plus.
- Differentiate Heap vs Non-Heap: Be ready to list metaspace, thread stacks, and Netty direct buffers as reasons why the heap should never take up 100% of container memory.