ThreadLocal<T> gives each thread its own independent copy of a variable: get() and set() read and write a value scoped to the calling thread, so the value is implicitly confined and needs no synchronization. It causes leaks on long-lived (pooled) threads when entries are never removed.
How it works and what it's for
Each Thread carries a ThreadLocalMap field. A ThreadLocal instance is the key into that per-thread map. So the value lives on the thread, keyed by the ThreadLocal object. Typical uses: per-thread non-thread-safe tools (SimpleDateFormat), per-request context (user/trace IDs in a web request bound to its worker thread), and avoiding parameter-passing through deep call stacks.
private static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String format(Date d) {
try {
return FMT.get().format(d); // each thread has its own formatter
} finally {
// on a pooled thread, leave it set; on truly per-task state, remove()
}
}
The leak
The danger lives in that ThreadLocalMap. Its keys are weak references to the ThreadLocal object, but its values are strong references. On a thread pool, worker threads are reused indefinitely and never die, so their ThreadLocalMap never gets discarded. If you set() a large value and never remove() it:
- The value is strongly held by the still-alive thread's map, so it can never be garbage-collected.
- Worse, if the
ThreadLocalkey becomes unreachable, the entry becomes a stale entry: a null key with a live value. The map only cleans these opportunistically, so memory accumulates. - If the value (or its classloader) belongs to a redeployed web app, this also pins the classloader — the classic "PermGen/Metaspace leak after redeploy."
The fix
Always pair set() with remove() in a finally block at the end of the unit of work (e.g. a servlet filter or request boundary on a pooled thread):
ctx.set(requestId);
try {
handle(request);
} finally {
ctx.remove(); // critical on pooled threads
}