Running Spring Boot in a container is easy. But by default the JVM does not "look" at the container limits — it looks at the parameters of the entire host. This leads to unexpected crashes with exit code 137 or to unreasonably bloated thread pools. Let's figure out why this happens and how to fix it.
The historical problem: the JVM saw the host, not the container
Before Java 10, the JVM determined the available memory and number of processors by reading the operating system parameters directly — without taking cgroups into account (the Linux kernel mechanism Docker uses to limit a container's resources).
Imagine this: the host has 32 GB of RAM, but the container is started with --memory=512m. The JVM "saw" 32 GB and allocated a heap of several gigabytes. The container exceeded its limit, and Linux killed the process with a SIGKILL signal.
Starting with Java 10 (and back-ported to Java 8u191), the JVM can read cgroup limits and correctly calculate the available resources. All you need is a current image — for example, eclipse-temurin:21-jre.
OOMKilled: exit 137 versus OutOfMemoryError
It's important not to confuse two different events:
OutOfMemoryError — an exception inside the JVM. It happens when the Java heap is full and the garbage collector could not free enough memory. The application crashes "from the inside", and the JVM writes a message to the log.
OOMKilled (exit 137) — the process is killed from the outside by the operating system. The container exceeded the memory limit set in docker run --memory=... or in resources.limits.memory in Kubernetes. There is no Java exception — the process is simply terminated without warning.
A short formula: OutOfMemoryError is the JVM shouting "I'm out of space"; OOMKilled is the OS saying "you took too much, I'm shutting you down".
You can check the cause of a container crash with this command:
docker inspect <container-id> --format '{{.State.OOMKilled}}'
If it returns true — the OS is to blame, not the JVM.
How to manage memory: MaxRAMPercentage instead of -Xmx
It used to be common to set the heap size explicitly via -Xmx512m. In containers this is inconvenient: when you change the container limit, you also have to change the application flag.
The modern approach is -XX:MaxRAMPercentage. The flag sets the percentage of the available (cgroup-limited) memory that the JVM will give to the heap.
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/app.jar app.jar
# The JVM reads the container limit itself and takes 75% for the heap
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-jar", "app.jar"]
# The container gets 512 MB; the JVM allocates ~384 MB for the heap
docker run --memory=512m myapp
Recommended values for Spring Boot:
- 70–75% — a typical web service without heavy file work or native memory usage.
- 50–60% — if the application actively uses off-heap caches (for example, Caffeine with
softValues) or native memory (DirectByteBuffer, libraries via JNI).
The remaining memory is needed by the JVM for metaspace, thread stacks, JIT-compiled code, and I/O buffers — you can't hand it all over to the heap.
How to choose the container limit
A practical rule for Spring Boot:
container limit = MaxRAMPercentage% → heap + ~200–300 MB headroom for the rest
For an application with an expected heap of 512 MB and MaxRAMPercentage=75, the minimum container limit is: 512 / 0.75 ≈ 700 MB — set 768 MB or 1 GB with some headroom.
Cores and thread pools: availableProcessors
The JVM determines the number of available processors via Runtime.getRuntime().availableProcessors(). Most thread pools — including the Tomcat pools (the embedded Spring Boot server) and ForkJoinPool.commonPool() — size themselves based on exactly this value.
Before Java 10, the method returned the number of logical processors on the host, not the container. If the host has 32 cores but the container is allocated 1 core (--cpus=1), Tomcat could create 200 threads competing for a single core — that's a performance degradation.
Starting with Java 10, availableProcessors() reads the CPU cgroup quota and returns the correct value. There is nothing else to configure — Java 10+ and setting --cpus or a CPU quota in Kubernetes is enough.
# The container sees 2 cores; the JVM reports availableProcessors() = 2
docker run --cpus=2 myapp
If you need to override it manually (a rare case):
java -XX:ActiveProcessorCount=2 -jar app.jar
Full configuration: a minimal working example
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY build/libs/app.jar app.jar
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+UseContainerSupport", \
"-jar", "app.jar"]
# Running with explicit limits — the JVM adapts automatically
docker run \
--memory=768m \
--cpus=2 \
myapp
The -XX:+UseContainerSupport flag has been enabled by default since Java 11, but it's useful to specify it explicitly as documentation — it immediately makes clear that the image is meant to run in a container.
In short
- Before Java 10 the JVM read host parameters, ignoring the container's cgroup limits — this led to
OOMKilled. OOMKilled(exit 137) — the process is killed by the OS for exceeding the container's memory limit;OutOfMemoryErroris a Java exception inside the JVM — different causes and different fixes.- Use
-XX:MaxRAMPercentage=75.0instead of-Xmx— the JVM computes the right amount from the container limit itself. - Leave 200–300 MB above the heap for metaspace, stacks, JIT, and I/O buffers.
- Since Java 10+,
availableProcessors()reads the container's CPU quota — thread pools (Tomcat,ForkJoinPool) are configured correctly and automatically. - The current base image is
eclipse-temurin:21-jre;UseContainerSupportis enabled by default.
What to read next
- Packaging Spring Boot into a Docker image — the first step: how to build and run an application in a container.
- Multi-stage builds and image layers — how to reduce image size and speed up rebuilds.
- Docker image best practices — security, minimal size, the right layer order.