Your JVM Is Lying to You: The Java Off-Heap Memory Leak That Kills Quietly

The issue  began with the problem of the application slowing down and ultimately crashing. It was strange, however, because the heap appeared normal. Initially, we presumed it was a conventional heap issue. Regarding GC logs, they were stable and the heap usage was within limits defined by -Xmx. There were also no apparent leaks from the heap dumps. Overall, the JVM logs seemed stable.Things were not adding up. Overall process memory usage was growing. Without a Java heap space error, the operating system eventually terminated the container. While the heap memory usage was stable, process memory usage grew steadily.

It hit us that the problem was not on the heap.What we suspected was an Off-Heap Memory Leak. Native memory leaks outside of the Java heap. Standard memory analysis techniques cannot fully analyze this region, making it one of the hardest leaks to find. An Off-Heap Memory Leak can create process memory leaks while the heap appears healthy.

Where Does a Java Off-Heap Memory Leak Hide?

Funny how it slips by at first. Even when heap dumps appear normal, memory climbs. In many cases, this points to a hidden Java off-heap memory leak. Garbage collection reports stay steady. Still, the footprint expands slowly. Only later does someone spot it. Outside the heap, metaspace lurks beyond GC reach. Direct byte buffers exist separate from main memory management. Memory mapped files operate off the radar of typical checks. Native libraries claim space without involving garbage collection. Thread stacks grow independently, unseen by common monitors. The code cache fills up quietly, bypassing normal sweeps. Cleanup processes ignore these areas completely. Most Java devs think otherwise mistakenly. A crack appears where pressure gathers. Once limits break  spill follows, silent at first then collapse arrives too fast to stop. The truth hides elsewhere. Not once did the heap cause trouble.

Symptoms of a Java Off-Heap Memory Leak

The Off-heap memory leaks are hard to catch because the heap looks fine. The leak happens in native memory, where standard tools don’t reach. We recommend that you take a look at the OutOfMemoryError types for a much more deeper insight.

1. Heap Usage Stays Stable

The Java heap remains within the configured -Xmx limit. GC logs look normal, heap dumps appear clean, and there are no visible retention spikes.

This creates a false sense of safety, because the leak is happening outside GC-managed memory.

2. Process RSS Keeps Increasing

At the operating system level, memory usage (RSS / Working Set) steadily increases. This growth is independent of heap utilization. In containerized environments, the process eventually exceeds its memory limit and gets terminated by the OOM killer, often without a clear Java heap space error.

3. No Major GC Activity

Garbage collection metrics remain stable:

  • No excessive Full GC cycles
  • No abnormal pause times
  • Allocation rates appear consistent

Since the leak occurs in native memory, the GC has nothing to reclaim.

4. Sudden JVM Crash Without Heap OOM

The application crashes without a typical java.lang.OutOfMemoryError: Java heap space.

Instead, you may see:

  • OutOfMemoryError: Direct buffer memory
  • OutOfMemoryError: Metaspace
  • Or no JVM error at all (silent container termination)

This mismatch between heap health and process failure is the strongest indicator of an off-heap memory leak.

Most Common Java Off-Heap Memory Leak Patterns

Weeks pass before the pattern clicks. A Java off-heap memory leak often lingers in four clear spots. Once noticed, they repeat without fail. Each one wears a different face, yet somehow feels familiar. Their real form stays unchanged- plain, exact, nothing added, nothing missing.

1. Unreleased Direct ByteBuffers

Direct buffers live outside the heap. When you call ByteBuffer.allocateDirect(), only a small wrapper object is stored in the heap -the actual memory is allocated natively. That native memory stays allocated as long as the DirectByteBuffer object is still reachable. Even though a Cleaner eventually frees it, there is no guarantee about when that happens. Things go sideways when new direct buffers pop up quicker than old ones vanish- worse still if tucked into static caches or hanging around inside lasting collections. As long as a reference exists, the JVM cannot reclaim the underlying native memory. In practice a Java off-heap memory leak shows up as stable heap usage but steadily increasing process RSS. A solid heap sits there, but off-heap space climbs anyway.

private static final List<ByteBuffer> cache = new ArrayList<>();
public void process(int sizeMB) {
ByteBuffer buf = ByteBuffer.allocateDirect(sizeMB * 1024 * 1024);
cache.add(buf);
}

Who holds the keys to object lifetimes? Faster results come when you step in instead of relying on automated responses. Instead of depending on background cleanup, try managing a shared set of reusable buffers. Timing matters- know when things let go. Predictability comes from structure, not chance. A tool like that is actually out there. Take Netty’s buffer pool, built on reference counting- fits right into situations just like this one.

2. Metaspace Bloat from Dynamic Class Loading

Usually, there is no limit on how much Metaspace can grow. A fresh class shows up now and then- triggered by CGLIB, maybe, or Groovy scripts, reflection-based proxies, even live-reload utilities- and sticks around nonstop till its entire class loader goes away. When apps lean too much on Spring AOP, scripts that run during execution, or OSGi setups, risks go up. Notice how Metaspace climbs without dropping- even after reboots. As a baseline, define XX:MaxMetaspaceSize to trigger an early out-of-memory error rather than let the system quietly terminate the task.

3. JNI and Native Library Leaks

 A Java off-heap memory leak can also originate in native code, where memory stays hidden from the garbage collector. Should a problem occur, and a native library fail to release memory, that chunk remains trapped indefinitely. The JVM lacks any means to detect these leaks, let alone resolve them. Tools such as Valgrind or AddressSanitizer can spot some of these errors when testing, though they are seldom active in production. Close scrutiny of each possible exit path in low-level routines proves most effective at preventing trouble.

4. Runaway Thread Stack Growth

A single stack comes with every thread. When apps run hundreds of threads- typical under blocking I/O setups- those stacks quickly pile up in memory. Tasks stuck running inside a thread pool, or an executor left alive indefinitely, keep eating native memory without triggering any warning from the heap.

// Executor leak — submitted tasks block forever, threads accumulate
ExecutorService pool = Executors.newCachedThreadPool();
pool.submit(() -> {
while (true) { /* waiting for an event that never arrives */ }
});

Odd how the thread count just keeps rising, like there’s no cause at all. Though things seem normal up front, watching memory patterns matters more than most think. When fresh sessions pop up out of nowhere, trouble’s likely brewing beneath. A sudden jump in threads, yet no increase in users- something’s definitely caught in a loop. Healthy setups don’t spawn extra threads unless pushed by purpose.

How to Detect a Java Off-Heap Memory Leak

 Old ways fade as new answers appear. Near the core, how you do things shifts without notice.

1. Using Native Memory Tracking (NMT)

To diagnose a Java off-heap memory leak, the JVM provides a built-in facility called Native Memory Tracking (NMT). Enable it at startup:

-XX:NativeMemoryTracking=detail

Then query it while the app is running:

jcmd <pid> VM.native_memory detail

Not just the obvious parts, but chunks such as heap, classes, and threads show up divided. Instead of staying together, code areas, garbage collection jobs, and internal zones land in separate spots. Bits that remain also take individual places. This splitting occurs straight from how NMT handles breakdowns. Not every tool from beyond does the job right- some hide what they should reveal. Here though, stretching out becomes obvious. Fastest rise? That comes into view only like this.

- Java Heap (reserved=262144KB, committed=10240KB)
(mmap: reserved=262144KB, committed=10240KB)
- Other (reserved=512000KB, committed=512000KB)
(malloc=512000KB #10) (at peak)
NMT detail output ‘Other’ category reached 500 MB independently of heap usage.
The #10 count maps exactly to the 10 buffers allocated in our demo code.

2. Using OS-Level Monitoring (RSS, VmRSS)

See how much RAM your JVM takes up, versus the -Xmx setting along with expected Metaspace usage. On Linux, peek into /proc//status and find VmRSS- it shows real consumption. For Windows, launch Process Explorer and examine a process’s working set number. If overall memory remains far beyond heap caps for no obvious cause, you’re probably dealing with unseen off-heap leaks lurking nearby.

3. Correlating Heap Dumps with Off-Heap Growth

Not every clue lives inside a heap dump- still, look there for Java objects tied to native memory. Think of DirectByteBuffer instances. Or class loader chains. Thread objects too. Start by tracing those. A test helped show what happens when direct buffers leak. Every two seconds, the system reserved 50MB blocks. These chunks were kept alive through firm references. Garbage collection had no chance to remove them. While heap usage held steady near 256MB, native memory kept growing. It shot beyond 500MB without slowing.

Something odd showed up-NMT flagged the ‘Other’ group getting larger. The heap dump sat there, waiting. After that came hours of going through things by hand.

Just small sets of data. Hardly any memory stuck around. The garbage collector stayed calm, the heap did not wobble. One after another, we unfolded the retention paths, narrowing down to DirectByteBuffer items. Yet every instance stood alone, disconnected. What made one stand out? Who truly caused the Java off-heap memory leak?

One day a voice broke through. Noticing the struggle. Watching threads slip away like sand. A thought formed slowly. It wasn’t about single instances anymore. Grouping the DirectByteBuffers revealed hidden ties. Patterns emerged only when seen together. The real clue lived in how they held on collectively.

It hit us then- we’d used HeapHero for something like this earlier. What stood out? Instead of showing memory buffers per individual instance, it reveals them in clusters. Patterns emerge. Retention chains link across cases, suddenly clear.

The heap dump should be checked using tools available there A file went up. Right after, the image snapped into view.

Not just random chunks anymore, those DirectByteBuffer objects got organized by HeapHero. Grouped together, they pointed back to a lone static list. A single field inside one class kept hold of all buffers at once. Everything else looked tidy in memory. Yet that one field stayed alive quietly, guarding native space unreachable by garbage collection.

A change happened. It went from noticing fast-growing memory to spotting one stubborn static field refusing to let go of its buffers. One moment offered a hint. The next brought resolution. Clues point. Fixes stop the leak.

Fig: HeapHero shows a nearly empty heap  931.55 KB. NMT shows where the rest went: 500 MB off-heap.

Reproducing a Java Off-Heap Memory Leak (Demo Code)

Here’s the full code we used. Run it, watch native memory climb, and take the heap dump the pattern becomes undeniable. https://heaphero.io/

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
public class DirectBufferLeak {
private static final List<ByteBuffer> leak = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
long pid = ProcessHandle.current().pid();
System.out.println("Use this pid for jcmd: " + pid);
for (int i = 1; i <= 10; i++) {
ByteBuffer buf = ByteBuffer.allocateDirect(50 * 1024 * 1024); // 50 MB
leak.add(buf);
System.out.printf("Iteration %d | Direct memory held: ~%d MB%n", i, i * 50);
Thread.sleep(2000);
}
System.out.println("\nLeak complete. Take the heap dump now:");
System.out.println(" jcmd " + pid + " GC.heap_dump C:\\heapdump\\leak.hprof");
System.out.println(" jcmd " + pid + " VM.native_memory detail > C:\\heapdump\\nmt.txt");
Thread.sleep(Long.MAX_VALUE);
}
}

Compile and run with:

Compile and run with:
javac DirectBufferLeak.java
java -XX:MaxDirectMemorySize=1g ^
-XX:NativeMemoryTracking=detail ^
-Xmx256m ^
DirectBufferLeak

When the program prints “Leak complete”, open a second terminal and run the jcmd commands it prints. Upload leak.hprof to HeapHero  and compare what the heap analysis shows against the NMT output. The contrast is the whole point.

How to Prevent Java Off-Heap Memory Leaks in Production

When a live off-heap memory leak shows up in production, hours start slipping away. Tension builds fast- teams scramble under growing weight. Noticing these four patterns cuts risk by a large margin: 

  • Start by drawing clear limits right away. Define memory caps using -XX:MaxMetaspaceSize along with –XX:MaxDirectMemorySize. Crashing fast with an OOM beats vanishing mid-night due to system termination. Predictable failure beats mystery downtime every time.
  • Someone must own a direct buffer. Pick the allocator, assign the deallocator never trust garbage collection for native memory cleanup.
  • Watch threads while checking memory. Connect thread pool numbers to your dashboard visuals. When thread totals climb in an otherwise steady setup, it often means some task is stuck indefinitely.
  • Profile native allocations in staging. async-profiler with malloc tracing enabled can surface native allocation hotspots before they ever reach production.

Why Heap Dumps Alone Miss Off-Heap Memory Leaks

A heap dump only captures memory managed by the garbage collector. It shows objects allocated with new and tracked within the Java heap. Anything allocated outside that boundary is simply not included.

This is where confusion begins.

Off-heap memory growth does not appear in heap analysis. Direct ByteBuffer allocations, Metaspace, JIT code cache, thread stacks, and JNI/native allocations all live outside GC-managed memory. A heap dump can look perfectly clean while native memory is steadily increasing.

That’s why a stable heap does not automatically mean a stable JVM.

Garbage collection may be functioning normally. Pause times remain flat. Heap usage stays within -Xmx. Yet from the operating system’s perspective, total process memory (RSS) keeps rising.

If RSS grows while heap usage remains flat, it is a strong indicator of off-heap memory pressure. On Linux, this can be verified through top or /proc/<pid>/status (VmRSS). In containerized environments, this discrepancy often leads to abrupt termination by the OOM killer.

Diagnosing this class of issue requires stepping outside the heap view.

Enable Native Memory Tracking (NMT):

-XX:NativeMemoryTracking=summary

Then query it at runtime:

jcmd <pid> VM.native_memory

For deeper investigation, OS-level tools such as pmap, jemalloc, or gperftools can help identify native allocation hotspots.

The key lesson is simple: a clean heap dump does not prove the absence of a memory leak. It only proves the heap is healthy. The rest of the JVM’s memory must be examined separately.

Wrapping Up: Making Off-Heap Memory Visible

Outside the heap, problems sneak in where eyes don’t usually go. Tools show clean heaps. Garbage collection stats appear normal. Yet the program keeps grabbing system memory, bit by bit, until it breaks -a classic Java off-heap memory leak unfolding silently.

Once you spot it, the trend stands out clearly. Process RSS climbs while heap usage doesn’t budge, leading to a frozen or crashed JVM. With Native Memory Tracking, we get categories of memory use laid bare. Tools at the OS level show what’s actually happening under the hood. When a utility flags growing DirectByteBuffers without manual digging, heap dumps tie it all together- linking Java objects directly to the native memory they pin down. 

Apart from the heap, the JVM uses more memory elsewhere. Metaspace lives outside heap limits. Direct buffers operate independently. Thread stacks grow without asking heap permission. Native libraries load where they need to. None of these care about -Xmx settings. 

Start by weaving visibility right into those areas. Draw clear lines around what matters. Get familiar with the instruments on hand. This is how a forty-eight-hour scramble shrinks to half an hour of clarity.

Share your Thoughts!

Up ↑

Index

Discover more from HeapHero – Java & Android Heap Dump Analyzer

Subscribe now to keep reading and get access to the full archive.

Continue reading