Explain how unremoved listeners and ThreadLocals in pooled threads cause leaks, and how you avoid them.
answer
- Long-lived holder pins short-lived object
- addListener without removeListener = retained for source's life
- ThreadLocal value lives as long as the thread; pool threads never die
- ThreadLocalMap: weak key, STRONG value -> remove() needed
- Symmetry: every register has an unregister (finally for ThreadLocal)
basics
~20 sIf you register a listener and never remove it, the event source keeps your object alive. A ThreadLocal value stays alive as long as the thread does — and pool threads live forever, so the value never goes away. Always deregister listeners and always call ThreadLocal.remove() in pooled code.
solid answer
~50 sBoth are 'long-lived holder pins short-lived object' leaks. A listener: you call source.addListener(this), but the source (often a long-lived singleton or UI component) now strongly references your object; if you never removeListener, your object — and everything it references — stays reachable for the source's whole life. ThreadLocal: a value is stored under the current thread; it lives as long as the thread does. In a thread pool the worker threads never die, so a value you set and never remove() stays reachable indefinitely, and it can also bleed into the next task that reuses the thread. The fixes: deregister listeners in a symmetric teardown (or use weak listeners), and in pooled threads always ThreadLocal.remove() in a finally block once the unit of work is done. A subtle ThreadLocal detail: the entry's key is a weak reference to the ThreadLocal, but the value is strong, so a stale value can linger until the slot is reused — explicit remove() is the reliable fix.
code
java · 13 lines// Listener leak: register without deregister
class Widget {
Widget(EventBus bus) { bus.addListener(this::onEvent); } // bus keeps 'this' alive
// never calls bus.removeListener(...) -> retained for the bus's whole life
}
// ThreadLocal in a pool: clear in finally
static final ThreadLocal<UserCtx> CTX = new ThreadLocal<>();
void handle(Request r) {
CTX.set(new UserCtx(r));
try { process(); }
finally { CTX.remove(); } // frees memory AND prevents stale bleed into next task
}go deeper
Knows you should unsubscribe listeners and call ThreadLocal.remove(), even if hazy on why.
Explains that the source holds the listener and the thread holds the ThreadLocal value, and that pool threads never die, so cleanup is required.
Reasons about retained size and root paths for both, knows the ThreadLocalMap weak-key/strong-value detail, uses try/finally remove() and symmetric register/unregister, and flags the stale-value correctness risk.
Bakes symmetry into framework boundaries: interceptors/filters that clear ThreadLocals after each request, lifecycle-managed subscriptions, lint/architecture rules, and observability to catch retention growth in services running on shared pools.
## The shared shape Both patterns are the same leak in two disguises: a **long-lived object holds a strong reference to a short-lived object**, so the GC can never reclaim the short-lived one. Remember the rule — the GC frees an object only when nothing **reachable from a GC root** points to it. ## Pattern 1: listeners / callbacks never deregistered Observer-style APIs let you subscribe: `eventSource.addListener(this)`. Internally the source stores your listener in a list. Now **the source strongly references your object.** If the source is long-lived (a singleton bus, a static registry, a parent UI component) and you never call `removeListener`, your object stays reachable for as long as the source lives — even after your object is logically dead. Worse, your listener usually closes over more state (an enclosing instance, a controller, a whole view), so the leak's **retained size** is much bigger than one object. Register-on-create without remove-on-destroy, repeated per dialog/request/connection, is a steadily growing leak. **Fixes:** - **Symmetric lifecycle:** every `addListener` has a matching `removeListener` in the teardown (`close()`, `dispose()`, `@PreDestroy`, controller destroy hook). Make registration and deregistration mirror each other. - **Weak listeners:** some frameworks support weakly-held listeners (e.g. via `WeakReference` wrappers) so the source doesn't keep the listener alive. Use with care — a listener referenced by nothing else can be collected mid-flight. - **Prefer narrow scopes:** don't subscribe a big object when a small, dedicated listener (or a method reference whose lifetime you control) would do. ## Pattern 2: ThreadLocal in pooled threads A **`ThreadLocal<T>`** gives each thread its own copy of a value. Mechanically, the value is stored in a `ThreadLocalMap` **owned by the `Thread` object** — so the value's lifetime is tied to the *thread's* lifetime. With a normal thread that's fine: the thread ends, the thread object becomes unreachable, its `ThreadLocalMap` (and your value) goes with it. But in a **thread pool**, the worker threads are **reused forever** — they never terminate. So a value you `set()` and never `remove()` stays reachable for the **entire life of the pool**. Multiply by every request that stashes something, and you have an unbounded leak. There's a second hazard: the *next task* that runs on the same pooled thread can **read a stale value** left by the previous task — a correctness and security bug (e.g. leaking another user's context), not just memory. ### The weak-key subtlety Inside `ThreadLocalMap`, the **key** (the `ThreadLocal` object itself) is held by a **weak reference**, but the **value is held strongly**. So if the `ThreadLocal` variable becomes unreachable, the key can be cleared, leaving a **stale entry** whose value is still strongly held until that map slot happens to be reused by housekeeping. This is why merely dropping the `ThreadLocal` reference does **not** reliably free the value — you must `remove()`. **Fix:** wrap pooled work in try/finally and clear it: ```java static final ThreadLocal<RequestContext> CTX = new ThreadLocal<>(); void handle(Request r) { CTX.set(new RequestContext(r)); try { process(); } finally { CTX.remove(); // mandatory in pooled threads } } ``` Always `remove()` at the end of the unit of work. This both frees the memory and prevents stale-value bleed into the next task. ## Detecting both - **Heap dump:** look at retained size and the **GC-root path**. A listener leak shows your objects retained by a `listeners` list inside a long-lived source. A ThreadLocal leak shows values retained via `Thread` → `ThreadLocalMap` → `Entry`. - **Trend:** live (post-GC) heap creeping up with traffic; restart 'fixes' it temporarily — a leak signature. ## The principle Anything that **registers** state into a longer-lived holder must have a matching **unregister** at end-of-life. Subscriptions need unsubscribes; `ThreadLocal.set` in pooled code needs `ThreadLocal.remove`. Symmetry is the discipline that prevents both leaks.
- Why doesn't the weak key inside ThreadLocalMap prevent the leak by itself?The key (the ThreadLocal object) is weakly referenced, but the value is strongly referenced by the entry. Even if the key is cleared, the value stays reachable via the live thread's map until that slot is reused by housekeeping. So you can get a stale entry holding a strong value indefinitely — explicit remove() is the reliable fix.
- Beyond memory, what bug can a leftover ThreadLocal cause in a pool?Stale-value bleed: the next task scheduled on the same reused thread reads the previous task's leftover value (e.g. another request's user/security context). That's a correctness and potential security/isolation bug, not just wasted memory.
saying these in an interview costs you the question
- Assuming the GC collects a listener that the source still holds
- Thinking dropping the ThreadLocal variable frees the value (the value is strongly held)
- Not calling ThreadLocal.remove() in pooled/executor code
- Ignoring the stale-value correctness/security risk of reused pool threads
- Believing weak listeners are a free fix without considering premature collection