Imagine this. The heap analysis comes back clean. Not a single leak is detected. Memory usage patterns also appear normal. Yet, the operations team notices something strange: the Java application is occupying 350 MB of system memory. Wait a minute. You set the heap limit to 256. That’s far beyond the 256 MB heap you configured. So where is the rest of the memory going?
Here everything begins to seem strange. At first glance, all appears fine. Not many developers understand how JVM memory works beyond the heap area. They think it revolves entirely around heap allocation. Yet the JVM process memory footprint often goes way beyond heap numbers seen in profiling tools. In this article, we run an actual test backed by solid data, breaking down each chunk of memory the JVM takes up from the operating system’s view.
The Heap Is Just the Tip of the Iceberg
That 256 MB limit? It only covers object storage, the part tools such as HeapHero examine closely. Memory use goes beyond that space, though. The full Java process grabs extra areas for itself – zones your profiler overlooks entirely. Think thread stacks, code caches, native libraries; none show up in heap dumps. So the JVM process memory footprint can continue to grow quietly even when the heap appears stable.
But the JVM process consumes memory from several other regions that heap profilers simply cannot see:
- Off-Heap Memory: Memory beyond the heap – set aside through ByteBuffer.allocateDirect()
If your off-heap usage grows unchecked, the JVM will eventually throw a java.lang.OutOfMemoryError: Direct buffer memory. For a deep dive into diagnosing these specific leaks, see our full Guide to Direct Buffer OOM. - Thread Stack Memory: A single chunk of memory gets set aside for every thread. This space lives separately, unique to that thread alone. Its stack stays isolated, never mixing with others. Each one holds what it needs without reaching beyond itself
- GC Metadata: A part of the system keeps track of memory cleanup tasks. This behind-the-scenes setup helps manage how unused data is removed. Information stored here guides the process that reclaims space automatically. Hidden details support the routine that frees up resources when needed
- Code Cache: Inside the JVM, compiled machine instructions hang around for reuse. What sticks after compilation is known as cached code. A space set aside keeps translated blocks alive between runs. After translation, raw execution steps remain accessible later. JIT output lingers so repeats run faster next time
- Class Metadata: A closer look at how classes are built shows details about their methods. Structure comes alive when fields reveal their setup inside a class. Information about each piece fits together through these connections.
Class definitions and metadata reside in the Metaspace, a native memory region that can grow until it triggers a java.lang.OutOfMemoryError: Metaspace. Learn how to identify and fix Metaspace leaks caused by dynamic class loading.
Understanding the full memory use of a JVM process means looking beyond the heap alone – every section plays a part.
Experiment Setup
A Java app was built to explore how memory gets assigned, focusing on three separate areas: heap, off-heap, but also thread stacks. Each part got tested on its own, which helped spot fine details when compared. The JVM reports one amount of memory use, yet the operating system sees a very different JVM process memory footprint – this gap surprised us. When split apart like this, those gaps stand out more clearly than before.
Test 1: Heap Allocation
| Code | List<byte[]> heapData = new ArrayList<>(); for (int i = 0; i < 20; i++) { heapData.add(new byte[5 * 1024 * 1024]); } |
| Result | Right now, about 100 MB of active data sits inside the heap. A chunk of memory still holds onto it. That space hasn’t been cleared out yet. Live information lingers there, taking up room. |
| Observe | When the list keeps pointers to those items, it stops cleanup routines from removing them. What shows up in heap snapshots matches that leftover space inside active memory. |
| Conclusion | Every chunk of memory set aside shows up clearly in monitoring software. That becomes the starting point when tracking how much is being used. |
Test 2: Off-Heap (Direct Buffer) Allocation
| Code | List<ByteBuffer> buffers = new ArrayList<>(); for (int i = 0; i < 200; i++) { buffers.add(ByteBuffer.allocateDirect(1024 * 1024)); } |
| Result | A chunk of memory, about 200 MB, sat beyond the Java heap. Outside that main space it lived, not managed by usual rules. This piece didn’t fit inside the standard allocation area. Instead, it resided apart, separate from the rest. |
| Observe | Out here past the -Xmx boundary, things shift. Memory gets set aside in native space instead of piling up on the heap. Tools like HeapHero won’t catch it – no visible climb in heap numbers. Seen only through that narrow lens, everything looks fine. Yet the overall memory footprint climbs steadily without pause. |
| Conclusion | Memory outside the heap, such as direct buffers, often takes up much of what the JVM uses. Applications leaning on NIO or calling ByteBuffer.allocateDirect add to this off-heap load quietly – without showing in usual heap measurements at all. |
Test 3: Thread Stack Allocation
| Code | for (int i = 0; i < 50; i++) { new Thread(() -> { try { Thread.sleep(600000); } catch (Exception e) {} }).start(); } |
| Result | 50 additional threads created, each reserving 1 MB of stack space (-Xss1m). |
| Observe | Right off, JVM internal threads push the tally up to 73. From the first moment a thread forms, its stack grabs space in the OS – no matter if it ever gets used. |
| Conclusion | When apps run many threads – like old-school servlet setups – stack space piles up fast. Heap snapshots do not reveal it. |
Once the application is ready, we compile:
javac JvmMemoryFootprintDemo.java
After that, start it up with Native Memory Tracking turned on, allowing us to check each section afterward
java -Xms256m -Xmx256m -Xss1m -XX:NativeMemoryTracking=summary JvmMemoryFootprintDemo
A lone Java app we built runs three tasks at once. Though all add up in memory use, just one shows up when checking heap space. What sticks around quietly still takes room.
What the Heap Analyzer Sees
Start by capturing the heap before sending it to a profiler. This shows how we created the snapshot while the app was still active.
Step 1: Find the Process ID
Running the app made it possible to spot the JVM process ID – either through jps or Task Manager. For us, that looked like this:
PID: 10644
Step 2: Generate the Heap Dump
A PID available, triggering followed – jcmd initiated a heap dump next
jcmd 10644 GC.heap_dump heap.hprof
A flash of the JVM heap locks into place without delay, stored under the name heap.hprof inside the application directory. Then move it over to software capable of digging through heap details..
Step 3: Analyze with HeapHero
We uploaded heap.hprof to HeapHero for analysis. The report came back entirely normal:
- Heap Memory Used 100.59 MB
- A test went by, catching no memory leaks along the way
- Objects spread across 722 types totaling 15.46k items
- GC Root Count 749

Fig: HeapHero Analysis: Used heap: 100.59 MB, no anomalies detected
Looking closer at the heap, everything seems fine at first glance. Stop here, then perhaps someone will say it’s all clear – matter closed. Yet peeking into how the operating system sees it? That paints another picture entirely.
What the Operating System Sees
When the app runs, using tasklist followed by findstr PID in Windows shows:
java.exe PID Console 1 348,xxx K
Halfway through the system check, the total JVM process memory footprint reaches roughly 348 MB- way beyond HeapHero’s heap count. This amount doesn’t just edge past that number. It triples it, quietly revealing how much lies outside the heap.
| Measurement | Tool | Value |
| Heap Usage | HeapHero | ~100 MB |
| Total Process Memory | OS (tasklist) | ~348 MB |
| Invisible Gap | — | ~248 MB |
About 248 MB simply does not show up at all in the heap profiler. Not broken – just how the JVM normally works. So then: what happens to that missing chunk of memory?
Dissecting the Gap with Native Memory Tracking
Every so often, Java’s NMT feature shows exactly where the JVM keeps its memory, split by type. To see that, run a specific command line check
jcmd PID VM.native_memory summary
This produces a breakdown like the one below, mapped to our experiment results:
| Memory Category | Size | NMT Output (key line) |
| Java Heap | 256 MB | reserved=262144KB, committed=262144KB |
| Off-Heap (Direct Buffers) | 200 MB | Other: malloc=204800KB #200 |
| Thread Stacks (73 threads) | ~73 MB | stack: reserved=74752KB |
| GC Overhead | ~54 MB | GC: reserved=55377KB |
| Code Cache (JIT) | ~8 MB | Code: committed=7608KB |
| Class Metadata | < 1 MB | Class: committed=241KB |
| TOTAL (OS view) | ~348 MB | reserved=1977994KB, committed=550090KB |
Exactly 204,800 KB sits inside The Other group – matching the total footprint of those 200 direct ByteBuffers. Around 73 MB gets held by Thread stacks, spread through 73 running threads. As for garbage collection, it uses about 54 MB on its own, memory none of the usual heap inspectors can reveal.
Why This Matters in Production
This reality comes straight from real runs, not theory. Misreading how much memory the JVM actually uses? That breaks production setups in three clear ways. Load hits, apps crash – simply because free RAM turns out less than assumed. Sudden surges catch everyone off guard, then the operating system shuts down containers. Garbage collection goes off track, and pause times stretch, creeping up when least expected.
- Container OOM kills: Memory limits in Kubernetes work through the operating system. A pod with a cap of 512 MB, plus -Xmx300m set for Java heap, can still vanish due to out-of-memory termination. Off-heap data or thread stacks add up beyond the limit: the heap may sit safe at 300 MB, yet total memory use crosses into danger. Heap alone doesn’t tell the whole story.
- Incorrect capacity planning: Teams pick server sizes using just heap space, then toss in a tiny extra margin. They forget 200 MB or more slipping outside the heap, hiding in native layers. Garbage collection costs get ignored too, tucked away behind simpler math. Space vanishes fast once those extras pile up unnoticed.
- False-negative leak investigations: Turns out heap looks okay, which wraps up the ticket. Yet trouble stirs in quiet corners. Memory outside grows hungry – leftover DirectByteBuffers linger, piling up unseen. Failure hits hard later, out of nowhere. The system just stops.
How to Monitor Total JVM Process Memory Footprint
Heap analysis tools are valuable, but they need to be complemented with OS-level and NMT-level monitoring:
- Enable NMT in production with -XX:NativeMemoryTracking=summary (low overhead) and poll jcmd VM.native_memory summary periodically to track category-level changes over time.
- Monitor OS RSS via /proc/<pid>/status on Linux or tasklist on Windows. If RSS grows but heap stays flat, the culprit is off-heap or thread memory.
- Track direct buffers explicitly via the java.nio BufferPoolMXBean, which exposes count and capacity of direct and mapped buffers at runtime.
- Use heap analyzers for the heap portion only: tools like HeapHero excel at detecting heap leaks, object waste, and GC root issues, but they should be one layer of a multi-layer monitoring strategy.
Conclusion
That snapshot in the heap dump only tells half. Tests ran using 100 MB inside heap, yet another 200 MB tucked into direct buffers along with fifty active threads – revealed different numbers. System counters climbed near 348 MB consumed. Almost 248 MB lived beyond what heap tools ever counted. Finding what slips past tools fixated on heap takes effort. Where memory goes unnoticed depends on where eyes turn away.
A fraction too high, the memory breakdown from jcmd’s NMT revealed exactly which segments held what: direct off-heap buffers locked down 200 MB, thread stacks quietly used 73 MB, with garbage collection tacking on an additional 54 MB. Pinpointing usage there doesn’t count as optional during active Java runs – accuracy here draws a line between stable operations and abrupt container failures.Odd when things falter even though heap appears stable. Total memory might whisper truths heap ignores. Garbage collection reports can lie flat while real usage paints another picture.

Share your Thoughts!