ThreadLocal in Java
Per-thread values and leak risks in pools.
One key, a locker per thread
A ThreadLocal is a key; each thread stores its own value under it, like a locker per worker. get() and set() act only on the current thread's copy, so two threads calling set() never overwrite each other. Handy for per-request context or non-thread-safe helpers.
static final ThreadLocal<String> USER =
new ThreadLocal<>();
USER.set("alice"); // only this thread
String u = USER.get(); // "alice" hereYour turn
The worker sets a value, then main reads it. What does main print?
ThreadLocal<String> tl = new ThreadLocal<>();
Thread t = new Thread(() -> tl.set("worker"));
t.start();
t.join();
System.out.println(tl.get());workernullThrows NullPointerException
Show the answer
null. The worker filled its own slot. main never set one, so get() on main returns null. Same key, separate values per thread.
Defaults and remove()
withInitial supplies a starting value. What does this print?
ThreadLocal<Integer> n =
ThreadLocal.withInitial(() -> 10);
n.set(99);
n.remove();
System.out.println(n.get());1099null
Show the answer
10. withInitial(supplier) gives each thread a default on its first get(). remove() deletes the current thread's value, so the next get() runs the supplier again.
Pools reuse threads
In a thread pool, threads outlive tasks. A ThreadLocal value lives as long as the thread, so a value set during request #1 is still attached when the same thread handles request #2, unless you remove() it.
The leaking name
A filter calls USER.set(name) only when a request is authenticated, and never removes it. Why do anonymous visitors sometimes see a previous user's name?
Think about it, then reveal the answer
The pooled thread still carries the last user's value. Anonymous requests skip set(), so get() returns whatever the previous request on that thread left behind. Always clear the value at the end of every request.
Cleaning up
CONTEXT.set(r.tenant());
process(r);
CONTEXT.remove();If process() throws, remove() is skipped and the tenant leaks into the next request.
CONTEXT.set(r.tenant());
try {
process(r);
} finally {
CONTEXT.remove();
}finally runs whether process() succeeds or throws.
Hidden everywhere
Logging frameworks keep the MDC (request ids in log lines) in ThreadLocals, and Spring Security keeps the current user in one by default. Forget cleanup and you leak data across requests, or memory on redeploys. Java 25's ScopedValue is a safer, bounded alternative.
Key takeaways
- get()/set() act only on the current thread's copy
- withInitial(supplier) gives each thread a default value
- Pooled threads keep values: call remove() in finally
- Leaks can expose data across requests; Java 25 adds ScopedValue
A ThreadLocal stores nothing itself: each Thread object has an internal map (threadLocals), and the ThreadLocal is just the key into it.
Practice questions
What does this print?
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("main-value");
Thread t = new Thread(() ->
System.out.println(tl.get()));
t.start();
t.join();
System.out.println(tl.get());- null main-value
- main-value main-value
- main-value null
- null null
Check your answer
null main-value. Each thread has its own slot. The worker never set a value, so it sees null, while main still sees its own "main-value".
What does this print?
ThreadLocal<Integer> n =
ThreadLocal.withInitial(() -> 10);
n.set(n.get() + 1);
System.out.println(n.get());
n.remove();
System.out.println(n.get());- 11 10
- 11 null
- 11 11
- 10 10
Check your answer
11 10. remove() deletes the current thread's value. The next get() runs the initial supplier again, giving 10.