Diagnosing a memory leak with a heap dump

A leaking JVM application doesn’t crash right away. It slows down first. The GC runs more and more often. Pauses get longer. CPU climbs for no clear reason. Then one day java.lang.OutOfMemoryError: Java heap space shows up in the logs and the process dies. Between the start and the end, memory has climbed and never really come back down. That’s the symptom of a leak.

On the JVM, the word “leak” has a precise meaning. The garbage collector frees any object that is no longer reachable from a root. So a leak isn’t an object the GC forgot to collect. It’s an object the code keeps referenced when it’s no longer needed. The GC does its job correctly, but it isn’t allowed to collect, because a reference still points to the object. To find that reference, you need the full contents of the heap: every object present, and what retains them. That’s what a heap dump holds.

A heap dump is the contents of the heap at a given moment: every object, its class, its size, and its references to other objects. A thread dump gives you the state of the threads; a heap dump gives you the state of memory. With the right tool, it tells you within minutes which structure has grown and which reference chain keeps it from being freed.

Spotting a leak before you even dump

Before capturing anything, look at the right curve. Heap usage goes up and down all the time: it climbs between two GCs, then drops at each collection. What matters isn’t the peak. It’s the low point after a major GC (a full GC). On a healthy application, that low point stays stable over time.

If that low point creeps up a notch at every full GC, live memory is accumulating. That’s a leak, not just load. The difference is clear: an application under load goes high but comes back down low; a leaking application never comes all the way back down.

You can see this trend in any tool that tracks the heap: JConsole, VisualVM, or a jvm_memory_used_bytes metric on a dashboard. The right reflex is to ignore the top of the curve and watch the trough right after each full GC, to see whether it drifts upward over several hours.

Capturing the dump

There are a few ways to do this, depending on whether the JVM is still alive.

At crash time, automatically. This is the option to enable everywhere, in production as much as anywhere. The JVM writes a dump just before throwing the OutOfMemoryError, which is the exact moment the leak is most visible:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/dumps

No cost during normal operation, and it’s the only reliable way to get a dump taken at the moment of the problem rather than hours later.

On a live JVM, on demand. jcmd is the tool the OpenJDK team now points to as the entry point for diagnostics. It triggers a dump from the PID:

jcmd 9850 GC.heap_dump /var/dumps/heap.hprof

The older tool, jmap, still works and does the same thing:

jmap -dump:live,format=b,file=/var/dumps/heap.hprof 9850

The live keyword matters. It forces a full GC before the dump and writes only reachable objects. That’s what you want for a leak. Without it, the dump also contains objects that are already dead but not yet collected, and they cloud the analysis.

Two things to know before running this in production. First, taking a dump imposes a stop-the-world for the duration of the write, which can last several seconds on a large heap. Second, the file produced is roughly the size of the used heap: an 8 GB heap gives a file of several gigabytes. Plan for the disk space, and a mounted volume if you’re in a container.

A quick first pass: the histogram

Before opening a multi-gigabyte file, a histogram often already points the way. It counts reachable objects per class, without writing a full dump:

jmap -histo:live 9850 | head -20
 num     #instances         #bytes  class name
----------------------------------------------
   1:       5012440      160398080  byte[]
   2:       4821880      154300160  java.util.HashMap$Node
   3:       4810210      115445040  java.lang.String
   4:       1203470       48138800  com.example.Session

More than a million reachable Session objects on a service with a few hundred users is already a strong lead. The histogram tells you what is piling up. It doesn’t tell you who retains it. For that, you need the full dump and an analysis tool.

The reading grid: shallow heap and retained heap

Analyzing a heap dump rests on one distinction.

The shallow heap is the size of the object alone: its own fields, not counting what it references. A HashMap has a shallow heap of a few dozen bytes, whatever its contents.

The retained heap is the memory that would be freed if you collected this object: the object itself plus everything it keeps reachable on its own. The retained heap of a full HashMap covers all its nodes, all its keys and all its values, as long as no other path retains them.

It’s the retained heap that points to the culprit. An object with a tiny shallow heap but a huge retained heap is the point from which, if it were collected, all that memory would come back. Sorting objects by descending retained heap puts the suspects at the top of the list.

The dominator tree

The tool that makes this concrete is the dominator tree. An object X dominates an object Y if every path from a GC root to Y goes through X. In other words, if X is collected, Y becomes unreachable and goes at the next GC. An object’s retained heap is the sum of what it dominates.

Eclipse MAT (Memory Analyzer Tool) builds this dominator tree and sorts it by retained heap. At the top are the few objects that, on their own, hold the largest share of the heap. On a leak, the result is clear: one object, often a collection or a cache, whose retained heap is 60, 80, sometimes 90% of the whole heap. The rest of the investigation is understanding why it grows and why it isn’t freed.

Tracing the culprit: the path to GC roots

Knowing which object is heavy isn’t enough. You need to know why the GC doesn’t collect it. An object survives as long as there’s an unbroken reference chain from a GC root. A GC root is a local variable on a thread’s stack, a static field, a loaded class, a live thread, or a JNI reference. As long as that chain exists, the object and everything it dominates stay in memory.

MAT’s decisive feature is the path to GC roots (on a whole class, merge shortest paths to GC roots). It starts from the large object and traces back to the root that retains it, showing the exact chain of fields it goes through. That’s where the leak appears: the chain shows that all these Session objects are retained by a static Map sessions field that’s never cleared.

One detail that saves time: exclude weak and soft references from the search. An object retained only by a weak reference (WeakReference) or a soft reference (SoftReference) doesn’t sustain a leak: the GC can reclaim it, at the next collection for a weak, and when memory runs low for a soft. MAT offers this exclusion in one click, and it rules out plenty of false positives, since some caches rely on exactly that kind of reference.

A full example, end to end

Take the Session example again, because it comes up often. The typical scenario: a web service that works in testing, then dies in production after a few days, always with an OutOfMemoryError on the heap.

First step, the curve. On the dashboard, the heap’s low point after full GC rises slowly but steadily, day after day. The leak is confirmed. Second step, the histogram with jmap -histo:live. At the top, more than a million Session, and HashMap$Node in the millions. We have the what.

Third step, trigger a dump with jcmd ... GC.heap_dump, copy the .hprof to an analysis machine, and open it in MAT. The dominator tree shows a single object at the top: a HashMap that on its own retains 85% of the heap. Fourth step, right-click, path to GC roots. The chain shows the map is a static field of a SessionRegistry class. Each session is registered on connect and never removed on disconnect. The map only grows.

The fix is two lines: remove the session on close, and add a safety expiry. Without the heap dump, the problem could have taken several days. With it, half an hour between capture and culprit.

The leaks you run into all the time

After a few diagnoses, the same culprits keep coming back.

The tools

Eclipse MAT is the reference for analysis. It reads a .hprof file, builds the dominator tree, provides the path to GC roots, and even generates an automatic report, Leak Suspects, that flags objects with abnormal retained heap right away. For tricky cases, its OQL query language lets you query the dump like a database, for example “all HashMap objects with more than 100,000 entries.”

VisualVM also opens .hprof files and is enough for a quick analysis; it’s handy for combining live monitoring and a dump. One thing to plan for, true of both: MAT and VisualVM index the dump in memory to explore it. Opening an 8 GB dump needs a well-provisioned analysis machine. Do it on a dedicated workstation, not on the production server already under pressure.

A few pitfalls

Going further

The heap dump answers the question “who retains the memory and why doesn’t the GC free it.” When the question becomes “why are GC pauses getting longer” or “is it really a leak or just a poorly tuned GC,” you need to look at garbage collector tuning: see Tuning the JVM garbage collector. And when the application is stuck rather than full, look at the threads instead: Reading a thread dump with jstack.