Memory

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

Memory · Published 2026-10-23

Server racks with bundles of yellow network cables next to a power distribution cabinet in a data centre
The heap is one consumer among many: threads, code, metadata and GC structures also take memory. Photo: D Coetzee, Flickr, CC0.

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:

CategoryWhat it coversRelated guide
Java HeapThe heap itselfTuning G1
ClassClass metadata (Metaspace and compressed class space)Metaspace
ThreadThread stacks and per-thread structures, with a thread countThread dumps
CodeJIT code cacheCode cache
GCCollector data structures such as remembered sets and mark bitmapsChoosing a collector
CompilerMemory used by the JIT compilers while compiling
Internal / OtherMiscellaneous JVM allocations; on recent releases, direct buffers allocated through the JDK appear under one of theseDirect buffers
SymbolThe 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:

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