Java Memory Leaks: Detection, Heap Dumps & Eclipse MAT Analysis

A Java memory leak occurs when objects are kept reachable by references even though the application no longer needs them, preventing the garbage collector from reclaiming their memory. Over time the heap fills, GC pressure builds, response times spike, and the JVM eventually crashes with OutOfMemoryError. This article shows you the most common leak patterns, how to capture heap dumps, and how to find the culprit with Eclipse Memory Analyzer (MAT).

Share

Contents

What is a Memory Leak in Java?

Java manages memory automatically, but the garbage collector can only reclaim objects that are unreachable: objects with no live reference chain from a GC root. A leak exists when objects remain reachable through references that are never cleared, even though they are logically dead.

  • GC roots: static fields, local variables on active call stacks, JNI references, class loaders. Any object reachable from a root survives GC.
  • Heap growth pattern: old-gen (or G1 heap) occupancy rises after each GC cycle without coming back down. This is the definitive signal of a leak.
  • Symptoms: increasing GC frequency, longer GC pauses, degraded response latency, eventual java.lang.OutOfMemoryError: Java heap space.
A memory leak is distinct from a memory hog. A hog uses a lot of memory intentionally (large caches, big buffers). A leak accumulates memory that the application thinks it released but hasn't.

Common Leak Patterns

The same patterns cause the vast majority of production Java memory leaks. Knowing them by name lets you spot them quickly during code review and investigation.

PatternHow the leak occursFix
Static collectionsstatic List / Map accumulates entries across requests that are never removedRemove entries explicitly; use WeakHashMap or a bounded cache
Unremoved listeners / callbacksObject registers itself as a listener but never deregisters; holder retains a referenceDeregister in @PreDestroy / close(); use WeakReference listeners
ThreadLocal not removedValue set in a request thread and never cleared; thread pool reuses the threadAlways call threadLocal.remove() in a finally block
Unclosed resourcesConnection, InputStream, or JDBC ResultSet not closed; underlying byte buffers remain allocatedUse try-with-resources for every closeable
Inner-class capturing outerAnonymous inner class or lambda captures the enclosing instance, preventing GC of a large object graphUse static nested classes or extract the needed data into a local variable
Class-loader leakDynamic classloaders (scripting, plugin frameworks) are created but never GCd because a static reference holds the classEnsure no static references cross the classloader boundary; use OSGi or module system isolation
Interned stringsString.intern() on unlimited user input fills the string pool (pre-Java 8: PermGen; Java 8+: heap)Stop interning unbounded input; use explicit maps instead

Detecting Leaks with JVM Tools

Before taking a full heap dump (which can pause the JVM for seconds on large heaps), use lightweight JVM tools to confirm that a leak is present and identify which class is growing.

Shell
# 1. Find the JVM process id
jps -lv

# 2. Print heap summary — look for rising old-gen occupancy
jcmd <pid> GC.heap_info

# 3. Class histogram — shows live object counts and bytes per class
# Run this 3-5 minutes apart and diff the outputs
jcmd <pid> GC.class_histogram | head -30

# 4. Trigger a GC and then print histogram (removes short-lived objects first)
jcmd <pid> GC.run
jcmd <pid> GC.class_histogram | head -30

# 5. Watch heap usage over time with jstat (1-second intervals, 60 readings)
jstat -gcutil <pid> 1000 60
# Columns: S0 S1 E(den) O(ld) M(etaspace) YGC YGCT FGC FGCT GCT

A growing O (Old generation) column in jstat output, combined with increasing FGC (Full GC) count, is a reliable leak signal. Next step: capture a heap dump to find what is holding the memory.

Generating Heap Dumps

A heap dump is a snapshot of all live objects and their reference graph at a point in time. It is the primary input for Eclipse MAT and other memory analysers. There are three ways to capture one:

Shell
# Option 1: jmap — live process dump (safe for production; brief STW on large heaps)
jmap -dump:format=b,file=heap.hprof <pid>

# Option 2: jcmd (preferred — same result, more features)
jcmd <pid> GC.heap_dump /tmp/heap.hprof

# Option 3: Automatic on OOM — add to JVM flags at startup
java -XX:+HeapDumpOnOutOfMemoryError \
     -XX:HeapDumpPath=/var/dumps/ \
     -jar app.jar

Tips for handling heap dumps:

  • On a 4 GB heap, the .hprof file will be roughly 2–4 GB. Ensure the target path has enough disk space.
  • To minimise pause on large heaps, dump to a ramdisk (/dev/shm on Linux) and copy afterwards.
  • Use -XX:+HeapDumpOnOutOfMemoryError in production as a safety net: it has zero runtime cost until triggered.
  • In Kubernetes, dump to an emptyDir volume and stream it out with kubectl cp.

Reading GC Logs for Leak Signals

Before investing time in a heap dump, GC logs can confirm whether a leak is growing. The key signal is old-gen occupancy after a full GC: if it increases across consecutive full GCs, something is holding memory.

Shell
# Enable GC logging (add to startup flags)
java -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m \
     -jar app.jar
Text
# G1GC log — look at heap occupancy after GC (the "after" value climbing across cycles)
[2.345s][info][gc] GC(12) Pause Full (Ergonomics)
[2.347s][info][gc,heap] GC(12)   Old regions: 820->790   # ← old-gen barely drops
[2.347s][info][gc] GC(12) Pause Full (Ergonomics) 3278M->3260M(4096M) 2.131ms

# Leak confirmed: old-gen does not recover meaningfully after full GC
# Same pattern 10 minutes later:
[620s][info][gc] GC(89) Pause Full (Ergonomics) 3950M->3900M(4096M) 4.892ms
# Heap before and after are both much higher → objects are accumulating
Upload GC log files to GCEasy (gcease.io) for automatic analysis: it identifies pause time trends, allocation rates, and promotion failures visually without manual log parsing.

Eclipse MAT: Leak Suspects & Dominator Tree

Eclipse Memory Analyzer (MAT) is the most powerful open-source heap dump analyser. Open the .hprof file and use the following views to find the leak root:

  • Leak Suspects Report: MAT's automated analysis. It identifies objects that keep more than 10% of the heap alive. Start here.
  • Dominator Tree: shows objects ranked by retained heap (everything that would be freed if this object were removed). The top entries reveal what is holding the most memory.
  • Histogram: shows object counts and shallow size per class. Sort by "Retained Heap" to see which class hierarchy is responsible.
  • Path to GC Roots: right-click any suspicious object → Path to GC Roots → exclude weak/soft/phantom references. This shows exactly which code path is preventing collection.
  • OQL (Object Query Language): SQL-like query for heap objects. Example: SELECT * FROM java.util.HashMap WHERE size() > 50000
Shell
# Download and run MAT (headless mode for CI/large heaps)
./ParseHeapDump.sh heap.hprof org.eclipse.mat.api:suspects org.eclipse.mat.api:overview

# The above generates an HTML report with the Leak Suspects analysis

Fixing Common Leaks

Once MAT identifies the leaking class and the reference path holding it, apply the appropriate fix. Here are the three most frequently encountered patterns with before/after code:

Java
// ❌ LEAK: static cache grows forever, entries never removed
public class UserCache {
    private static final Map<Long, User> CACHE = new HashMap<>();

    public static void put(Long id, User user) {
        CACHE.put(id, user);          // no eviction — leak
    }
}

// ✅ FIX: bounded cache with automatic eviction (Caffeine)
public class UserCache {
    private static final Cache<Long, User> CACHE = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(10, TimeUnit.MINUTES)
            .build();

    public static void put(Long id, User user) {
        CACHE.put(id, user);          // evicts oldest when full
    }
}
Java
// ❌ LEAK: ThreadLocal set in request but never removed
// Thread returns to pool carrying the old value
public class RequestContext {
    private static final ThreadLocal<String> TENANT = new ThreadLocal<>();

    public static void set(String tenantId) {
        TENANT.set(tenantId);
    }

    public static String get() {
        return TENANT.get();
    }
}

// ✅ FIX: always remove in a finally block (or use a Filter/Interceptor)
public class RequestFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        try {
            RequestContext.set(req.getParameter("X-Tenant-Id"));
            chain.doFilter(req, res);
        } finally {
            RequestContext.remove();     // critical — prevents leak in thread pools
        }
    }
}
Java
// ❌ LEAK: listener registered but never removed
public class EventService {
    private final List<EventListener> listeners = new ArrayList<>();

    public void register(EventListener listener) {
        listeners.add(listener);         // strong reference, never cleared
    }
}

// ✅ FIX: store weak references so listeners can be GCd when no longer used
public class EventService {
    private final List<WeakReference<EventListener>> listeners = new CopyOnWriteArrayList<>();

    public void register(EventListener listener) {
        listeners.add(new WeakReference<>(listener));
    }

    public void dispatch(Event event) {
        listeners.removeIf(ref -> {
            EventListener l = ref.get();
            if (l == null) return true;  // GCd — prune
            l.onEvent(event);
            return false;
        });
    }
}