Contents

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.

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.

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

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:

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:

# 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; }); } }