Turning it on
The problem. The heap is fine, RSS keeps growing, and the pod is OOMKilled anyway. Something outside the heap grows - threads, metaspace, buffers - and only the JVM can tell you which. NMT (Native Memory Tracking) is that report.
What you need to know already: the memory areas of a JVM (20.1), jcmd and attaching (20.3), systemd drop-ins (2.3), cgroup OOM (5.11).
NMT is the JVM accounting for its own native allocations. It must be enabled at startup - there is no attaching it later:
-XX:NativeMemoryTracking=summary per category (5-10% overhead in the worst case, usually ~1-2%)
-XX:NativeMemoryTracking=detail per call site - for the JVM developers, rarely needed
Without it:
$ sudo -u appuser jcmd $(pgrep -f orders.jar) VM.native_memory summary
1210:
Native memory tracking is not enabled
On a systemd service the least invasive way is a drop-in:
$ sudo systemctl edit orders
# [Service]
# Environment=JAVA_TOOL_OPTIONS=-XX:NativeMemoryTracking=summary
$ sudo systemctl restart orders
$ journalctl -u orders -n 20 | grep Picked
java[17390]: Picked up JAVA_TOOL_OPTIONS: -XX:NativeMemoryTracking=summary
Reading the summary
$ sudo -u appuser jcmd $(pgrep -f orders.jar) VM.native_memory summary
17390:
Native Memory Tracking:
(Omitting categories weighting less than 1KB)
Total: reserved=2127809KB, committed=598240KB
malloc: 29095KB #165519
mmap: reserved=2098714KB, committed=569145KB
- Java Heap (reserved=524288KB, committed=370688KB)
(mmap: reserved=524288KB, committed=370688KB)
- Class (reserved=1050624KB, committed=14720KB)
(classes #16234)
( instance classes #15321, array classes #913)
...
- Metaspace (reserved=100352KB, committed=99602KB)
- Thread (reserved=116480KB, committed=17472KB)
(thread #56)
(stack: reserved=114240KB, committed=15680KB)
- Code (reserved=253952KB, committed=46080KB)
- GC (reserved=39649KB, committed=22536KB)
- Other (reserved=16384KB, committed=16384KB)
- Symbol (reserved=20480KB, committed=20480KB)
...
- Total committed is the number to compare with RSS. They will not match to the byte: committed memory is not always resident, and glibc's malloc arenas and native libraries are outside NMT's view.
- reserved is address space: the 1 GB for
Classand the 240 MB forCodeare reservations, harmless. Java Heap committedequalsGC.heap_info'stotal.Thread (thread #56)withstack: reserved=114240KB= 56 x 2040 KB - the aarch64 defaultThreadStackSize. committed (15 MB) is what the stacks actually touched.Otheris where direct ByteBuffers land (ByteBuffer.allocateDirect, Netty). It stays small in a plain Spring MVC app and grows in Netty/gRPC ones.Class+Metaspace: class metadata. A metaspace leak (a classloader leak, a library generating classes at runtime) shows up here asclasses #climbing.
Baseline and diff: what is growing
One summary is a photograph. What you want in an incident is a diff:
$ sudo -u appuser jcmd $(pgrep -f orders.jar) VM.native_memory baseline
PID:
Baseline taken
... wait a few minutes of real traffic ...
$ sudo -u appuser jcmd $(pgrep -f orders.jar) VM.native_memory summary.diff
PID:
Native Memory Tracking:
Total: reserved=2489602KB +361793KB, committed=951402KB +210310KB
- Java Heap (reserved=1075200KB, committed=1075200KB)
- Thread (reserved=895232KB +340000KB, committed=213888KB +52080KB)
(thread #422 +160)
...
Every changed category carries a + or - delta. Here Thread grew by 160 threads and 52 MB committed in a few minutes while the heap did not move at all. That is a thread leak, and it kills the container by RSS while every heap dashboard looks fine.
Mapping symptoms to categories
Thread grows, thread # grows thread leak: executors created per request, never shut down
Other grows direct buffers: Netty/NIO, missing release(), MaxDirectMemorySize unset
Class/Metaspace grows classloader leak (redeploys, dynamic proxies, scripting engines)
Code grows very rarely an issue; code cache full logs a warning
GC grows with the heap normal
Nothing grows but RSS does native code outside NMT: JNI libraries, glibc arenas (MALLOC_ARENA_MAX)
The OOM kill you cannot see from the heap
When the cgroup kills a JVM, nothing is logged by the JVM - SIGKILL gives it no chance. No heap dump, no OutOfMemoryError. The evidence is in the kernel log and the unit:
# during a cgroup OOM loop, like chapter 4's payments incident:
journalctl -k | grep -i 'out of memory'
... Memory cgroup out of memory: Killed process 5123 (java) total-vm:3412044kB, anon-rss:1391208kB, ...
systemctl status payments | grep -E 'Active|Main PID'
Active: activating (auto-restart) (Result: oom-kill) since ...
Contrast with a heap OOM: the JVM itself throws java.lang.OutOfMemoryError: Java heap space, logs it, writes a heap dump if asked, and exits only if ExitOnOutOfMemoryError is set. Two different failures:
heap OOM JVM throws OutOfMemoryError app log, hprof file exit code 3 (with ExitOnOOM) or limps on
cgroup OOM kernel sends SIGKILL kernel log, memory.events exit 137, Result: oom-kill
"OOMKilled but the heap is only 60%" is always the second kind, and NMT is how you find what grew.