ThreadLocal<T> gives every thread its own independent copy of a variable, with no locking. It is the standard trick for carrying request context (user id, trace id, transaction) through a call stack without threading a parameter through every method. It is also one of the most common sources of subtle memory leaks and cross-request data bleed in server code — because the storage is tied to the thread, and on a thread pool the thread outlives the work.
Where the value actually lives
The value is not stored in the ThreadLocal object. Each Thread has a field threadLocals of type ThreadLocalMap; the ThreadLocal instance is only the key. get() looks up the current thread's map using this as key; set(v) stores into it. That is why two threads calling tl.get() see different values with zero contention — they are reading different maps.
static final ThreadLocal<SimpleDateFormat> FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
String today = FMT.get().format(new Date()); // each thread its own SDF instanceThis is the canonical fix for non-thread-safe-but-expensive objects like SimpleDateFormat: one instance per thread instead of a new one per call or a shared lock.
The thread-pool leak, in detail
On an application server, threads come from a pool and are reused across thousands of requests. A value you set() during request A is still in that thread's map when the thread is handed request B. Two failure modes follow: stale data (request B reads request A's user id), and memory leak (the value is never garbage-collected because the live pool thread keeps a reference).
try {
CONTEXT.set(currentUser);
handleRequest();
} finally {
CONTEXT.remove(); // MANDATORY on pooled threads
}The remove() in finally is not optional hygiene — it is the correctness boundary. Frameworks that expose ThreadLocal context (Spring's RequestContextHolder, SLF4J's MDC) all clear it at the end of the request for this reason.
Why the leak is a weak-key story
ThreadLocalMap's keys are WeakReference<ThreadLocal>, but the values are strong. When a ThreadLocal object becomes unreachable, its key is cleared, leaving a stale entry whose value is still strongly held by the map until the next map operation happens to evict it. On a long-lived pool thread that rarely touches that map slot again, the value can linger indefinitely — a classic 'my heap grows under load' leak.
This is why you should keep ThreadLocal instances static final (so the key never dies mid-flight) and always remove() values explicitly. Do not rely on the weak key to clean up for you; it only clears the key, not the value.
InheritableThreadLocal and its pool trap
InheritableThreadLocal copies values to child threads at creation time: when thread A creates thread B, B's map is seeded from A's inheritable values. Useful for propagating context into spawned threads — but it copies at Thread construction, which on a thread pool happens once, when the pool creates the worker, not per task.
So on a pool, an InheritableThreadLocal captures whatever context existed when the pool warmed up and then never updates — usually the wrong value, silently. For context propagation across async boundaries, prefer explicit capture (grab the value, pass it into the submitted task) or a library built for it, rather than leaning on inheritance.
ScopedValue: the modern replacement
JDK 21+ introduces ScopedValue (finalised in later releases, preview earlier) precisely because ThreadLocal's mutability and manual cleanup do not fit virtual threads, where you may have millions of threads. A ScopedValue is immutable and bounded: it is bound for the dynamic extent of a lambda and automatically unbound when that scope exits — no remove(), no leak, no stale-on-reuse.
final static ScopedValue<User> USER = ScopedValue.newInstance();
ScopedValue.where(USER, currentUser).run(() -> {
handleRequest(); // USER.get() valid only inside here, cleared on exit
});With millions of virtual threads, per-thread ThreadLocal maps would be a memory problem; ScopedValue shares immutable bindings down the call tree instead. Where you control the JDK version, new context-propagation code should prefer ScopedValue; ThreadLocal remains correct and necessary on older runtimes and for genuinely mutable per-thread state.
Legitimate uses vs. abuse
Good: per-thread caching of expensive non-thread-safe objects (formatters, Random, buffers), and framework-level request context cleared at the boundary. Abuse: using ThreadLocal as a back channel to avoid passing parameters through your own code — it turns an explicit data dependency into hidden global-ish state that breaks the moment work hops threads (async, parallel streams, reactive). If a value is really part of your method's input, pass it as a parameter; reserve ThreadLocal for cross-cutting infrastructure concerns you genuinely cannot thread through.
ThreadLocal stores values in a per-Thread map keyed by the ThreadLocal instance, so reads are lock-free. On pooled threads you MUST remove() in a finally block or you get stale data and heap leaks. Keep instances static final, distrust InheritableThreadLocal on pools, and prefer ScopedValue on JDK 21+ for immutable, auto-cleaned context.