Riadh Mnasri
← Back to blog
4 min read

Investigating a JVM stall with a heap dump: capture, read, resolve

Before generating a heap dump, the first question to settle is often skipped: is this actually a memory problem? A component that "stalls" can just as easily be a deadlock between threads (no excessive memory, just threads waiting on each other) as a genuine memory drift. Those are two different diagnoses, with two different tools.

First, the right tool for the right symptom

SymptomRight toolWhat it reveals
Threads stuck, CPU idle, no progressThread dump (jcmd <pid> Thread.print)Every thread's state and which locks it's waiting on
Memory growing, GC firing more and more often, possibly an OutOfMemoryErrorHeap dumpThe heap's contents, object by object, with their references
CPU at 100% with no apparent stallCPU profiling (async-profiler, JFR)Where CPU time is actually being spent

A heap dump on a deadlock shows nothing useful: memory isn't the problem, the threads are. That's the most common trap for anyone new to these tools: reaching for a heap dump by reflex on any "it stopped responding."

Generating a heap dump

Three common ways, depending on the context:

# On demand, on a running process (jcmd is the recommended tool today)
jcmd <pid> GC.heap_dump /path/heap.hprof

# Capture only live objects (excludes what the GC would reclaim anyway)
jcmd <pid> GC.heap_dump -all=false /path/heap-live.hprof

# Automatically at the moment of an OutOfMemoryError, no manual step needed
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/heap-oom.hprof

The HeapDumpOnOutOfMemoryError flag deserves to be enabled by default in production: a dump taken after the fact, once the service has restarted, no longer captures the state that caused the incident. The one moment this dump has real value is exactly when the JVM realizes it can't continue.

Reading the dump: shallow size vs retained size

Opening a dump with Eclipse Memory Analyzer (MAT) shows a list of objects sortable by size, and that's exactly where misreading begins. Two metrics coexist, and confusing them leads straight to a wrong diagnosis:

MetricWhat it measuresTrap if ignored
Shallow sizeThe size of the object itself, without what it referencesAn empty HashMap has a tiny shallow size, even while retaining gigabytes of data through its entries
Retained sizeThe total memory freed if this object became unreachable (the object plus everything it exclusively retains)This is the metric that actually reveals where the problem is

Sorting by shallow size almost always surfaces generic types (char[], HashMap$Node) without saying which specific one is the actual problem. Sorting by retained size, via MAT's Dominator Tree, surfaces the root object that's actually holding the memory, regardless of its own size.

A concrete case: the cache that never stops growing

MAT's "Leak Suspects" report often points to a single dominator responsible for most of the heap. In a frequent case, that dominator is a static HashMap used as an application cache, with no size limit or expiration policy. The "Path to GC Roots" feature lets you trace the exact chain of references keeping the garbage collector from freeing it: typically, a static field on a Spring singleton, referenced from the classloader itself, so never eligible for GC as long as the application runs.

// The trap: a cache that grows indefinitely
private static final Map<String, ExpensiveObject> cache = new HashMap<>();

// A direction for the fix: explicitly bound both size and lifetime
private static final Cache<String, ExpensiveObject> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(30))
    .build();

The HashMap isn't at fault by itself: it's the missing eviction policy that turns a legitimate cache into a slow memory leak, invisible for weeks until the number of distinct keys outgrows what the heap can absorb.

Don't confuse a leak with a normal spike

A single heap dump, taken at one point in time, says nothing about the trend: high memory usage right after a traffic spike can be perfectly normal if it drops back down after a full GC pass. The signal of a real leak isn't a full heap, it's a memory floor (usage right after a full GC) that keeps rising from one dump to the next, across several measurements spread over time, never a single isolated snapshot.

What a heap dump doesn't replace

A heap dump identifies where the memory is, not why the code accumulated it with that intent. Once the reference chain is found, the fix remains a matter of understanding the domain: was this cache really meant to keep everything, or did the author simply forget to add an expiration policy when writing it. The dump gives the exact location of the problem; it never gives the business justification for whether the data was supposed to stay there or not.