Back to Blog
javamemory-leaksdebuggingperformanceproduction

Java Memory Leaks: Detection, Diagnosis, and Fixes in Production

Comprehensive guide to identifying, diagnosing, and fixing Java memory leaks. Learn heap dump analysis, GC patterns, and production debugging techniques.

J

JOptimize Team

June 11, 2026· 12 min read

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.


What is a Memory Leak in Java?

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.


Common Causes of Memory Leaks

1. Static Collections Without Cleanup

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 } }

2. Event Listeners and Callbacks Not Unregistered

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

3. Unclosed Resources (Streams, Connections)

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

4. Circular References with Long-Lived Parents

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.

5. ThreadLocal Variables Not Cleared

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 } } }

Detecting Memory Leaks Before Crash

1. Monitor Heap Usage Over Time

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.

2. Use JConsole or VisualVM

JConsole (built-in to JDK):

jconsole

Connect to your running JVM. Go to the Memory tab.

  • Heap grows and never shrinks after GC → leak
  • Heap grows and stabilizes after GC → normal
  • Sawtooth pattern (grows, drops, grows, drops) → normal
  • Monotonic growth → leak

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.

3. Monitor GC Activity

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"

Diagnosing the Leak

Once you confirm a leak, the next step is identifying which object is leaking.

Step 1: Trigger a Heap Dump

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.

Step 2: Open the Heap Dump in Eclipse MAT

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:

  • Collections (ArrayList, HashMap) with millions of items
  • Byte arrays that shouldn't be there
  • Thread-related objects (thread stacks holding references)

Step 3: Trace References

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.


Common Fixes

Fix 1: Add a Cleanup Method

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

Fix 2: Use Weak References for Caches

// 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.

Fix 3: Set Bounded Cache Sizes

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

Fix 4: Use Try-With-Resources

// 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

Fix 5: Unregister Listeners

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

Prevention Best Practices

  1. Use try-with-resources for streams, connections, and any AutoCloseable
  2. Avoid static collections unless you have a strict cleanup strategy
  3. Unregister listeners and callbacks in a shutdown method or @PreDestroy
  4. Clear ThreadLocal in a finally block or within a context manager
  5. Set maximum size limits on all caches (use Caffeine with maximumSize)
  6. Monitor heap growth in production — alert when heap grows above baseline
  7. Take regular heap snapshots and compare object counts over time

Summary

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.


Faster Memory Leak Detection with JOptimize

Heap dump analysis is slow. JOptimize detects memory leak patterns in your code before they leak in production.

  • IntelliJ Plugin — analyze code for leak patterns (unclosed resources, static collections, ThreadLocal): Install JOptimize
  • Web Dashboard — full code scan for memory, performance, and security issues: Analyze your project free →

Fix leaks before production. Analyze with JOptimize.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.