ThreadLocal — what it is and the classic memory leak
HardA ThreadLocal is a slot where every thread reads and writes a separate value, stored in a map on the Thread object itself. Pool threads never die, so without remove() the value outlives the request: memory leaks and the next task sees stale data.
How it works
- Storage lives on the thread. Every
Threadhas a fieldthreadLocals, aThreadLocalMap.tl.set(v)putstl → vinto the current thread's map. - Weak key, strong value. Each map entry holds the
ThreadLocalthrough aWeakReference, but holds the value strongly. - Leak 1: long-lived threads. In a pool, the same thread serves request after request. If nobody calls
remove(), the value stays reachable from the thread forever (and the next task on that thread sees it). - Leak 2: lost
ThreadLocal. If theThreadLocalobject itself becomes garbage, the weak key is cleared but the entry, and its value, stays until some later map operation happens to clean up that slot. - Leak 3: classloaders. On an app server, a value whose class came from a web app keeps that app's whole classloader alive after an undeploy. Redeploy a few times and Metaspace runs out.
Example
final class TenantHolder {
private static final ThreadLocal<String> TENANT_ID = new ThreadLocal<>();
static void bind(String tenantId) { TENANT_ID.set(tenantId); }
static String tenant() { return TENANT_ID.get(); }
static void unbind() { TENANT_ID.remove(); }
}
// Runs on a pooled worker thread for each incoming job
void runJob(Job job) {
TenantHolder.bind(job.tenantId());
try {
invoiceService.generate(job); // reads TenantHolder.tenant() deep inside
} finally {
TenantHolder.unbind(); // skip this and the next job on this thread bills the wrong tenant
}
}
// Per-thread reusable object, a legitimate use
private static final ThreadLocal<MessageDigest> SHA256 =
ThreadLocal.withInitial(() -> {
try { return MessageDigest.getInstance("SHA-256"); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
});Edge cases
set(null)doesn't free the entry; onlyremove()deletes it.InheritableThreadLocalcopies values when a thread is created. Pool threads are created once, so tasks later submitted to them get whatever was copied back then, not the submitter's current value.- Virtual threads support
ThreadLocal, but each of possibly millions of threads gets its own copy. Don't use it to cache expensive objects there. - Declare
ThreadLocalfieldsstatic final. An instance field creates a new key per object and multiplies entries.
Common mistakes
- Calling
remove()at the end of the happy path instead of infinally. - Saying the weak reference prevents leaks. It only covers the key; the value is still strongly held.
- Using
ThreadLocalto pass data between threads. It does the opposite.
Likely follow-up
"How do frameworks like Spring use it?" Transaction state, security context and request attributes are kept in ThreadLocals and cleared by the framework when the request or transaction ends. Code that hops to another thread (@Async, CompletableFuture) loses that context unless it's copied over explicitly.
Get every deep dive in the app
Coming soon to the App StoreComing soon to Google Play