ThreadLocal<T> gives every thread its own independent copy of a variable, read and written without locks. Server frameworks use it to carry request context such as the user, the tenant, a trace id or an open transaction through a deep call stack without adding a parameter to every method. Libraries use it to cache objects that are expensive to build and unsafe to share. It is also behind two of the most common production bugs in Java services: one request reading another request's data, and a heap that grows under load because values are never released.
Both bugs come from the same fact. The storage belongs to the thread, and in a server the thread outlives the work. This article explains where the value is stored, why the weak reference in that storage does not save you, how to scope values correctly on thread pools, how to carry context across executors, what changes with virtual threads, and when to move to ScopedValue, which became a final API in JDK 25. Examples assume JDK 17 or later; version differences are called out.
Where the value actually lives
The value is not stored in the ThreadLocal object. Every java.lang.Thread has a field called threadLocals that points to a ThreadLocalMap, created lazily on the first set() or get(). The ThreadLocal instance is only the key. get() fetches the current thread's map and looks itself up; set(v) stores into that map. Two threads calling CONTEXT.get() read two different maps, which is why there is no contention and no need for volatile or locks: no other thread can ever see the slot.
ThreadLocalMap is not a HashMap. It is a small array of entries using open addressing with linear probing. Each ThreadLocal gets a hash code from a global counter that advances by a fixed constant, which spreads consecutive instances well across power-of-two tables. The table starts at 16 slots and grows when it fills to about two-thirds, so a get() is a few array reads.
The API is small. get() returns the value or, if none is set, calls initialValue() (or the supplier passed to withInitial), stores the result and returns it. set(v) replaces the value. remove() deletes the entry, so the next get() runs the initializer again. Storing null is legal and different from removing: the entry stays.
Weak keys, strong values: the leak mechanics
Each map entry extends WeakReference<ThreadLocal<?>>: the key is weak, the value is a plain strong field. The design intent is that if your code drops every reference to a ThreadLocal instance, the garbage collector can clear the key. What it cannot clear is the value, because the entry still holds it and the thread holds the entry. The map expunges cleared entries only when a later get(), set() or remove() on the same map happens to probe past them. Nothing runs on a schedule.
So on a long-lived thread that rarely touches its map again, the value lingers until the thread dies. With per-object ThreadLocal instances (a classic mistake: a non-static field holding one) stale entries pile up between purges, and if a value references the object that owns its key, the key is never cleared at all, so the entries are never purged and the heap grows with every instance. The worse variant is the class loader leak. If the value's class was loaded by a web application's class loader, the entry pins that loader, and the loader pins every class it loaded. Redeploy the application and the old copy cannot be unloaded because a container worker thread still references one value. Tomcat checks for exactly this when an application stops and logs a warning naming the key and value types it found still attached to its threads.
Two rules follow. Declare ThreadLocal instances static final, so the key is never collected mid-flight and each class contributes one entry per thread, not one per object. And call remove() explicitly. The weak key is a mitigation for programs that forgot, not a cleanup mechanism.
Thread pools and the scoping idiom
Thread pools turn forgotten cleanup into a correctness bug. An executor or servlet container reuses each worker thread for thousands of tasks. A value set during task A is still present when the same thread runs task B, so B can read A's user, tenant or transaction. It looks intermittent because it depends on which worker the scheduler picks. The fix is a scope that always ends:
public final class RequestContext {
private static final ThreadLocal<Ctx> CURRENT = new ThreadLocal<>();
public static Ctx get() {
Ctx c = CURRENT.get();
if (c == null) throw new IllegalStateException("no request context bound");
return c;
}
/** Binds ctx for the duration of body, restoring whatever was bound before. */
public static <T> T with(Ctx ctx, Supplier<T> body) {
Ctx previous = CURRENT.get();
CURRENT.set(ctx);
try {
return body.get();
} finally {
if (previous == null) CURRENT.remove(); else CURRENT.set(previous);
}
}
}The finally runs on every exit path, including exceptions. Restoring the previous value, rather than always removing, makes nested scopes correct, which happens when one service call runs inside another on the same thread. And get() fails loudly when nothing is bound instead of returning null that surfaces three layers later. Frameworks follow the same pattern: Spring clears its request attributes holder after each request, and logging frameworks expect you to clear the SLF4J MDC the same way.
Worked example: context across an executor
Consider an order service. A servlet filter authenticates the caller, binds a Ctx holding the tenant id and trace id, and calls the handler. The handler fans out two lookups on an executor so they run in parallel. The lookups call a repository that reads RequestContext.get().tenantId() to choose a schema. In production the pool threads have nothing bound, so the lookups throw, or, if someone added a default, read the wrong tenant.
The context did not travel because ThreadLocal values never cross threads on their own. Capture at submission, restore at execution:
public final class ContextExecutor implements Executor {
private final Executor delegate;
public ContextExecutor(Executor delegate) { this.delegate = delegate; }
@Override
public void execute(Runnable task) {
Ctx captured = RequestContext.get(); // on the submitting thread
Map<String, String> mdc = MDC.getCopyOfContextMap();
delegate.execute(() -> RequestContext.with(captured, () -> {
Map<String, String> before = MDC.getCopyOfContextMap();
if (mdc != null) MDC.setContextMap(mdc); else MDC.clear();
try {
task.run();
} finally {
if (before != null) MDC.setContextMap(before); else MDC.clear();
}
return null;
}));
}
}
// usage: wrap once, use everywhere
Executor io = new ContextExecutor(Executors.newFixedThreadPool(32));
CompletableFuture<Price> price = CompletableFuture.supplyAsync(() -> prices.lookup(sku), io);The data flow is now explicit: the filter binds on the request thread, the executor wrapper copies the immutable Ctx reference into the task's closure, and the worker binds it for exactly the task's duration and removes it afterwards. Keep Ctx immutable; if two threads share a mutable context object you have reintroduced shared state with none of the visibility guarantees of the Java memory model. Pass the wrapper explicitly to every CompletableFuture async method; the variants without an executor run on the common pool, which the wrapper never sees. Spring offers the same hook as TaskDecorator, and Micrometer's context-propagation library generalizes it across several thread-local stores.
InheritableThreadLocal and its pool trap
InheritableThreadLocal copies values from parent to child when a Thread is constructed. Override childValue(parent) to copy a mutable value rather than share it. The copy happens once, at construction. On a pool, construction happens when the pool creates a worker, which may be during the first request after startup or after a burst. The worker then carries the context of whichever task happened to trigger its creation, forever. That is worse than seeing nothing, because nothing fails.
Use inheritance only for threads you create directly for one unit of work. Since JDK 9 a Thread constructor takes an inheritInheritableThreadLocals flag, so infrastructure that creates threads on behalf of many callers can opt out. For pools, use capture and restore as above.
Virtual threads change the economics
Virtual threads support ThreadLocal fully: each virtual thread has its own map, and values behave exactly as on platform threads. Preview builds briefly allowed creating virtual threads without thread-local support, but that option was dropped when virtual threads became final in JDK 21. What changes is the economics. Virtual threads are not pooled; you start one per task. A per-thread cache of an expensive object, which on a 200-thread pool built 200 objects once, now builds one object per task and keeps it until that task ends, possibly for a million concurrent tasks.
Prefer objects that are thread-safe and shared, such as DateTimeFormatter instead of the old per-thread SimpleDateFormat trick, or a small bounded pool for genuinely expensive resources. The JDK's virtual-thread JEP documents a diagnostic system property, jdk.traceVirtualThreadLocals, that prints a stack trace when a virtual thread sets a thread-local value; run your test suite with it to find every site. See virtual threads in depth for the scheduling side.
Migrating context to ScopedValue
ScopedValue was designed for the context-passing half of ThreadLocal's job. It was previewed from JDK 21 and finalized in JDK 25 (JEP 506). A binding is immutable and lasts for the dynamic extent of a call; when the call returns, the binding is gone, with no remove() to forget:
private static final ScopedValue<Ctx> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, ctx).run(() -> handler.handle(request));
// deeper in the stack, on the same thread:
String tenant = CTX.get().tenantId(); // throws NoSuchElementException if unboundThere is no set(), so code below the binding cannot change what callers see, and rebinding for a nested call is visible only inside that nested call. Child tasks forked inside a structured task scope inherit bindings automatically; structured concurrency itself is still a preview API in JDK 25. Plain ExecutorService tasks do not inherit, so the capture pattern above still applies there. Migration is mechanical where the old code already used a strict set, call, restore shape; code that mutates context midway needs a redesign. Scoped values in depth covers the API and performance in detail. ThreadLocal remains the right tool for mutable per-thread state and for runtimes older than JDK 25.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Request sees another user's or tenant's data | value left on a pooled thread | scope with try/finally; restore or remove |
| Heap grows with object count | non-static ThreadLocal whose value references its owner | make it static final; remove() |
| Old web app classes never unload after redeploy | value from app class loader pinned by a container thread | remove() at request end; stop custom pools on shutdown |
| Context is null in async code | work hopped to an executor or the common pool | capture and restore; pass the executor explicitly |
| Async tasks see stale startup context | InheritableThreadLocal on pool workers | disable inheritance; capture per task |
| Memory spike after adopting virtual threads | per-thread caches rebuilt per task | share thread-safe objects or use a bounded pool |
| Test passes alone, fails in suite | context leaked between tests on the same thread | assert nothing is bound in an after-each hook |
Finding and preventing leaks in production
To confirm a suspected leak, take a heap dump under load and open it in any heap analyzer. Follow a worker Thread to threadLocals, then table, and look for entries whose referent is null but whose value is large, or for many entries of the same value type. After a redeploy, more than one instance of the application's class loader confirms a loader leak.
Prevent rather than diagnose: bind only through one helper, assert at the end of each request that nothing is still bound, and count violations as a metric.
Trade-offs and alternatives
Rank the options by how visible the data flow is. A method parameter is explicit, checked by the compiler and free; use it whenever the value is a real input to your own code. ScopedValue is the next best for cross-cutting context: immutable and bounded. ThreadLocal is for mutable per-thread state and for context on older JDKs, used through one scoping helper. Using it as a hidden back channel to avoid changing method signatures turns a data dependency into global state that breaks the moment work changes threads in parallel streams, reactive pipelines or CompletableFuture chains. Executors that run that work are covered in ExecutorService.
What to do next
Work through this list on your codebase:
- Search for
new ThreadLocalandThreadLocal.withInitial; make every onestatic finalor justify why not. - Find every
.set(on a thread-local and confirm a matchingremove()or restore in afinally; move them behind one scoping helper. - Wrap every executor that runs request work so it captures and restores context and the MDC.
- Remove
InheritableThreadLocalfrom anything that runs on a pool. - Run tests with
-Djdk.traceVirtualThreadLocalsbefore adopting virtual threads, and replace per-thread caches. - On JDK 25 or later, migrate set, call, restore context code to
ScopedValue. - Add an end-of-request assertion that no context is bound, and alert on it.