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.
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
ThreadLocalin pools are the three most common. ThreadLocalin 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
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
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.
// 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
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
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.
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
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.
// 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
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
// 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
# 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?
Why does ThreadLocal leak in a thread pool?
What is a classloader leak?
Related tutorials
- Reading GC Logs and Tuning Without GuessingEnabling unified GC logging, reading a G1 log line by line, calculating allocation and promotion rates, identifying every Full GC cause, and the tuning changes that are usually wrong.
- Every OutOfMemoryError and What It MeansEach OutOfMemoryError message, what it actually indicates, the most likely cause, and the first three things to check — plus why OOMKilled by the kernel is a different failure entirely.
- Garbage Collectors: Serial, Parallel, G1, ZGC, ShenandoahHow each collector works, the throughput-versus-latency trade-off that separates them, what G1 regions and pause targets really do, and how ZGC achieves sub-millisecond pauses on huge heaps.
- Heap Dump Analysis with MATCapturing a heap dump safely in production, the difference between shallow and retained size, reading the dominator tree, using path to GC roots, and OQL queries that answer real questions.