ByteScrollGet the app
☰ Topics
threadlocal-leak10 / 200‹›
JAVA / CONCURRENCY3 minute read

ThreadLocal — what it is and the classic memory leak

Hard

A 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

  1. Storage lives on the thread. Every Thread has a field threadLocals, a ThreadLocalMap. tl.set(v) puts tl → v into the current thread's map.
  2. Weak key, strong value. Each map entry holds the ThreadLocal through a WeakReference, but holds the value strongly.
  3. 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).
  4. Leak 2: lost ThreadLocal. If the ThreadLocal object 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.
  5. 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.
weak refstrong refPool thread(lives forhours)ThreadLocalMapEntryThreadLocalkeyValue: 5 MBreport bufferits class →web appClassLoader
weak refstrong refPool thread(lives forhours)ThreadLocalMapEntryThreadLocalkeyValue: 5 MBreport bufferits class →web appClassLoader

Example

Example.javaJava
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; only remove() deletes it.
  • InheritableThreadLocal copies 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 ThreadLocal fields static 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 in finally.
  • Saying the weak reference prevents leaks. It only covers the key; the value is still strongly held.
  • Using ThreadLocal to 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