Memory Leak Patterns in Java

 

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 DriverManager and 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:MaxMetaspaceSize to 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 DirectByteBuffer instances via a pool (Netty’s PooledByteBufAllocator is the standard approach)
  • Call System.gc() as a hint (not a guarantee) when under native memory pressure
  • Set -XX:MaxDirectMemorySize to cap direct memory and surface the leak early
  • In JNI code, always pair malloc/free and 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 AutoCloseable and 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:

  1. Enable GC logging and observe whether used after GC grows monotonically across Full GCs — the clearest sign of a leak.
  2. Take two heap dumps separated by 10–15 minutes of load and compare object counts. Growing object types are suspects.
  3. Open the heap dump in Eclipse MAT and run the “Leak Suspects” report.
  4. Examine the dominator tree — the top entries represent the objects retaining the most memory.
  5. Follow the shortest path from a suspected object to a GC root to identify which reference is keeping it alive.
  6. If heap looks healthy but native memory grows, check direct buffer usage with -XX:NativeMemoryTracking=summary.