Why this matters
- Nearly every Java leak in practice comes from one of about six patterns, so recognising them is faster than any tool.
- Reading a heap dump is the one skill that turns "the service restarts every six hours" into a specific line of code.
- This is where garbage collection, collections and the reference types all meet actual production behaviour.
What a leak looks like
The signature in a GC log or in jstat is distinctive: old generation occupancy climbing steadily, never
returning to a baseline after a full collection, with full collections becoming more frequent and longer
until the application spends most of its time collecting and then fails.
The patterns that cause almost all of them
A static collection that only grows
// Wrong: nothing ever removes from this, and statics live as long as the class
public class AuditLog {
private static final List<Event> events = new ArrayList<>();
public static void record(Event event) {
events.add(event);
}
}
A static field is a GC root. Everything it reaches is permanently live. This is the single most common
Java leak.
// Right: bound it, or do not keep it in memory at all
private static final Deque<Event> recent = new ArrayDeque<>();
public static synchronized void record(Event event) {
recent.addLast(event);
if (recent.size() > 1_000) recent.removeFirst();
}
An unbounded cache
// Wrong: every key ever seen is retained forever
private final Map<String, Report> cache = new HashMap<>();
A cache without an eviction policy is a leak with a helpful name. Use a bounded LinkedHashMap with LRU
eviction, or a cache library with size and time limits.
private final Map<String, Report> cache =
Collections.synchronizedMap(new LinkedHashMap<>(256, 0.75f, true) {
@Override protected boolean removeEldestEntry(Map.Entry<String, Report> eldest) {
return size() > 1_000;
}
});
A listener that is never unregistered
// Wrong: the publisher holds the subscriber forever
publisher.addListener(this); // and nothing ever calls removeListener
The publisher usually outlives the subscriber. Every registration not paired with a removal keeps the subscriber — and its entire reachable graph — alive.
A ThreadLocal in a pooled thread
// Wrong: pooled threads are never destroyed, so the value is never released
private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>();
void handle(Request request) {
CONTEXT.set(new Context(request));
process();
// no remove()
}
ThreadLocal values are cleared when the thread dies. A thread-pool thread does not die, so the value
survives for the pool's lifetime, and the next request inherits stale data as well as leaking memory.
void handle(Request request) {
CONTEXT.set(new Context(request));
try {
process();
} finally {
CONTEXT.remove(); // mandatory in pooled or container-managed threads
}
}
A mutable key in a hash map
List<String> key = new ArrayList<>(List.of("a"));
map.put(key, value);
key.add("b"); // the hash changed; the entry is unreachable but still present
This is a genuine leak as well as a lookup bug: the entry cannot be found, so it cannot be removed either.
An inner class outliving its enclosing object
// Wrong: a non-static inner class holds an implicit reference to Outer
class Outer {
class Task implements Runnable { // keeps the whole Outer alive
@Override public void run() { }
}
void submit(ExecutorService pool) {
pool.submit(new Task()); // Outer survives as long as the task is queued
}
}
Make it static and pass only what it needs. The same applies to a lambda that captures this.
| Aspect | Pattern | Fix |
|---|---|---|
| static collection that grows | A static field is a GC root | Bound the size, or do not keep it in memory |
| Unbounded cache | Every key retained forever | LRU eviction, or a cache library with limits |
| Unregistered listener | Publisher outlives subscriber | Pair every add with a remove |
| ThreadLocal in a pool | Pooled threads never die | remove() in a finally block |
| Mutable map key | Entry stranded in the wrong bucket | Immutable keys only |
| Non-static inner class | Implicit outer reference | Make it static; pass what it needs |
| Unclosed resources | Native memory and file handles | try-with-resources |
static collection that grows
PatternA static field is a GC rootFixBound the size, or do not keep it in memoryUnbounded cache
PatternEvery key retained foreverFixLRU eviction, or a cache library with limitsUnregistered listener
PatternPublisher outlives subscriberFixPair every add with a removeThreadLocal in a pool
PatternPooled threads never dieFixremove() in a finally blockMutable map key
PatternEntry stranded in the wrong bucketFixImmutable keys onlyNon-static inner class
PatternImplicit outer referenceFixMake it static; pass what it needsUnclosed resources
PatternNative memory and file handlesFixtry-with-resources
Six patterns and a resource check cover the overwhelming majority of real Java leaks.
Capturing a heap dump
# Automatically, when the heap is exhausted — set this in every production service
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app
# On demand. live=true collects first, so only reachable objects appear.
jcmd <pid> GC.heap_dump /tmp/app.hprof
jmap -dump:live,format=b,file=/tmp/app.hprof <pid>
# A histogram without a full dump — often enough to spot the culprit
jcmd <pid> GC.class_histogram
Analysing it
Open the dump in Eclipse MAT, VisualVM or JDK Mission Control. The method is the same in all three.
What to look at, in order
- The dominator tree — which objects keep the most memory alive. The top entry is almost always the answer.
- Retained size, not shallow size. Shallow size is the object itself; retained size is everything that would be freed if it went. A 48-byte
HashMapwith a 400 MB retained size is your leak. - The path to GC roots for a suspect object — the exact chain of references keeping it alive. Read it from the root downwards.
- The histogram grouped by class. A million instances of one class stands out immediately.
- MAT's leak suspects report, which automates the first two steps and is right surprisingly often.
A worked reading of a path to GC roots:
class com.example.AuditLog <- a static field: a GC root
events : java.util.ArrayList <- retained 1.2 GB
elementData : java.lang.Object[4194304]
[0] : com.example.Event
payload : byte[1024]
The chain names the field, the collection and the element type. That is enough to find the code without guessing.
Live diagnosis
# Is memory growing? Watch the O column over several minutes.
jstat -gcutil <pid> 5000
# Which classes dominate right now?
jcmd <pid> GC.class_histogram
# What are the threads doing? Useful when a leak comes with a hang.
jcmd <pid> Thread.print
# Native memory, when the heap looks fine but the process keeps growing
jcmd <pid> VM.native_memory summary # needs -XX:NativeMemoryTracking=summary
Preventing them
- Bound every cache and every queue. Unbounded is the default and it is the wrong one.
- Pair every registration with a removal, ideally through
AutoCloseableso the compiler reminds you. - Always
remove()aThreadLocalin afinallyblock when the thread is pooled. - Use immutable map keys.
- Default nested classes to
static. - Use try-with-resources for anything closeable.
- Set
-XX:+HeapDumpOnOutOfMemoryErrorand GC logging in production before you need them.
Common misreadings
- "Java cannot leak memory because it has a garbage collector." The collector reclaims the unreachable. Objects you still reference and no longer need are a leak it cannot help with.
- "Growing memory means a leak." Growth that plateaus is a working set. A leak never returns to a baseline.
- "
System.gc()will confirm a leak." It forces an expensive collection and tells you little. Take a dump instead. - "Shallow size identifies the culprit." Retained size does. A tiny object can retain gigabytes.
- "
WeakHashMapprevents leaks automatically." Only the keys are weak. A value referencing its key defeats it entirely. - "A leak always shows as
OutOfMemoryError: Java heap space." Metaspace, native memory and thread exhaustion all have their own variants.
Quick recall
Everything you need if you only revisit this box.
- A Java leak is an unwanted reference, not forgotten memory. The collector is behaving correctly.
- The signature is old-generation occupancy that never returns to a baseline after a full collection.
- Six patterns cover nearly all of them: growing static collections, unbounded caches, unregistered listeners,
ThreadLocalin pooled threads, mutable map keys, non-static inner classes — plus unclosed resources. ThreadLocal.remove()in afinallyblock is mandatory on pooled threads.- Capture with
jcmd GC.heap_dump, or automatically via-XX:+HeapDumpOnOutOfMemoryError. - Analyse by dominator tree → retained size → path to GC roots. Retained size, not shallow size, finds the culprit.
- Stable heap but growing process means native memory: direct buffers, class loaders, unclosed handles, or thread stacks.
Test yourself
Answer these before moving on — recall is what makes it stick.