Memory Leaks in Java
Memory Leaks in Java
Java has a garbage collector, so many developers assume memory leaks are impossible. That assumption is wrong and dangerous. A memory leak in Java means objects that are no longer needed by the application are still reachable by the GC graph and therefore never collected. The GC only frees what is unreachable; if a long-lived object keeps a reference to a short-lived object, the short-lived object lives forever.
Why Leaks Are Subtle
Leaks do not crash the JVM immediately. They cause a slow, steady rise in heap usage — a symptom called memory creep. The application runs fine for hours or days, then old-generation GC pauses get longer, throughput drops, and eventually an OutOfMemoryError occurs. By that point, the root cause may be deep in a cache or listener registered days ago.
Pattern 1: Static Collections as Caches
The most common leak: a static field holds a collection that grows without bound because items are added but never removed.
Fix: Remove entries when they are no longer needed, or use a bounded data structure. If you need a cache that evicts old entries automatically, use LinkedHashMap with override of removeEldestEntry, or a purpose-built cache like Caffeine.
Pattern 2: Forgotten Event Listeners and Callbacks
Any time an object registers itself as a listener with a longer-lived publisher, the publisher holds a reference to it. If the listener is never deregistered, it cannot be collected — and neither can anything it references.
AutoCloseable on any object that registers callbacks or acquires resources. Callers can then use try-with-resources to guarantee cleanup, and static analysis tools like SpotBugs will warn when the object is not closed.
Pattern 3: Inner Classes Capturing Outer Instances
A non-static inner class always holds an implicit reference to its enclosing instance. If the inner class escapes to a longer-lived scope (a thread, a timer task, a framework callback), it drags the outer class with it.
Fix: Use a static nested class or a lambda that captures only the data it needs (not the enclosing instance), or hold only a weak reference to the outer object.
Pattern 4: ThreadLocal Variables
Thread pools reuse threads. A ThreadLocal value set on a pooled thread persists forever unless explicitly removed. In web containers (where the thread pool is managed by the server) this is a very common leak.
ThreadLocal.remove() in a try-finally block. Pooled threads live for the lifetime of the application, so a missing remove() effectively pins the value (and everything it references) in memory for the same duration. This can also cause correctness bugs when the same thread later handles an unrelated request and sees stale data.
Pattern 5: WeakHashMap Misuse
WeakHashMap is often recommended as a "self-cleaning cache", but it only allows its keys to be weakly referenced. If the values hold a strong reference back to the key (directly or indirectly), the key is always reachable and is never collected — the map never shrinks.
Spotting Leaks: Key Signals
- Heap usage grows monotonically across multiple full GC cycles without coming back down.
- Old-generation fill rate increases over time even under constant load.
- GC logs show full GC running more and more frequently while reclaiming less and less memory each cycle.
- Heap histogram (
jmap -histo:live <pid>) shows a class whose instance count keeps growing but should be bounded. - Heap dump diff between two snapshots taken minutes apart reveals which object types accumulated.
jcmd <pid> GC.heap_info to see aggregate sizes, then jmap -histo:live <pid> | head -30 to rank object types by retained count. Compare the output taken five minutes apart under load — the type whose count keeps climbing is the suspect.
Defensive Patterns to Prevent Leaks
- Prefer bounded caches (Caffeine, Guava Cache) over raw maps for any application-scoped state.
- Always deregister listeners and callbacks in a
close()/destroy()lifecycle method. - Use static nested classes (or lambdas that capture only specific values) instead of non-static inner classes passed to long-lived objects.
- Pair every
ThreadLocal.set()with aThreadLocal.remove()in afinallyblock. - Use weak or soft references purposefully:
WeakReferencefor caches where eviction on GC pressure is acceptable;SoftReferencefor memory-sensitive caches that should survive minor GCs. - Run heap-dump analysis as part of your load-testing pipeline — catching a leak at 1,000 requests is far cheaper than at 10,000,000.
Summary
Memory leaks in Java are entirely about the reference graph. The GC will collect anything it cannot reach; leaks happen when a long-lived root keeps a chain of references alive to objects that are logically dead. The five patterns to watch — unbounded static collections, forgotten listeners, inner classes escaping to long-lived scopes, un-removed ThreadLocal values, and misused WeakHashMap — cover the vast majority of production leaks. Instrument your applications with heap histograms, GC logs, and heap dumps, and treat a steadily rising old-generation size as a bug, not a tuning problem.