Native Memory Tracking: why your JVM uses more memory than -Xmx

A JVM started with -Xmx2g that occupies 3 GB of resident memory is not necessarily broken. The heap is only one consumer. Thread stacks, the JIT code cache, class metadata, garbage collector structures, direct buffers and the JVM's own allocations all add up. Native Memory Tracking (NMT) shows where the rest goes.
Enabling NMT
NMT must be enabled at startup; it cannot be switched on later. The java reference lists three modes: off (the default), summary (usage per JVM subsystem) and detail (additionally per call site and per virtual memory region).
$ java -XX:NativeMemoryTracking=summary -jar app.jar
It is not free. The troubleshooting guide states that enabling NMT results in a 5 to 10 percent JVM performance drop and that it adds two machine words to every malloc allocation as a header. Use summary on a canary or test instance rather than everywhere, and detail only while investigating.
Reading the summary
$ jcmd <pid> VM.native_memory summary scale=MB
The output starts with a total and then one block per category, each with reserved and committed amounts. The shape (abbreviated, values elided):
Native Memory Tracking:
Total: reserved=…MB, committed=…MB
- Java Heap (reserved=…MB, committed=…MB)
- Class (reserved=…MB, committed=…MB)
(classes #…)
- Thread (reserved=…MB, committed=…MB)
(thread #…)
- Code (reserved=…MB, committed=…MB)
- GC (reserved=…MB, committed=…MB)
- Compiler (reserved=…MB, committed=…MB)
- Internal (reserved=…MB, committed=…MB)
- Other (reserved=…MB, committed=…MB)
- Symbol (reserved=…MB, committed=…MB)
- Native Memory Tracking (reserved=…MB, committed=…MB)
...
Category names and the exact list vary between releases. The main ones:
| Category | What it covers | Related guide |
|---|---|---|
| Java Heap | The heap itself | Tuning G1 |
| Class | Class metadata (Metaspace and compressed class space) | Metaspace |
| Thread | Thread stacks and per-thread structures, with a thread count | Thread dumps |
| Code | JIT code cache | Code cache |
| GC | Collector data structures such as remembered sets and mark bitmaps | Choosing a collector |
| Compiler | Memory used by the JIT compilers while compiling | |
| Internal / Other | Miscellaneous JVM allocations; on recent releases, direct buffers allocated through the JDK appear under one of these | Direct buffers |
| Symbol | The symbol table (names and signatures) |
Reserved versus committed
Reserved is address space the JVM has set aside; it costs almost nothing physically. Committed is memory the JVM has asked the OS to back. For budgeting, committed is the number that matters. Even committed memory is not necessarily resident: pages that were committed but never touched may not be in RSS yet. The heap's committed size is usually far above its touched size until it fills, unless -XX:+AlwaysPreTouch is used.
Finding growth with a baseline
A single summary tells you where memory is. A diff tells you where it is going. The troubleshooting guide's workflow:
# after warm-up
$ jcmd <pid> VM.native_memory baseline
# hours later, under similar load
$ jcmd <pid> VM.native_memory summary.diff scale=MB
Each category now shows its change since the baseline, with + or -. A category that grows steadily is the lead to follow: Thread growing with the thread count means threads are being created and not ended; Class growing means classes are not unloading; Other or Internal growing may be direct buffers. The guide notes that a small leak can take a while to show up, so leave enough time between baseline and diff.
With detail mode, detail.diff narrows growth down to call sites inside the JVM.
What NMT cannot see
NMT tracks memory allocated by the JVM. The troubleshooting guide is explicit that it does not track allocations by non-JVM code. If RSS is much larger than NMT's committed total, the difference is usually one of these:
- Native libraries loaded through JNI or the FFM API (compression, cryptography, database drivers, image processing) that allocate with
mallocdirectly. - malloc arenas. The glibc allocator keeps several arenas to reduce contention between threads and does not always return freed memory to the OS. Many threads allocating natively can leave a large amount of freed-but-retained memory. The glibc environment variable
MALLOC_ARENA_MAXlimits the number of arenas; see the mallopt(3) manual page. The JDK'sjcmdalso offersSystem.trim_native_heap(Linux only) to ask the C library to release free memory, andSystem.native_heap_infoto print the C library's own statistics. - Memory-mapped files mapped by the application, which count in RSS when pages are touched.
For allocations outside the JVM, use OS tools: pmap -x <pid> lists mappings with their resident size, and the process's /proc/<pid>/smaps gives per-mapping detail.
The Thread category in practice
Thread memory is often the easiest category to act on. Each platform thread has a stack whose maximum size is set by -Xss (or -XX:ThreadStackSize); the java reference gives a default of 1024 KB on Linux/x64 and 2048 KB on Linux/AArch64. NMT's Thread line shows the thread count next to reserved and committed memory. Stacks are reserved at full size but committed as they are used, so committed memory per thread is usually well below the maximum.
If the thread count itself keeps rising, the cause is in the application: a pool without an upper bound, a library creating a thread per connection, or threads that are started and never end. A thread dump grouped by thread name shows which pool is growing. Reducing -Xss is rarely the right fix and risks StackOverflowError in deep call chains.
Printing NMT data at exit
For batch jobs and tests, the JVM can print NMT statistics when it exits:
-XX:NativeMemoryTracking=summary -XX:+UnlockDiagnosticVMOptions -XX:+PrintNMTStatistics
Putting numbers into a budget
NMT is the best tool to build a realistic memory budget for a container: run the service under production-like load with summary, take the committed values per category after warm-up and at peak, and add a margin for what NMT does not see. The JVM in containers explains how to turn that budget into heap and limit settings. Measured budgets are also the first step in cutting memory costs, covered in the AI memory squeeze; for services that sit around AI models, see keeping AI products affordable.
Sources
- Java Platform Troubleshooting Guide: Diagnostic Tools, Native Memory Tracking
- The java command (NativeMemoryTracking)
- The jcmd command (VM.native_memory, System.trim_native_heap, System.native_heap_info)
- mallopt(3), pmap(1), proc(5)
MemoryDirect buffer memory and MaxDirectMemorySize
Why the error appears while the heap looks fine, what the default limit really is, and how to measure direct buffers.
ProductionThe JVM in containers
How limits are detected, what MaxRAMPercentage really sets, and a budget that keeps the OOM killer away.
MemoryMetaspace and class loader leaks
Class metadata outside the heap: the flags, the jcmd views, and how redeploys leak class loaders.