Contents
- What is a Memory Leak in Java?
- Common Leak Patterns
- Detecting Leaks with JVM Tools
- Generating Heap Dumps
- Reading GC Logs for Leak Signals
- Eclipse MAT — Leak Suspects & Dominator Tree
- Fixing Common Leaks
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.
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.
| Pattern | How the leak occurs | Fix |
| Static collections | static List / Map accumulates entries across requests that are never removed | Remove entries explicitly; use WeakHashMap or a bounded cache |
| Unremoved listeners / callbacks | Object registers itself as a listener but never deregisters; holder retains a reference | Deregister in @PreDestroy / close(); use WeakReference listeners |
| ThreadLocal not removed | Value set in a request thread and never cleared; thread pool reuses the thread | Always call threadLocal.remove() in a finally block |
| Unclosed resources | Connection, InputStream, or JDBC ResultSet not closed; underlying byte buffers remain allocated | Use try-with-resources for every closeable |
| Inner-class capturing outer | Anonymous inner class or lambda captures the enclosing instance, preventing GC of a large object graph | Use static nested classes or extract the needed data into a local variable |
| Class-loader leak | Dynamic classloaders (scripting, plugin frameworks) are created but never GCd because a static reference holds the class | Ensure no static references cross the classloader boundary; use OSGi or module system isolation |
| Interned strings | String.intern() on unlimited user input fills the string pool (pre-Java 8: PermGen; Java 8+: heap) | Stop interning unbounded input; use explicit maps instead |
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.
# 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.
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:
# 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.
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.
# 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
# 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 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
# 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
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:
// ❌ 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
}
}
// ❌ 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
}
}
}
// ❌ 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;
});
}
}