Definition
- 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 Listorstatic Mapthat grows without bound makes everything it holds immortal. - ThreadLocal in pools – a pooled thread never dies, so a
ThreadLocalvalue 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.
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
}
}
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.
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.
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.
# 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
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.
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.
# Capture heap dump
jmap -dump:live,format=b,file=heap.hprof <pid>
# Or automatically on OOM
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/ ...
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.
# 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
thishold an implicit reference to the enclosing instance. - Classloader leaks in app servers are a form of memory leak that appears as Metaspace growth.
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
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.
---