When the heap is fine and memory still grows

JVM · lesson 33 of 34 · 6 min read

Metaspace, direct buffers, thread stacks and the allocator - the memory Xmx does not cover.

Open this lesson in the learning hub

Key points

  • A JVM process uses far more than its heap. Metaspace, code cache, thread stacks, direct byte buffers, GC structures and the native allocator all sit outside -Xmx.
  • This is why a container is killed with an OOM while heap dumps look perfectly healthy: the limit applies to the whole process, and the heap was never the problem.
  • Metaspace grows with loaded classes. Repeated redeployment in one JVM, or heavy dynamic proxy generation, leaks it - and the classloader is kept alive by a single lingering reference.
  • Direct byte buffers are freed only when their Java wrapper is collected, so native memory can be held long after it is logically unused. NIO and Netty allocate these heavily.
  • Thread stacks are the quiet one: 1MB each by default, so a thread leak costs native memory rather than heap and shows up nowhere in a heap dump.
  • Native Memory Tracking is the tool for all of this. Enable it, take a baseline, and diff - guessing at native memory is unproductive.

Example

# Native Memory Tracking - the only reliable way to see this.
$ java -XX:NativeMemoryTracking=detail -jar app.jar
$ jcmd <pid> VM.native_memory baseline
#   ... let it run ...
$ jcmd <pid> VM.native_memory summary.diff

#   Total: reserved=2.5GB, committed=1.8GB
#   -   Java Heap (reserved=1GB,   committed=1GB)
#   -     Class (reserved=200MB, committed=180MB  +40MB)  <- metaspace growing
#   -    Thread (reserved=520MB, committed=520MB  +60MB)  <- thread leak
#   -      Code (reserved=250MB, committed=120MB)
#   -        GC (reserved=100MB, committed=90MB)

# The arithmetic that explains a container OOM kill:
#
#   heap        1024 MB   (-Xmx1g)
#   metaspace    180 MB
#   code cache   120 MB
#   threads      520 MB   (520 threads x 1 MB stack)
#   direct        64 MB
#   GC + misc    190 MB
#   ---------------------
#   TOTAL       ~2.1 GB   against a 2 GB container limit -> killed
#
# And the heap dump would have shown 1 GB, looking entirely healthy.

# Bound the parts that are boundable:
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:ReservedCodeCacheSize=128m
-Xss512k                          # halves the cost of every thread
-XX:MaxRAMPercentage=60           # leave real room for the rest

A container OOM with a healthy heap means native memory - enable NMT and diff a baseline rather than guessing.

This is a reading copy. The full lesson — with the visual explainer, the interactive lab and a Run button for the code — lives in the JVM course, and every lesson in it is listed on the JVM contents page.