🧵 Concurrency Fundamentals · Advanced

ThreadLocal in Java

Per-thread values and leak risks in pools.

🧩 The mysteryAn anonymous visitor opens your site and reads "Welcome back, Alice". Nobody hacked anything. A ThreadLocal simply outlived its request.

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" here
🔮 Predict it

Your 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());
  1. worker
  2. null
  3. Throws 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.

🔮 Predict it

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());
  1. 10
  2. 99
  3. null
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.

🤔 Think first

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

✗ Leaks on exceptions
CONTEXT.set(r.tenant());
process(r);
CONTEXT.remove();

If process() throws, remove() is skipped and the tenant leaks into the next request.

✓ Always cleared
CONTEXT.set(r.tenant());
try {
    process(r);
} finally {
    CONTEXT.remove();
}

finally runs whether process() succeeds or throws.

💼 In the real world

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

  1. get()/set() act only on the current thread's copy
  2. withInitial(supplier) gives each thread a default value
  3. Pooled threads keep values: call remove() in finally
  4. Leaks can expose data across requests; Java 25 adds ScopedValue
🤯 Did you know?

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());
  1. null main-value
  2. main-value main-value
  3. main-value null
  4. 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());
  1. 11 10
  2. 11 null
  3. 11 11
  4. 10 10
Check your answer

11 10. remove() deletes the current thread's value. The next get() runs the initial supplier again, giving 10.

Next world: stop creating threads by hand. Thread pools, futures, CompletableFuture, lock-free atomics, and virtual threads that let one JVM juggle a million tasks.