What causes memory leaks in Java despite having a garbage collector?

9 minadvancedmemory-leaksgcbest-practices

Quick Answer

Java's GC only reclaims objects that are unreachable. A memory leak still happens whenever code keeps an unintended strong reference to an object it no longer needs, preventing collection even though the object is logically 'done'. Common causes: unbounded caches without eviction, listeners/callbacks never unregistered, ThreadLocal values not removed in pooled threads, inner classes holding an implicit outer-instance reference longer than intended, and static collections that only ever grow.

Detailed Answer

Garbage collection reclaims objects that are unreachable from any GC root. It does nothing to help if your code still holds a reference to an object it no longer actually needs. A "Java memory leak" is really an unintentional reachability leak: something keeps pointing at an object long after it's logically garbage, so it silently accumulates and eventually causes OutOfMemoryError.

Common real-world causes:

  1. Unbounded caches: a HashMap used as a cache that only ever grows, with no eviction policy or size cap. Every entry ever added stays reachable forever.
static Map<String, byte[]> cache = new HashMap<>(); // never removes entries — grows forever

Fix: use a bounded cache — an LRU-evicting cache, SoftReference values, or a library like Caffeine.

  1. Unregistered listeners/callbacks: registering a listener on a long-lived object, like a UI component or event bus, and never removing it. The long-lived publisher keeps the listener, and everything it references, alive indefinitely.

  2. ThreadLocal values not cleaned up in thread pools: since pooled threads are reused, a value set via ThreadLocal.set() and never remove()-d persists on that thread indefinitely, referenced by the thread's internal ThreadLocalMap long after the task that set it has finished.

  3. Non-static inner classes: each instance implicitly holds a reference to its enclosing outer instance. If you hand out a long-lived inner-class instance — say, storing it in a static collection — it transitively keeps the entire outer object alive too, often unexpectedly.

  4. Static fields holding large collections: static fields live for the entire JVM lifetime, tied to the class rather than any instance. Accumulating data in a static collection without ever clearing it is a straightforward, common leak.

Diagnosis: heap dumps (via jmap/VisualVM/Eclipse MAT) showing an ever-growing count of a particular object type, and GC logs showing the old generation steadily climbing even after full GCs, are the classic symptoms of a reachability leak rather than a genuine allocation-rate problem.