Tuning the JVM in a container

It happens all the time. An app runs perfectly on your laptop, then gets killed the moment it goes to Kubernetes. In the pod events, an OOMKilled, and nothing to explain it. Another version: it starts, but it drags, and the code has nothing to do with it. Most of the time the problem is elsewhere. The JVM misreads the container’s limits, or the heap was sized without counting what sits around it.

There are only a handful of settings to know, though. The trick is knowing what the JVM actually sees when it starts inside a container.

What the JVM sees of a container

A container isn’t a machine. It’s just an isolated process on the host, with resource limits set by the kernel’s cgroups. The old trap: back then the JVM ignored those limits and read the RAM and core count of the whole host. On a 64 GB, 32-core node, a JVM capped at 512 MB by the container thought it had room to spare and sized its structures for the whole machine. So it got killed.

That’s been solved for a long time. From JDK 10 on, the JVM reads the cgroups, and -XX:+UseContainerSupport is on by default. It picks up the container’s memory limit and CPU quota, not the host’s. On any current version (17, 21, or the recently released 25), there’s nothing to switch on.

One case still trips people up in production: cgroup v2. Recent systems (modern systemd, Docker and Kubernetes on a recent kernel) use this version of the cgroup hierarchy, and the JVM only learned to read it in JDK 15, with a backport to 11.0.16. On an older 11 sitting on a cgroup v2 host, detection fails silently and the JVM falls back to the host’s numbers. If your settings look ignored, start by checking the exact JDK version.

Memory: 25% by default, and everything else

This is the setting that surprises people most. In a container you don’t fix the heap with a hard -Xmx, but with a percentage of available memory:

-XX:MaxRAMPercentage=75.0

The default for MaxRAMPercentage is 25%. On a container limited to 1 GB, the heap therefore caps out at 256 MB, and three quarters of the allocated memory sit idle. Plenty of teams run this way without knowing it, paying for RAM they never use. Raising this value to around 70 to 75% reclaims that headroom.

Why not 100%? Because the heap is only one piece of what the JVM consumes. You also need room for:

If you push the heap right up to the container ceiling, nothing is left for any of this, and the kernel’s OOM killer steps in. Leave headroom. As with GC tuning, set the initial equal to the max so the memory is reserved up front:

-XX:InitialRAMPercentage=75.0 -XX:MaxRAMPercentage=75.0

OOMKilled is not OutOfMemoryError

These two errors look alike but have nothing in common, and confusing them costs hours.

A java.lang.OutOfMemoryError: Java heap space comes from the JVM itself: the heap is full, the GC couldn’t free enough space, and an exception is thrown in the code. You get a stack trace, often a heap dump, and the problem is on the Java-object side. That’s the subject of Diagnosing a memory leak with a heap dump.

An OOMKilled (exit code 137, that is 128 + 9 for SIGKILL) comes from the kernel: the process’s RSS exceeded the cgroup’s memory limit, and the container was killed outright. No Java exception, no stack trace, just a process that vanishes. The culprit is often outside the heap: a metaspace that swells, a native memory leak, too many threads, or simply a MaxRAMPercentage set too high that leaves no room for the rest.

When RSS climbs while the heap stays healthy, turn on Native Memory Tracking to see where the off-heap memory goes:

-XX:NativeMemoryTracking=summary

Then, on the running process:

jcmd <pid> VM.native_memory summary

You get the breakdown across heap, metaspace, threads, code and internal memory: enough to know which area is overflowing before touching anything.

CPU: availableProcessors drives everything

Memory gets the attention, but CPU detection does just as much damage, and more quietly. Runtime.availableProcessors() returns the CPU count computed from the cgroup’s quota, rounded up. With --cpus=1.5, the JVM sees 2 processors.

That number isn’t just for your code: by default it sizes the GC’s thread count, the size of the common ForkJoinPool, and any number of pools calibrated on the core count. Underestimated, your parallelism collapses; overestimated, you create more threads than the container can actually run, and time drains away in context switches.

When detection gets it wrong, or you want to force it, there’s a dedicated flag:

-XX:ActiveProcessorCount=2

It fixes the number of CPUs the JVM sees, independently of what the cgroup exposes. Handy when several JVMs share a large node and each overestimates its share.

Verify what the JVM detected

Don’t tune blind. The JVM can print exactly what it read from the container, through unified logging:

java -Xlog:os+container=trace -version

The output lists the detected memory limit, the CPU quota and period, and the processor count it settled on. It’s the first move when a setting seems ignored: you see at once whether the JVM understood the limits, or fell back to the host’s values, a telltale sign of a cgroup v2 or version problem.

In short

Check your JDK version and its cgroup v2 support, raise MaxRAMPercentage to around 75% while keeping headroom for off-heap, learn to tell an OOMKilled from an OutOfMemoryError, watch the CPU count the JVM thinks it has, and confirm all of it with -Xlog:os+container=trace. A well-tuned JVM in a container is nothing arcane: it just asks that you look at what it sees, instead of assuming.