Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

The Seven Classic Java Memory Leaks

The seven leak patterns that recur in every codebase, why a garbage-collected language leaks at all, how each one is diagnosed from a heap dump, and the code change that fixes each.

Advanced7 min readUpdated
On this page

Java leaks memory constantly, and the collector is never at fault. Every leak is the same bug in a different costume: something still holds a reference to data nobody needs. These seven patterns account for the overwhelming majority of real cases.

Key Takeaways

  • A leak is unintentional retention — reachable from a GC root, but no longer needed.
  • The signature in a GC log: heap occupancy after each Full GC rises monotonically.
  • Static collections, unremoved listeners and ThreadLocal in pools are the three most common.
  • ThreadLocal in a pooled thread also leaks across requests, which is a correctness and security bug as well as a memory one.
  • Every one of these is found the same way: a heap dump plus the dominator tree.

The signature

A sawtooth that returns to the same floor is healthy. A sawtooth whose floor climbs is a leak.

That distinction is the first thing to establish, and it takes one glance at jstat -gcutil or a GC log. A heap that fills and empties is doing its job. A heap whose post-collection floor rises every hour will hit OutOfMemoryError at a predictable time.

1. Static collections

the cache that never forgets
public class OrderCache {
    // Static, so it lives for the life of the classloader. Nothing removes.
    private static final Map<String, Order> CACHE = new HashMap<>();
 
    public static void put(Order order) { CACHE.put(order.id(), order); }
}

A static field is a GC root. Anything reachable from it is retained forever, so a static collection with no eviction is a leak with a schedule: it fails when traffic × uptime exceeds the heap.

the fixes
// Bounded, with eviction and TTL
Cache<String, Order> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(30))
        .build();
 
// Or a WeakHashMap when the key's lifetime is controlled elsewhere
Map<Session, Metadata> meta = new WeakHashMap<>();

Note the WeakHashMap caveat that catches people out: the key is weakly referenced, but if the value holds a strong reference back to the key, the entry is never collected. That circular reference is a leak inside the leak-prevention mechanism.

2. Unremoved listeners and callbacks

registration without deregistration
public class OrderPanel {
    public OrderPanel(EventBus bus) {
        bus.register(this);       // the bus now holds a strong reference to this panel
    }
    // No unregister. Every panel ever created is retained by the bus,
    // along with everything the panel references.
}

The observer pattern leaks by default: the subject holds a strong reference to every observer, and observers are usually shorter-lived than subjects. Anything with addListener, subscribe, register or on(...) needs a matching removal, ideally in a close() or @PreDestroy.

Where the API allows it, a WeakReference-based registry inverts the default so a listener that is otherwise unreachable does not keep itself alive.

3. ThreadLocal in a pooled thread

the worst of the seven
public class TenantContext {
    private static final ThreadLocal<Tenant> CURRENT = new ThreadLocal<>();
 
    public static void set(Tenant t) { CURRENT.set(t); }
    public static Tenant get()       { return CURRENT.get(); }
    // No remove(). On a pooled thread, this Tenant survives the request
    // and is visible to the NEXT request handled by that thread.
}

Two failures at once. The memory leak: pooled threads live for the life of the application, so every ThreadLocal value they hold is retained indefinitely. And a correctness and security bug: request B, handled by the same thread, sees request A's tenant.

the fix
public static void runWith(Tenant t, Runnable action) {
    CURRENT.set(t);
    try {
        action.run();
    } finally {
        CURRENT.remove();     // mandatory, in a finally block
    }
}

Since Java 21, ScopedValue is the designed replacement: immutable, bound to a lexical scope, and automatically unbound on exit — it cannot leak this way.

4. Unclosed resources

every unclosed resource is two leaks
public List<Order> load() throws SQLException {
    Connection conn = dataSource.getConnection();     // never closed
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("SELECT ...");
    return map(rs);
}

This leaks Java heap (the driver's buffers), native memory (socket handles), and a connection from the pool — which is the one that takes the service down first, in minutes rather than hours. See Connection-pool exhaustion.

try-with-resources fixes it in every case, and InputStream, Reader, Connection, HttpClient responses and Stream from Files.lines all need it. That last one surprises people: a Stream backed by a file holds an open descriptor until closed.

5. Classloader leaks

The most difficult to diagnose, and the reason application servers used to require a restart on every redeploy.

one reference retains an entire application
// Registered in the JVM-wide DriverManager, which outlives the web app.
// Retains the Driver class -> its classloader -> every class the app loaded.
DriverManager.registerDriver(new com.acme.Driver());
 
// A thread started by the app and never stopped. Its context classloader
// is the app's, and a running thread is a GC root.
new Thread(this::poll).start();
 
// A ThreadLocal set on a container thread, holding an app class as its value.
CONTEXT.set(new AppSpecificContext());

Each of these is a reference from a longer-lived scope into a shorter-lived one. Because a class holds a reference to its classloader, and a classloader holds every class it loaded, retaining one object retains tens of megabytes of Metaspace plus the entire static state of the application.

The symptom is OutOfMemoryError: Metaspace after several redeploys, and the diagnosis is a heap dump with a "path to GC roots" query on the leaked classloader. Tomcat's JreMemoryLeakPreventionListener and its leak detection exist specifically for this family.

6. Growing keys with broken hashCode

the mutable key, again
Set<Order> processed = new HashSet<>();
processed.add(order);
order.setStatus(SHIPPED);      // hashCode changed — the entry is now unreachable
 
processed.remove(order);       // false — cannot be found
processed.contains(order);     // false
processed.size();              // still counts it. Forever.

The entry is in the set, counted in size(), visible in iteration, and impossible to remove. Every mutation adds another. This is the equals/hashCode contract violation showing up as a leak rather than as a bug, and the fix is to use immutable keys.

7. Substring, arrays and accidental retention

retaining a lot to keep a little
// Fixed in Java 7 — before that, substring shared the parent's char array,
// so a 3-character substring of a 10MB string retained all 10MB.
String small = huge.substring(0, 3);
 
// Still true today: a slice that keeps a reference to the whole
List<Order> firstTen = allMillionOrders.subList(0, 10);   // a VIEW — retains the whole list
 
// And the one people still hit:
byte[] buffer = new byte[100_000_000];
process(buffer);
// buffer stays in scope for the rest of a long method — set it to null
// if the method continues doing unrelated work.

subList returning a view rather than a copy is the live version of this. So is any long-lived object holding a reference to a big graph it only needed briefly — a cached exception holding its stack trace, a listener holding the request that registered it.

Finding one

the sequence
# 1. Confirm it is a leak: does post-Full-GC occupancy rise?
jstat -gcutil <pid> 5000 20
 
# 2. Capture a dump. Do this on a canary — it pauses the JVM for seconds.
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# or automatically, always configured in production:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/
 
# 3. Two dumps an hour apart, compared, show what GREW — usually decisive.

Then open it in Eclipse MAT, run the Leak Suspects report, and use the dominator tree and "path to GC roots" to find the retaining reference. That workflow is covered step by step in Heap dump analysis with MAT.

What gets asked

"How would you find a memory leak in production?" is the standard question, and the answer is the sequence above: confirm from GC behaviour, dump, dominator tree, path to roots. Then expect "name some common causes" — give static collections, listeners and ThreadLocal in a pool, and explain the ThreadLocal one properly, because it is the case most candidates have heard of and few can explain.

Frequently Asked Questions

How can a garbage-collected language have memory leaks?
Because the collector reclaims unreachable objects, not useless ones. A leak in Java is an object you no longer need that is still reachable from a GC root — a static field, a live thread stack, a JNI reference. The collector is working exactly as designed; the bug is that your code still holds a reference. This is sometimes called unintentional object retention, which is a more accurate name.
Why does ThreadLocal leak in a thread pool?
Because pooled threads are never destroyed. A ThreadLocal value set while handling one request stays attached to that thread and is still there for the next request, and forever after. The ThreadLocalMap key is a weak reference so the key can be collected, but the value is a strong reference held until the entry is explicitly removed. Always call remove() in a finally block, or use a servlet filter that clears them.
What is a classloader leak?
A reference from outside a web application into any class it loaded, which retains that class, its classloader, and every other class the loader ever loaded. Redeploying then leaks the entire application. The usual culprits are a JDBC driver registered in the JVM-wide DriverManager, a ThreadLocal on a container thread, a shutdown hook, or a thread the application started and never stopped.

Related tutorials