Back to Advanced Topics
Q6Question 6 of 41

What causes memory leaks in Java despite GC, and how do you diagnose one end-to-end?

Key Concepts
  • A Java leak is live but unneeded references the GC cannot free, because the collector only reclaims unreachable objects.
  • Static collections that grow without bound are the classic cause, making everything they hold immortal.
  • ThreadLocal values retained by pooled threads are subtle and dangerous because pooled threads never die.
  • Unclosed resources leak native handles and buffers, so always use try-with-resources.
  • The diagnosis pipeline is jstat (watch old-gen trend), GC logs (confirm reclamation decline), heap dump, MAT leak suspects, and reference chain to GC root.
  • Always compare two heap dumps taken minutes apart and sort by retained size to find the real offender.

Definition

A Java leak is live but unneeded references the GC cannot free, and you diagnose it by watching GC growth, taking a heap dump, and tracing the reference chain from the bloated objects back to the GC root.
  • Logical leak – objects are no longer needed but remain reachable through a live reference chain, so the GC cannot reclaim them.
  • Static collections – a static List or static Map that grows without bound makes everything it holds immortal.
  • ThreadLocal in pools – a pooled thread never dies, so a ThreadLocal value that is never removed stays attached forever.
  • Unclosed resources – streams, connections, and sessions hold native handles; failing to close them keeps buffers alive.
  • Unbounded caches – caches without size limits or TTL grow until the heap is gone.
  • Diagnosis pipeline – jstat → GC logs → heap dump → MAT leak suspects → dominator tree → reference chain to GC root.

Imagine National Bank of India's vault where old files should be shredded once a case closes, but a clerk keeps a master index that still lists every closed case, so the shredding crew (the garbage collector) is not allowed to destroy any of them because the index still points to them. The files are useless but still referenced, so the vault fills up. Diagnosing it is like a forensic audit: watch the vault filling, take a full inventory snapshot, find the suspiciously huge index, and trace the chain from the index back to the clerk who refuses to let go.

Understanding the Concepts

Why do memory leaks happen in a garbage-collected language?

The garbage collector frees only objects that are unreachable from GC roots. A Java leak is never truly unreferenced memory; it is memory you forgot to stop referencing. The objects are useless but still reachable through a live chain.

JAVA
package com.bank.diagnostic;

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;

public class StaticCollectionLeak {
    // WRONG: static list grows without bound
    private static final List<BigDecimal> transactionCache = new ArrayList<>();

    public void recordTransaction(BigDecimal amount) {
        transactionCache.add(amount); // never removed
    }

    public static void main(String[] args) {
        StaticCollectionLeak service = new StaticCollectionLeak();
        for (int i = 0; i < 10_000_000; i++) {
            service.recordTransaction(new BigDecimal("1000.00"));
        }
        System.out.println("Cached: " + transactionCache.size());
        // All 10 million entries are immortal
    }
}
TEXT
Cached: 10000000
// Heap: full of BigDecimal objects that will never be freed

What are the common causes of Java memory leaks?

Six patterns appear repeatedly in production systems.

JAVA
package com.bank.diagnostic;

import java.math.BigDecimal;

public class LeakPatterns {

    // 1. Static collection: immortal references
    private static final java.util.Map<String, BigDecimal> cache
            = new java.util.HashMap<>();

    // 2. Unclosed resource: native handle leak
    public void readStatement(String path) throws Exception {
        // WRONG: stream never closed
        java.io.FileInputStream fis = new java.io.FileInputStream(path);
        // ... use fis ...
    }

    // 3. ThreadLocal in pool: never removed
    private static final ThreadLocal<String> userContext = new ThreadLocal<>();

    public void handleRequest(String userId) {
        userContext.set(userId);
        // WRONG: never call userContext.remove() in finally
    }

    // 4. Listener never unregistered
    // publisher.addListener(this); // but never removeListener(this)

    // 5. Unbounded cache: no size limit or TTL
    // Map<String, BigDecimal> cache = new HashMap<>(); // grows forever

    // 6. Inner class holding outer reference
    public Runnable createTask() {
        // Non-static inner class captures 'this'
        return new Runnable() {
            @Override
            public void run() {
                // Keeps the entire outer object alive
            }
        };
    }
}

How do you fix each leak pattern?

Each pattern has a specific fix.

JAVA
package com.bank.diagnostic;

import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.math.BigDecimal;
import java.time.Duration;

public class LeakFixes {

    // Fix 1: Bounded cache with TTL
    private final Cache<String, BigDecimal> boundedCache = Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(30))
            .build();

    // Fix 2: try-with-resources
    public void readStatement(String path) throws Exception {
        try (java.io.FileInputStream fis = new java.io.FileInputStream(path)) {
            // stream closed automatically
        }
    }

    // Fix 3: ThreadLocal remove in finally
    private static final ThreadLocal<String> userContext = new ThreadLocal<>();

    public void handleRequest(String userId) {
        try {
            userContext.set(userId);
            // ... process request ...
        } finally {
            userContext.remove(); // always remove
        }
    }
}

How do you diagnose a leak with jstat?

Start by watching the old generation usage trend after full GCs. A leak shows post-GC usage rising over time.

BASH
# Watch GC stats every second
jstat -gcutil <pid> 1s

# Healthy: old-gen usage returns to a baseline after full GC
# Leak:    old-gen usage trends upward after each full GC
TEXT
  S0     S1     E      O      M     CCS    YGC    FGC   GCT
  0.00  45.23  67.89  42.15  97.12  95.40   120     3   1.234
  0.00  45.23  12.45  42.15  97.12  95.40   121     3   1.256
  0.00  45.23  89.01  55.67  97.12  95.40   125     4   1.890
  // O (old gen) trending up after FGC: possible leak

How do you use GC logs to confirm a leak?

Enable GC logging and look for full GCs that reclaim less and less.

BASH
java -Xlog:gc*:file=gc.log:time,uptime,level,tags ...

# In the log, look for:
# [Full GC] ... 450M->420M(512M)   ← recovered 30M
# [Full GC] ... 470M->465M(512M)   ← recovered only 5M (shrinking)
# [Full GC] ... 500M->498M(512M)   ← almost nothing recovered

How do you analyze a heap dump with MAT?

Capture a heap dump and open it in Eclipse MAT. The Leak Suspects report and dominator tree point at the real offenders.

BASH
# Capture heap dump
jmap -dump:live,format=b,file=heap.hprof <pid>

# Or automatically on OOM
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/ ...
DIAGRAM
MAT Analysis Steps:
1. Open heap.hprof in Eclipse MAT
2. Run Leak Suspects report
   → "One instance of java.util.ArrayList retained 450 MB"
3. Open Dominator Tree
   → Sort by retained size
4. Right-click → Path to GC Roots → exclude weak references
   → Shows: GC Root → StaticCollectionLeak.transactionCache
     → ArrayList → Object[] → 10,000,000 BigDecimal instances
5. Fix: replace with bounded Caffeine cache

How do you compare two heap dumps?

Take two heap dumps minutes apart and compare to see exactly what is growing.

BASH
# Dump 1
jmap -dump:live,format=b,file=heap1.hprof <pid>

# Wait 5 minutes

# Dump 2
jmap -dump:live,format=b,file=heap2.hprof <pid>

# In MAT: File → Compare → shows new objects by class

What should a senior engineer know?

  • Sort by retained size, not shallow size, to find the real offender rather than a leaf object.
  • Use weak and soft references (WeakHashMap, SoftReference) for caches where entries can be reclaimed under memory pressure.
  • Non-static inner classes and lambdas capturing this hold an implicit reference to the enclosing instance.
  • Classloader leaks in app servers are a form of memory leak that appears as Metaspace growth.
JAVA
package com.bank.diagnostic;

import java.lang.ref.WeakReference;
import java.math.BigDecimal;
import java.util.WeakHashMap;
import java.util.Map;

public class WeakReferenceCache {
    // Entries can be reclaimed under memory pressure
    private final Map<String, WeakReference<BigDecimal>> cache
            = new WeakHashMap<>();

    public void put(String key, BigDecimal value) {
        cache.put(key, new WeakReference<>(value));
    }

    public BigDecimal get(String key) {
        WeakReference<BigDecimal> ref = cache.get(key);
        return ref != null ? ref.get() : null;
    }
}

Visual Overview

DIAGRAM
  End-to-End Leak Diagnosis Pipeline:

  Step 1: jstat
  ┌──────────────────────────────────┐
  │ jstat -gcutil <pid> 1s            │
  │ Watch old-gen % after Full GC     │
  │ Trending up? → possible leak      │
  └──────────────┬───────────────────┘
                 │
  Step 2: GC logs
  ┌──────────────▼───────────────────┐
  │ -Xlog:gc*                         │
  │ Full GCs reclaim less and less?   │
  │ Yes → confirmed leak              │
  └──────────────┬───────────────────┘
                 │
  Step 3: Heap dump
  ┌──────────────▼───────────────────┐
  │ jmap -dump:live,format=b ...      │
  │ Or -XX:+HeapDumpOnOutOfMemoryError│
  └──────────────┬───────────────────┘
                 │
  Step 4: MAT analysis
  ┌──────────────▼───────────────────┐
  │ Leak Suspects → Dominator Tree    │
  │ Path to GC Roots                  │
  │ → static Map holding 10M entries  │
  │ → Fix: bounded cache or remove()  │
  └──────────────────────────────────┘

Key Takeaway

The first thing to clarify is why leaks happen in a garbage-collected language at all. The collector only frees objects that are unreachable from GC roots, so a Java leak is never truly unreferenced memory; it is memory you forgot to stop referencing. The objects are useless but still reachable through a live chain, so the collector cannot reclaim them.

The classic cause is a static collection that grows without bound, making everything it holds immortal. An unbounded cache is the same pattern with good intentions, which is why you should always bound a cache with a size limit or a TTL. ThreadLocal values are subtle and dangerous in thread pools because a pooled thread never dies, so a value you set and forget to remove stays attached forever. Unclosed resources leak native handles, so always use try-with-resources. Unremoved listeners leak because registering gives the publisher a strong reference. And a non-static inner class or a lambda capturing this holds an implicit reference to its enclosing instance.

The end-to-end diagnosis is a disciplined pipeline. Start with jstat to watch the old generation trend after full GCs; a real leak shows post-GC usage rising. Enable GC logging to confirm that full collections reclaim less and less. Capture a heap dump and open it in Eclipse MAT. The Leak Suspects report points at the biggest retained sets, and the dominator tree with path to GC roots shows the exact reference chain keeping the objects alive.

At National Bank of India the statement service leaked because a static map cached every statement forever, and MAT traced thousands of retained byte arrays straight back to that static field, fixed by switching to a bounded Caffeine cache. The senior habit is to take two heap dumps minutes apart and compare them so you see precisely what is growing, and to sort by retained size rather than shallow size so you find the real offender.

Remember one sentence: a Java leak is live but unneeded references the GC cannot free, and you diagnose it by watching GC trends, taking a heap dump, and tracing the reference chain from bloated objects back to the GC root you can then eliminate.

---

Share
Use ← → arrow keys to navigate