PrepZone Logo
PrepZone

Finding Memory Leaks

The handful of patterns that cause nearly every Java leak, and how to confirm one from a heap dump.

Read these first

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

Static collectionGrows forever, never cleared
Unclosed resourceStreams and connections held open
Listener not removedPublisher keeps the subscriber alive
ThreadLocal in a poolPooled thread never dies, value stays
A Java leak is never forgotten free(); it is always an object still reachable from something long-lived. Find the owner and the leak explains itself.

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

Java
// 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.

Java
// 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

Java
// 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.

Java
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

Java
// 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

Java
// 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.

Java
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

Java
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

Java
// 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.

AspectPatternFix
static collection that growsA static field is a GC rootBound the size, or do not keep it in memory
Unbounded cacheEvery key retained foreverLRU eviction, or a cache library with limits
Unregistered listenerPublisher outlives subscriberPair every add with a remove
ThreadLocal in a poolPooled threads never dieremove() in a finally block
Mutable map keyEntry stranded in the wrong bucketImmutable keys only
Non-static inner classImplicit outer referenceMake it static; pass what it needs
Unclosed resourcesNative memory and file handlestry-with-resources
  • static collection that grows

    PatternA static field is a GC root
    FixBound the size, or do not keep it in memory
  • Unbounded cache

    PatternEvery key retained forever
    FixLRU eviction, or a cache library with limits
  • Unregistered listener

    PatternPublisher outlives subscriber
    FixPair every add with a remove
  • ThreadLocal in a pool

    PatternPooled threads never die
    Fixremove() in a finally block
  • Mutable map key

    PatternEntry stranded in the wrong bucket
    FixImmutable keys only
  • Non-static inner class

    PatternImplicit outer reference
    FixMake it static; pass what it needs
  • Unclosed resources

    PatternNative memory and file handles
    Fixtry-with-resources

Six patterns and a resource check cover the overwhelming majority of real Java leaks.

Capturing a heap dump

Java
# 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 HashMap with 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:

Java
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

Java
# 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 AutoCloseable so the compiler reminds you.
  • Always remove() a ThreadLocal in a finally block when the thread is pooled.
  • Use immutable map keys.
  • Default nested classes to static.
  • Use try-with-resources for anything closeable.
  • Set -XX:+HeapDumpOnOutOfMemoryError and 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.
  • "WeakHashMap prevents 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, ThreadLocal in pooled threads, mutable map keys, non-static inner classes — plus unclosed resources.
  • ThreadLocal.remove() in a finally block 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.