Memory leaks in Java aren’t always loud or obvious. Sometimes, they creep in quietly - through static fields, forgotten listeners, or subtle misuse of common classes. These leaks don’t crash your app right away, but they slowly degrade performance, increase memory usage, and lead to unexpected OOM errors in long-running systems.
In this post, we’ll look at some lesser-known but real-world Java memory leak patterns with clean fixes you can apply right away.
1. Using ThreadLocal
without removing
In thread pools (e.g., servlet containers), threads live long. If we don't remove the value, it stays in memory forever, even if it's not used again
.
Fix:
.remove()
clears the reference tied to the thread, allowing GC to collect the HeavyObject
instance after it's used - avoiding memory bloat in long-lived threads.
2. Static Collections Holding Data
Static map never dies = entries stick around forever = slow memory leak as data piles up.
Solution: Using a proper caching library (like Caffeine) introduces eviction + TTL, meaning old or unused entries are automatically removed, keeping memory usage in check.
3. Anonymous Inner Classes Holding Outer Class References
Inner classes implicitly hold a reference to the outer class. If the task lives long, it prevents the outer class from being GC'ed - even if the user navigated away.
Non-static inner classes hold an implicit reference to their outer class. If they outlive the outer class, they can cause a memory leak.
Fix: Use static inner classes or separate classes. A static inner class does not hold an implicit reference to an instance of the outer class. This decoupling ensures that the lifecycle of the inner class is not tied to the outer class, preventing potential memory leaks.
when the outer object is unused, it’s freed correctly by the GC.
4. Listeners Not Removed
You added a listener but never removed it. So even if the object that registered it is no longer needed, it stays alive because the button holds it.
Fix: Explicitly removing the listener breaks the reference chain, allowing both the listener and possibly its enclosing object to be garbage collected.
5. Holding Strong References to ClassLoaders
private static final List<ClassLoader> loaders = new ArrayList<>();
In plugin/reloadable apps, classloaders should be GC'ed after unload. But strong references keep them in memory, causing class metadata and heap leaks.
Fix:
Weak references don’t prevent GC, so once a classloader is unused, it’s eligible for cleanup. You avoid both heap and Metaspace bloat.
List<WeakReference<ClassLoader>> loaders = new ArrayList<>();
6. Unbounded Executor Queues
The default LinkedBlockingQueue
used by Executors.newFixedThreadPool()
is unbounded. Submitting millions of tasks causes the queue to grow indefinitely, consuming heap.
Fix: Use a custom ThreadPoolExecutor
with a bounded queue:
The queue has a size cap, preventing runaway heap usage. CallerRunsPolicy
throttles the caller instead of leaking memory.
7. JDBC Connections Not Closed Properly
If the connection isn’t closed, it stays alive, leading to connection leaks and memory/resource exhaustion.
Connection conn = dataSource.getConnection();
// do stuff
Fix:
try (Connection conn = dataSource.getConnection()) {
// use it
}
The try-with-resources block ensures the connection is always closed, even if an exception occurs - preventing resource + memory leaks.
8. Keeping Long References in Logging Context (like MDC)
MDC uses ThreadLocal
internally. If you don’t clear the context, that data lives on in the thread - leaking memory across requests in thread pools.
Fix:
.clear()
removes all MDC values tied to the thread, letting memory get released cleanly after request completion.
Most memory leaks in Java happen not because of complex code, but because of overlooked patterns in everyday usage.
Catch these early, and your app stays healthy. Miss them, and they quietly eat your memory.
If you found these helpful, drop a follow or share — and let me know if you’ve seen any sneaky leaks in the wild.