How can code that uses only ordinary strong references still cause a memory leak in a garbage-collected JVM?
answer
- Leak = unintended strong reachability, not failure to free
- Static collections, listeners, ThreadLocals, unclosed resources
- Pooled thread outlives request -> ThreadLocal.remove() in finally
- Cycles don't leak (reachability, not refcounting)
- Diagnose via heap dump + dominator tree / retained size
basics
~20 sEven with a GC, you leak when objects stay strongly reachable but are never used again. Things like an ever-growing static list, listeners never removed, or ThreadLocals not cleared keep strong references alive, so the GC can't reclaim them.
solid answer
~50 sGarbage collection frees only what is unreachable, so a Java memory leak is unintended strong reachability: objects that are no longer needed but remain reachable from a GC root through a strong reference chain, so the collector must keep them. Classic patterns are unbounded static collections or caches that you add to but never evict, listeners or callbacks registered against a long-lived publisher and never deregistered, ThreadLocals not removed on pooled threads (the thread outlives the request and pins the value), and objects held by unclosed resources. The symptom is steadily growing retained heap and eventually OutOfMemoryError: Java heap space. The fix is to break the unwanted strong reachability: bound or evict caches (or use weak/soft references where appropriate, e.g. WeakHashMap), deregister listeners, call ThreadLocal.remove() in a finally block, and close resources. Diagnosis is done with heap dumps and a dominator/retained-size analysis to find what is holding the references.
code
java · 11 lines// Leak: pooled thread keeps the value alive across requests
static final ThreadLocal<UserContext> CTX = new ThreadLocal<>();
void handleRequest() {
CTX.set(new UserContext());
try {
process();
} finally {
CTX.remove(); // break the strong reachability; without this it accumulates
}
}go deeper
Recognizes that a GC exists but an ever-growing list keeps objects alive; may name only one pattern such as a static collection.
Defines a leak as objects that stay reachable but unused, lists several patterns (static caches, listeners, ThreadLocals, unclosed resources), and knows the OutOfMemoryError symptom.
Explains leaks as unintended strong reachability rooted at a GC root, prescribes the matching fix per pattern (bound/evict, deregister, remove() in finally, close), and dismisses the cycle misconception.
Designs to prevent the class of bug (lifecycle ownership, weak/soft references where appropriate, bounded caches), drives heap-dump/dominator-tree diagnosis, and reasons about retained-heap trends and the platform reachability model behind it all.
## 'Managed' does not mean 'leak-proof' Java has a **garbage collector (GC)** that reclaims objects you can no longer reach, so it prevents the C/C++ style of leak (forgetting to `free`). But the GC's rule is purely **reachability**: it keeps every object reachable from a **GC root** (an always-live anchor — thread stacks, static fields, etc.) through a chain of **strong references**, and frees the rest. A **Java memory leak** is therefore not a failure to free; it is **unintended strong reachability** — you keep a strong reference to something you will never use again, so the GC is *obligated* to keep it. Memory usage climbs (the leaked objects' **retained heap** grows) until the JVM throws `OutOfMemoryError: Java heap space`. The whole topic is the dark side of the strong-reference contract: the same rule that guarantees your live objects survive also guarantees your *accidentally-still-referenced* objects survive. ## Classic strong-reference leak patterns ### 1. Unbounded static collections / caches ```java static final Map<Key, Value> CACHE = new HashMap<>(); void handle(Key k) { CACHE.put(k, compute(k)); } // never removed ``` The `static` field is a GC root; everything it transitively holds is pinned forever. A cache with no eviction policy is the most common leak. Fixes: bound it (LRU, e.g. a size-limited map or a library cache), evict explicitly, or hold values via **soft/weak references** so the GC may reclaim them (`WeakHashMap`, soft-valued caches). ### 2. Listeners / callbacks never deregistered Register an observer with a long-lived publisher and forget to remove it: the publisher keeps a strong reference to the listener (and through it, often a whole UI screen or request context), so the listener — and everything it captures — never dies. Fix: always `removeListener` in the matching teardown, or hold listeners weakly. ### 3. ThreadLocals on pooled threads ```java static final ThreadLocal<Heavy> TL = new ThreadLocal<>(); TL.set(new Heavy()); // set during a request handled by a pool thread // ... never TL.remove() ``` In a thread pool, the **thread outlives the request**. The value stays strongly reachable via the thread's `ThreadLocalMap` for the life of the pooled thread, accumulating across requests. Fix: `TL.remove()` in a `finally` block. ### 4. Unclosed resources / lapsed-listener style holds Streams, connections, or buffers held open keep their backing objects (and sometimes native memory) reachable. Fix: `try-with-resources` / `AutoCloseable`. ## Why cycles are *not* the cause A common misconception is that reference cycles leak in Java. They do not: the GC uses reachability from roots, not reference counting, so an isolated cycle that no root can reach is collected. Every real Java leak traces back to a **live root** still strongly reaching the garbage. ## Diagnosing Because the leaked objects are validly reachable, the GC cannot help you; you must find *who* is holding the references: - Enable `-XX:+HeapDumpOnOutOfMemoryError`, or capture a dump with `jmap`. - Open it in **Eclipse MAT** or **VisualVM** and inspect the **dominator tree** and **retained size** to see which root is responsible for the bulk of retained heap, then follow the reference chain back to the offending field/collection. - Watch trends live with `jstat`, JFR, or VisualVM: a leak shows as retained heap that keeps rising after full GCs rather than returning to a baseline. ## The remedy in one sentence Every fix is the same shape: **break the unwanted strong reachability** — bound/evict, deregister, `remove()`, `close()`, or downgrade to a weaker reference type so the GC is allowed to reclaim the object.
- Do reference cycles cause leaks in Java the way they can with reference-counting GCs?No. The JVM determines liveness by reachability from GC roots, so an isolated cycle unreachable from any root is collected. Java leaks always have a live root still strongly reaching the garbage.
- How would you confirm and locate a suspected heap leak in production?Capture a heap dump (jmap or -XX:+HeapDumpOnOutOfMemoryError), analyze it in Eclipse MAT/VisualVM via the dominator tree and retained sizes to find the dominating root, then trace the reference chain to the offending static collection, listener, or ThreadLocal. Live, watch retained heap trend with jstat/JFR.
It is like a warehouse where staff only throw out boxes nobody has a claim ticket for. If you keep handing out claim tickets (strong references) and never tear them up, the warehouse fills with boxes you will never open again — even though the throw-out policy works perfectly.
saying these in an interview costs you the question
- Claiming Java cannot leak because it has a garbage collector.
- Attributing leaks to reference cycles (Java uses reachability, not counting).
- Forgetting that pooled threads outlive requests, so ThreadLocals must be removed.
- Proposing to 'force' collection with System.gc() instead of removing the offending strong reference.