Overview
A memory leak in Java occurs when objects are no longer needed by the application but remain reachable through some reference chain, preventing the garbage collector from reclaiming them. Unlike C/C++, Java’s GC handles deallocation automatically — but it can only collect what is unreachable. If your code inadvertently holds a reference, the object lives forever. Over time, these accumulate and cause OutOfMemoryError, degraded performance, and unpredictable pauses.
Leaks fall into three broad categories: object retention (things you forgot to dereference), classloader and native leaks (memory outside the heap), and resource management leaks (OS-level handles).
Pattern 1 — Static field retention
What happens
Static fields live for the entire lifetime of the ClassLoader that loaded the class — which in most apps means the lifetime of the JVM process. Any object referenced by a static field is permanently rooted and can never be GC’d.
Example
public class SessionCache {
// Danger: grows forever, never evicted
private static final Map<String, UserSession> cache = new HashMap<>();
public static void addSession(String id, UserSession session) {
cache.put(id, session);
}
}
Fix
Use a cache with eviction policy (WeakHashMap, Guava CacheBuilder, or Caffeine), or explicitly remove stale entries. If the field must be static, ensure entries are removed when they are no longer needed.
private static final Cache<String, UserSession> cache = Caffeine.newBuilder()
.expireAfterAccess(30, TimeUnit.MINUTES)
.maximumSize(1000)
.build();
Pattern 2 — Unbounded collections / caches
What happens
A List, Map, or Set that is populated but never trimmed or evicted. Common in in-memory caches, event history buffers, and log queues that accumulate entries indefinitely.
Example
List<Event> eventLog = new ArrayList<>();
public void onEvent(Event e) {
eventLog.add(e); // Never removed — grows forever
}
Fix
Cap the size explicitly, or use a bounded data structure:
// Ring buffer: drops oldest when full
Deque<Event> eventLog = new ArrayDeque<>();
public void onEvent(Event e) {
if (eventLog.size() >= 1000) eventLog.pollFirst();
eventLog.addLast(e);
}
Pattern 3 — Listeners and callbacks not deregistered
What happens
When you register an object as a listener/observer on a long-lived publisher (event bus, UI component, Spring context), the publisher holds a reference to your listener. If the listener’s owner is discarded without deregistering, neither the owner nor the listener can be collected.
Example
public class ReportWidget {
public ReportWidget(EventBus bus) {
bus.register(this); // bus now holds a reference to this widget
}
// No deregistration — widget leaks when discarded
}
Fix
Always deregister in a close() or lifecycle callback:
public class ReportWidget implements AutoCloseable {
private final EventBus bus;
public ReportWidget(EventBus bus) {
this.bus = bus;
bus.register(this);
}
@Override
public void close() {
bus.unregister(this);
}
}
Alternatively, use WeakReference-based listener lists so the GC can collect dead listeners automatically.
Pattern 4 — Non-static inner classes
What happens
A non-static inner class (and anonymous classes) holds an implicit reference to its enclosing outer class instance. If the inner class instance outlives the outer instance (e.g., it’s posted to a background thread or stored in a static field), the outer class cannot be collected.
Example
public class Activity {
private final byte[] largeData = new byte[10 * 1024 * 1024];
public Runnable createTask() {
// Anonymous Runnable implicitly holds a reference to Activity.this
return new Runnable() {
@Override public void run() { /* some work */ }
};
}
}
Fix
Use a static nested class (no implicit outer reference) and pass any needed data explicitly:
private static class MyTask implements Runnable {
private final WeakReference<Activity> ref;
MyTask(Activity a) { this.ref = new WeakReference<>(a); }
@Override public void run() {
Activity a = ref.get();
if (a != null) { /* use a */ }
}
}
Pattern 5 — Classloader leaks
What happens
In OSGi containers, Java EE app servers, and frameworks that use custom ClassLoaders (like Spring in a servlet container), dynamically loaded classes are unloaded only when their ClassLoader becomes unreachable. If any live object (static field, thread-local, JVM internal table) references a class loaded by that loader, the entire loader — and all classes it loaded — stays in Metaspace.
Symptoms
OutOfMemoryError: Metaspace after repeated hot-deploys, even though heap is healthy.
Common causes
- A static field on a framework class references an application class
- A JDBC driver registered with
DriverManagerand never deregistered on undeploy - A thread spawned by the application that outlives the redeployment
Fix
- Deregister JDBC drivers in
ServletContextListener.contextDestroyed() - Avoid static references to application-class objects in shared libraries
- Use
-XX:MaxMetaspaceSizeto surface the leak early rather than letting native memory grow silently
Pattern 6 — ThreadLocal misuse in thread pools
What happens
ThreadLocal stores a value per thread. In a thread pool, threads are reused across requests. If a ThreadLocal value is set for a request but never removed, the value persists on the thread and is reused by the next request — both a leak and a correctness bug.
Example
private static final ThreadLocal<Connection> conn = new ThreadLocal<>();
public void handleRequest() {
conn.set(openConnection());
// ... processing ...
// Missing: conn.remove() — connection stays on thread forever
}
Fix
Always call remove() in a finally block:
public void handleRequest() {
conn.set(openConnection());
try {
// ... processing ...
} finally {
conn.get().close();
conn.remove(); // Critical: clears the thread-local value
}
}
Pattern 7 — Off-heap and native memory leaks
What happens
ByteBuffer.allocateDirect() allocates memory outside the Java heap, in native memory. This memory is freed when the ByteBuffer object is GC’d — but GC is triggered by heap pressure, not native memory pressure. If you allocate many direct buffers while the heap stays small, native memory exhausts before GC kicks in.
Similarly, JNI code that allocates native memory must free it explicitly. Java’s GC has no visibility into it.
Symptoms
OutOfMemoryError: Direct buffer memory or process RSS growing far beyond -Xmx.
Fix
- Reuse
DirectByteBufferinstances via a pool (Netty’sPooledByteBufAllocatoris the standard approach) - Call
System.gc()as a hint (not a guarantee) when under native memory pressure - Set
-XX:MaxDirectMemorySizeto cap direct memory and surface the leak early - In JNI code, always pair
malloc/freeand track allocations carefully
Pattern 8 — Unclosed streams and connections
What happens
InputStream, OutputStream, Connection, PreparedStatement, and similar resources hold OS file descriptors or database connection slots. If not closed, these accumulate until the process hits the OS limit (too many open files) or the connection pool is exhausted.
Fix
Always use try-with-resources (Java 7+):
// Correct
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) { /* process row */ }
}
// conn, ps, rs all closed automatically — even on exception
Never rely on GC to close resources. The GC may not run before the OS limit is hit, and even when it runs there is no guarantee of ordering.
Pattern 9 — Finalizer and Cleaner misuse
What happens
Objects that override finalize() or register a Cleaner action are processed by a single finalizer thread. If your application creates such objects faster than the finalizer thread processes them, a queue builds up in the heap. Each queued object keeps its memory (and any referents) alive until it is finalized.
Symptoms
Heap fills with objects that appear “ready for collection” in a heap dump but haven’t been finalized yet. The finalizer thread becomes a bottleneck.
Fix
- Avoid
finalize()entirely (deprecated since Java 9, removed in Java 18+) - Prefer explicit resource management with
AutoCloseableand try-with-resources - If cleanup must be deferred, use
java.lang.ref.Cleaner(Java 9+) — but ensure the cleaning action does not reference the object being cleaned (or you create a reference cycle that prevents collection)
Detection and Tooling
| Tool | Use case |
|---|---|
jmap -dump:live,format=b,file=heap.hprof <pid> |
Capture a heap dump for analysis |
| Eclipse MAT (Memory Analyzer Tool) | Inspect heap dumps, find leak suspects, dominator tree |
| VisualVM / JConsole | Live heap monitoring, GC activity, memory pool sizes |
-verbose:gc / -Xlog:gc* |
GC log analysis — look for shrinking reclaimed bytes over time |
| Async-profiler | Allocation profiling — find which code paths allocate the most |
-XX:+HeapDumpOnOutOfMemoryError |
Automatically capture heap dump on OOM for post-mortem analysis |
Quick Diagnosis Checklist
When you suspect a memory leak, work through these steps in order:
- Enable GC logging and observe whether
used after GCgrows monotonically across Full GCs — the clearest sign of a leak. - Take two heap dumps separated by 10–15 minutes of load and compare object counts. Growing object types are suspects.
- Open the heap dump in Eclipse MAT and run the “Leak Suspects” report.
- Examine the dominator tree — the top entries represent the objects retaining the most memory.
- Follow the shortest path from a suspected object to a GC root to identify which reference is keeping it alive.
- If heap looks healthy but native memory grows, check direct buffer usage with
-XX:NativeMemoryTracking=summary.