skip to content
everything.deepDives.threadlocalInternals

ThreadLocal internals

weak keys, the per-thread map, and the thread pool leak.

chapter
chapter 17.07
reading time
9 min read

a ThreadLocal doesn't store anything. each Thread has a field, threadLocals, pointing at a small hash map; the ThreadLocal object is just the key. the key is held by a weak reference and the value by a strong one, so when you drop a ThreadLocal without calling remove(), its value stays pinned to the thread. on a thread pool, threads live forever, and so do those values.

the picture

the name suggests a variable with a copy per thread living inside it. turns out it's the other way round: the copies live inside the threads.

think of a gym. every member (thread) gets their own locker room (threadLocals). a ThreadLocal is a locker number printed on a tag. get() means "go to my locker room, open the locker with this number". two members with the same tag open two different lockers. throw the tag away and the locker's contents don't disappear, you just can't find them anymore.

stack · main
user
buffer
ThreadLocal objects
ThreadLocal (user)
ThreadLocal (buffer)
the current Thread
Thread "main"
threadLocals
ThreadLocalMap · Entry[16]
key (weak)value
[7]
[14]
values
"ada"
byte[1048576]

user.set("ada") and buffer.set(1 MB). the values don't live in the ThreadLocal objects at all: they live in a map that belongs to the current Thread, keyed by the ThreadLocal.

step 1/6
step through a real dump of Thread.threadLocals (read with reflection on jdk 25, slot numbers and all). dashed arrows are weak references. watch step 3: the key is gone, the value is still pinned by the thread, and nothing in the next three steps cleans it up.

so get() is: Thread.currentThread(), read its threadLocals, look up this. no synchronization anywhere, because only the owning thread ever touches its own map. that's the whole reason ThreadLocal is fast.

the map inside every thread

ThreadLocalMap isn't a HashMap. it's a tiny custom table, tuned for a handful of keys:

table
Entry[], created lazily on the first set (a thread that never uses a ThreadLocal has threadLocals == null). starts at 16, power of two, doubles.
collisions
open addressing with linear probing: on a collision, try the next slot. no chains, no trees.
load
the resize threshold is 2/3 of the length. before growing, it first sweeps out stale entries and only doubles if that didn't free enough.
Entry
extends WeakReference<ThreadLocal<?>>. the weak referent is the key. the value is a plain Object field.

the hash is the cute part. each ThreadLocal gets threadLocalHashCode from a global counter that goes up by 0x61c88647 per new instance. that constant is the classic golden-ratio number 2³²/φ (0x9e3779b9) negated, a trick called Fibonacci hashing, and its multiples are spread so evenly that consecutive ThreadLocals land in different slots of any power-of-two table:

java
void main() {
    int h = 0;
    var sb = new StringBuilder();
    for (int i = 0; i < 16; i++) {
        sb.append(h & 15).append(' ');
        h += 0x61c88647;
    }
    IO.println(sb.toString().trim());
}
output
0 7 14 5 12 3 10 1 8 15 6 13 4 11 2 9

sixteen ThreadLocals, sixteen different slots of a 16-slot table, no collisions. (look at the figure again: our three ThreadLocals, created back to back, landed in 7, 14 and 5. three consecutive numbers from that exact sequence.)

weak keys, strong values

why is only the key weak?

  • the key is weak so that dropping your last reference to a ThreadLocal doesn't leave the ThreadLocal object itself alive forever inside every thread that ever used it.
  • the value can't be weak, because the map is often the only thing holding it. a weak value could be collected while you still expect get() to return it.

the price is the stale entry: key collected, value still there. ThreadLocalMap cleans them up opportunistically: when a lookup probes past one, when set runs a short scan after inserting (about log₂(size) slots), when remove() is called, or when the table is rehashed. a direct hit cleans nothing. the source's own comment admits stale entries are only guaranteed to go when the table starts running out of space. as you saw in the figure, that can be never.

predict the output
java
ThreadLocal<byte[]> buffer = new ThreadLocal<>();
buffer.set(new byte[1 << 20]);
buffer = null;
System.gc();
// what happens to the 1 MB array?

the thread pool problem

a stale entry on a short-lived thread is harmless: the thread dies, its map dies, done. the trouble is that servers don't have short-lived threads. a request runs on a pool thread, and pool threads live as long as the pool. two things go wrong.

one: memory. a static final ThreadLocal used as per-thread scratch space never becomes stale (the key is always reachable), so each pool thread keeps its value until it dies. we ran 64 tasks on an 8-thread pool, each setting a 16 MB scratch buffer, then measured the heap after a few forced GCs:

after 64 tasks, pool idleheap in use
no remove()137 MB (8 threads × 16 MB, held)
remove() in finally1 MB
either version, after pool.shutdown()1 MB

two: data from the last request. the next task on the same thread sees whatever the last one left behind:

Main.java
import java.util.concurrent.*;
 
public class Main {
    static final ThreadLocal<String> USER = new ThreadLocal<>();
 
    static String handle(String loggedInAs) {
        if (loggedInAs != null) USER.set(loggedInAs);   // set, but never removed
        return "request served for " + USER.get();
    }
 
    public static void main(String[] args) throws Exception {
        try (var pool = Executors.newFixedThreadPool(1)) {
            System.out.println(pool.submit(() -> handle("alice")).get());
            System.out.println(pool.submit(() -> handle(null)).get());   // an anonymous request
        }
    }
}
output, from jdk 25
request served for alice
request served for alice

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

the anonymous request was served as alice. in a real app that's a security incident. the fix is boring and mandatory:

don'tset and forget
java
USER.set(principal);
handle(request);
doalways clean up
java
USER.set(principal);
try {
    handle(request);
} finally {
    USER.remove();
}

and there's a third, nastier version: if the value (or its class) comes from a web app's class loader, the pool thread now holds a reference into that app, and redeploying it leaks the whole old copy. that's its own chapter: class loader leaks.

InheritableThreadLocal

InheritableThreadLocal copies values from parent to child. the copy happens in the Thread constructor (ThreadLocal.createInheritedMap), exactly once. it's a snapshot of whatever the creating thread had at that moment, which is useless with pools:

Main.java
import java.util.concurrent.*;
 
public class Main {
    static final InheritableThreadLocal<String> TENANT = new InheritableThreadLocal<>();
 
    public static void main(String[] args) throws Exception {
        TENANT.set("acme");
        Thread child = new Thread(() -> System.out.println("child thread sees " + TENANT.get()));
        child.start(); child.join();
 
        try (var pool = Executors.newFixedThreadPool(1)) {
            pool.submit(() -> {}).get();          // the pool thread is created now, under "acme"
            TENANT.set("globex");                 // next request, different tenant
            System.out.println("pool thread sees " + pool.submit(TENANT::get).get());
        }
    }
}
output, from jdk 25
child thread sees acme
pool thread sees acme

this one needs a real JVM (executors and futures), so it doesn't run in the browser. copy it into java Main.java to try it.

the pool thread was born while the tenant was acme, so it inherited acme, and it will say acme for the rest of its life. pools create threads lazily, at unpredictable moments, so which context a pool thread inherited is basically random.

the modern answer: ScopedValue

ScopedValue java 25+ (final in java 25, JEP 506) fixes the design instead of the symptoms. a value is bound for the duration of a call, it can't be changed, and it's unbound automatically when the call returns, so there's nothing to forget to remove:

Main.java
public class Main {
    static final ScopedValue<String> USER = ScopedValue.newInstance();
 
    static void handle() {
        System.out.println("handling for " + USER.get());
        System.out.println("bound inside? " + USER.isBound());
    }
 
    public static void main(String[] args) {
        ScopedValue.where(USER, "ada").run(Main::handle);
        System.out.println("bound after? " + USER.isBound());
    }
}
output
handling for ada
bound inside? true
bound after? false
ThreadLocalScopedValue
lifetimeuntil remove() or the thread diesexactly the run/call it's bound for
mutableyes, set() anywhereno. rebind in a nested scope instead
leaks on poolsyes, if you forget remove()can't, it unbinds itself
child threadscopies the whole map per thread (inheritable)shared cheaply with structured concurrency subtasks
keylines
  • the values live in the thread (Thread.threadLocals). the ThreadLocal is just the key.
  • the map uses open addressing and Fibonacci hashing (0x61c88647) so consecutive ThreadLocals don't collide.
  • keys are weak, values are strong. a dropped ThreadLocal leaves a stale entry whose value stays reachable.
  • pool threads never die: always remove() in a finally, or you leak memory and leak data between requests.
  • InheritableThreadLocal copies once, at thread creation. pools make that random.
  • ScopedValue (final in 25) has a lifetime you can't get wrong.