Comprehensive guide to identifying, diagnosing, and fixing Java memory leaks. Learn heap dump analysis, GC patterns, and production debugging techniques.
JOptimize Team
Memory leaks in Java are deceptive. The garbage collector hides them until your application crashes with OutOfMemoryError at 3 AM on Sunday. By then, your heap is 8GB and growing, and you have no idea which object is holding references.
This guide covers how to detect leaks before they become catastrophic, diagnose them accurately, and fix them permanently.
A memory leak occurs when an application allocates memory but never releases it back to the heap. The garbage collector cannot reclaim this memory because something still holds a reference to the object.
Unlike C/C++, Java's GC prevents segfaults and dangling pointers. But Java cannot prevent logical leaks: objects that should be eligible for garbage collection but remain referenced.
Example: the static collection leak
public class Cache { private static List<String> cache = new ArrayList<>(); public static void add(String item) { cache.add(item); // Items never removed } } // Client code while (true) { Cache.add(UUID.randomUUID().toString()); // Leaks memory indefinitely }
Each iteration adds a string to the static list. The list never shrinks. Heap grows until OOM.
Static fields persist for the lifetime of the JVM. A static collection that grows without bounds is a memory leak by design.
// BAD public class ListenerRegistry { private static final List<EventListener> listeners = new ArrayList<>(); // Never shrinks public static void register(EventListener l) { listeners.add(l); // No removal method } } // GOOD public class ListenerRegistry { private static final List<EventListener> listeners = new ArrayList<>(); public static void register(EventListener l) { listeners.add(l); } public static void unregister(EventListener l) { listeners.remove(l); // Cleanup path } }
A listener registered but never unregistered holds a reference to the component. If the component goes out of scope but the listener stays in a global registry, the component cannot be garbage collected.
// BAD public class UserService { public UserService(EventBus eventBus) { eventBus.subscribe(this); // No unsubscribe in destructor or close method } } // On shutdown: // UserService instances are created, used, then discarded. // But EventBus still holds references — they never become eligible for GC. // GOOD public class UserService implements AutoCloseable { private final EventBus eventBus; public UserService(EventBus eventBus) { this.eventBus = eventBus; eventBus.subscribe(this); } @Override public void close() { eventBus.unsubscribe(this); // Cleanup } } // Usage try (UserService service = new UserService(eventBus)) { // Use service } // close() called automatically
A database connection or file stream that is never closed remains open indefinitely.
// BAD public List<String> readFile(String path) throws IOException { FileInputStream fis = new FileInputStream(path); BufferedReader reader = new BufferedReader(new InputStreamReader(fis)); List<String> lines = new ArrayList<>(); String line; while ((line = reader.readLine()) != null) { lines.add(line); } // If exception occurs, reader and fis are never closed reader.close(); return lines; } // GOOD — try-with-resources public List<String> readFile(String path) throws IOException { List<String> lines = new ArrayList<>(); try (BufferedReader reader = new BufferedReader(new FileReader(path))) { String line; while ((line = reader.readLine()) != null) { lines.add(line); } } // Automatically closes return lines; }
A parent holds a reference to a child, and the child holds a reference back to the parent. If the parent is static or long-lived, both remain in memory.
// BAD public class Parent { private Child child = new Child(this); // Child holds reference to parent } public class Child { private Parent parent; public Child(Parent parent) { this.parent = parent; // Parent -> Child -> Parent cycle } } // Even if you lose your reference to parent, Parent and Child remain mutually referenced.
Java's GC detects cycles and collects them. But if the parent is static or cached long-term, both remain alive.
ThreadLocal stores a value per thread. If a thread is pooled (e.g., in Tomcat's request thread pool), the ThreadLocal value persists across requests.
// BAD public class RequestContext { private static final ThreadLocal<User> user = new ThreadLocal<>(); public static void setUser(User u) { user.set(u); } public static User getUser() { return user.get(); } } // Servlet code public class UserServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) { User user = getUserFromRequest(req); RequestContext.setUser(user); // ... handle request // RequestContext.user is never cleared! // Thread returns to pool and handles next request. // Next request's handler sees the previous user in ThreadLocal. } } // GOOD public class UserServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) { User user = getUserFromRequest(req); RequestContext.setUser(user); try { // ... handle request } finally { RequestContext.clear(); // Always clear } } }
Start your application with heap dump on OOM:
java -Xmx2g -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/log/heaps \ -jar app.jar
Then monitor heap growth:
jmap -heap <pid> | grep "Heap Size"
Run this every few hours. If heap grows monotonically and never stabilizes, you have a leak.
JConsole (built-in to JDK):
jconsole
Connect to your running JVM. Go to the Memory tab.
VisualVM (more detailed):
jvisualvm
Take two heap snapshots 1 hour apart. Compare object counts. If a class has 10x more instances than before, that class is leaking.
Enable GC logging:
java -Xmx2g \ -Xlog:gc*:file=gc.log:time,uptime,level,tags \ -jar app.jar
Analyze the log:
# Look for patterns # Full GC frequency increasing = leak # Heap after GC increasing = leak grep "Heap after"
Once you confirm a leak, the next step is identifying which object is leaking.
jmap -dump:live,format=b,file=heap.hprof <pid>
The live flag tells the JVM to run GC first, then dump only live objects. This filters out garbage and reveals what's actually being held.
Download Eclipse MAT from eclipse.org/mat. Open heap.hprof.
Run the "Leak Suspects" report. MAT analyzes the heap and reports the most likely culprits — objects holding large amounts of memory.
Inspect the Dominator Tree. This shows object hierarchy by memory size. Expand the top objects and look for:
Right-click a suspicious object → "Show in Dominator Tree" → expand parent references.
Look for the chain:
ArrayList (1 GB) ↑ held by MyService.cache (static field) ↑ held by MyService class loader
Now you know: MyService.cache is leaking.
public class Cache { private static List<Item> cache = new ArrayList<>(); public static void add(Item item) { cache.add(item); } public static void cleanup() { cache.clear(); // Add this } } // In shutdown hook or Spring's @PreDestroy public class CacheManager { @PreDestroy public void shutdown() { Cache.cleanup(); } }
// Instead of HashMap, use WeakHashMap private static Map<String, ExpensiveObject> cache = new WeakHashMap<>(); // If no other reference to the key exists, the entry is automatically removed cache.put("expensive", new ExpensiveObject());
WeakHashMap entries are garbage collected when the key is no longer referenced elsewhere.
public class BoundedCache<K, V> { private final Map<K, V> cache = new LinkedHashMap<K, V>(16, 0.75f, true) { protected boolean removeEldestEntry(Map.Entry eldest) { return size() > 10000; // Max 10,000 entries } }; }
// Old style — prone to leaks Connection conn = dataSource.getConnection(); try { // use conn } finally { conn.close(); // Easy to forget } // New style — guaranteed cleanup try (Connection conn = dataSource.getConnection()) { // use conn } // Always closes
public class ComponentWithListeners { private final EventBus eventBus; private final MyListener listener; public ComponentWithListeners(EventBus eventBus) { this.eventBus = eventBus; this.listener = new MyListener(); eventBus.subscribe(listener); } public void cleanup() { eventBus.unsubscribe(listener); // Cleanup } } // In Spring, use @PreDestroy @Component public class ComponentWithListeners { @PreDestroy public void cleanup() { eventBus.unsubscribe(listener); } }
Memory leaks happen when references prevent objects from being garbage collected. Common culprits: static collections, unregistered listeners, unclosed resources, and ThreadLocal variables. Detect leaks by monitoring heap growth and GC patterns. Diagnose with heap dumps and Eclipse MAT. Fix by cleaning up references, using bounded caches, and try-with-resources.
Heap dump analysis is manual and time-consuming. For complex applications with hundreds of classes and millions of objects, it's easy to miss the actual leak.
Heap dump analysis is slow. JOptimize detects memory leak patterns in your code before they leak in production.
Fix leaks before production. Analyze with JOptimize.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.