A Java service in a container with a 2 GiB memory limit keeps being killed by the kernel out-of-memory killer, yet it never throws a Java heap OutOfMemoryError. How do you size the JVM so this stops happening?
answer
- exit 137 kernel kill, no Java OutOfMemoryError
- RSS = heap + metaspace + code cache + stacks + direct + GC structures + native
- MaxRAMPercentage 50-75, not the 25 default
- cap MaxMetaspaceSize, MaxDirectMemorySize, ReservedCodeCacheSize
- prove it with NativeMemoryTracking + jcmd
basics
~20 sThe container limit covers the whole process, not just the heap. Set the maximum heap to a fraction of the limit - commonly 50-75% depending on thread count and off-heap use - leaving room for metaspace, code cache, stacks, direct buffers and GC structures, then verify the real resident footprint with Native Memory Tracking.
solid answer
~50 sA kernel kill with no Java OutOfMemoryError means the process exceeded the cgroup limit through memory the heap bound never covered. Resident memory equals committed heap plus metaspace and compressed class space, the JIT code cache, one stack per thread, direct and mapped byte buffers, garbage-collector bookkeeping, and native library and malloc overhead. Size from the limit downward. Confirm the JVM sees the limit at all - container support is on by default from JDK 10, but an old JDK 8 build sizes from host RAM. Then set the heap explicitly, either -XX:MaxRAMPercentage around 50-75 or a fixed -Xmx, choosing the lower end when the service runs hundreds of threads or uses large direct buffers. Bound the other consumers rather than hoping: -XX:MaxMetaspaceSize, -XX:MaxDirectMemorySize, -XX:ReservedCodeCacheSize, and a considered -Xss with a bounded thread pool. Verify with -XX:NativeMemoryTracking=summary and jcmd, and pin -Xms so the footprint is proven at startup.
code
text · 9 linesjava -XX:InitialRAMPercentage=60 -XX:MaxRAMPercentage=60 \
-XX:MaxMetaspaceSize=256m \
-XX:ReservedCodeCacheSize=128m \
-XX:MaxDirectMemorySize=128m \
-XX:NativeMemoryTracking=summary \
-jar app.jar
# attribute the real footprint under load
jcmd <pid> VM.native_memory summarygo deeper
Know that the container limit covers the whole process and that the heap must be set below it, not equal to it.
Enumerate the main non-heap consumers and use percentage-based heap sizing with container support in mind.
Build the budget from the limit downward, cap each unbounded consumer, and verify with Native Memory Tracking under peak load.
Make heap fraction and non-heap caps a platform default tied to workload class, with footprint verification in CI or canary rather than per-service folklore.
## Reading the symptom correctly Two different failures are easy to confuse. A Java OutOfMemoryError is thrown by the JVM when it cannot satisfy an allocation within its own configured limits; the process usually survives long enough to log it. A kernel OOM kill happens when the cgroup's total charged memory exceeds the container limit - the process is terminated by signal, typically surfacing as exit code 137, with no Java-level message at all. The second failure with no sign of the first is the fingerprint of a process whose non-heap memory was never budgeted. ## The budget What the container is charged for is roughly: committed heap, plus metaspace and the compressed class space that holds class metadata, plus the JIT code cache holding compiled methods, plus one stack per live thread, plus direct and mapped byte buffers used by NIO and many network and serialization libraries, plus the collector's own structures (mark bitmaps, remembered sets, forwarding tables - commonly several percent of heap and materially more for some collectors), plus symbol and string tables, JVM internals, JNI allocations and native library arenas. On a small container these are not a rounding error. Two hundred threads at a 1 MiB default stack size is 200 MiB of address space and a real, if smaller, resident cost. A code cache can reach a couple of hundred megabytes on a large application. Direct buffers default to a maximum equal to -Xmx, so a service that uses them heavily can in principle double its footprint. ## Making the JVM see the limit First check the obvious: does the JVM know the limit? -XX:+UseContainerSupport is on by default from JDK 10 and reads cgroup v1 and v2 memory limits, so defaults are computed from 2 GiB rather than host RAM. On older runtimes, or where the limit is applied in a way the JVM cannot read, -XX:MaxRAM can state it explicitly. Note also that a cgroup CPU limit changes the JVM's view of available processors, which changes GC and compiler thread counts and therefore memory - worth checking on small containers. ## Choosing the heap fraction The default of 25% is safe but wasteful. A practical approach is to compute rather than guess: start from the limit, subtract a measured allowance for metaspace, code cache, stacks and direct buffers, subtract a margin for GC structures and native slack, and give the remainder to the heap. For an ordinary service with a bounded thread pool and little direct-buffer use, that typically lands between 60% and 75%. For a service with hundreds of threads, large off-heap caches, or native libraries, 50% or less is realistic. Express it as -XX:MaxRAMPercentage so the setting survives a change of container limit, and set -XX:InitialRAMPercentage to the same value if you want the footprint proven at startup rather than discovered at peak. ## Bounding the rest Unbounded consumers are what turn a working configuration into a 3 a.m. kill. Metaspace is unlimited by default and grows with dynamically generated classes, so -XX:MaxMetaspaceSize converts a slow container kill into a clear Java error. -XX:MaxDirectMemorySize does the same for NIO buffers instead of letting them track -Xmx. -XX:ReservedCodeCacheSize caps compiled code. Thread stacks are bounded by bounding the thread pools, with -Xss adjusted only with knowledge of recursion depth. On glibc, MALLOC_ARENA_MAX limits per-thread native arenas that can otherwise inflate resident memory noticeably on many-threaded services. ## Verifying instead of hoping Run with -XX:NativeMemoryTracking=summary and use jcmd VM.native_memory summary to attribute reserved and committed memory by category - heap, class, thread, code, GC, internal. Compare the total committed figure against the container limit under peak load, and compare it against the resident size the platform reports. This turns the budget from arithmetic into measurement, and it is what distinguishes a sized service from a guessed one. ## Result The fix is not a bigger container by reflex. It is an explicit heap fraction, explicit caps on the other consumers, a pinned initial heap so the footprint is honest from startup, and a measurement that proves the total sits inside the limit with margin.
- How do you tell a kernel OOM kill from a Java OutOfMemoryError after the fact?A kernel kill leaves no Java-side evidence: the process disappears, the platform reports termination by signal (commonly surfaced as exit code 137), and the node's kernel log records the OOM event for the cgroup. A Java OutOfMemoryError is thrown inside the JVM, appears in application logs with a subsystem in the message, and does not by itself terminate the process by signal.
- Why does capping metaspace turn a vague problem into a clear one?Metaspace is unbounded by default, so a class-loading leak grows native memory until the container limit is hit and the kernel kills the process with no diagnostic. With -XX:MaxMetaspaceSize the JVM instead fails at a known boundary with an explicit Java-level error naming metaspace, which points directly at class loading rather than at the heap.
saying these in an interview costs you the question
- Setting -Xmx equal to the container memory limit
- Assuming resident memory should approximately equal -Xmx
- Believing modern JVMs still size the heap from host RAM inside containers
- Raising the container limit repeatedly without attributing where the memory goes
- Leaving metaspace and direct memory unbounded and calling the heap correctly sized