What is ThreadLocal, and why can it cause a memory leak? — Cracked Java
// Concurrency & Multithreading · Thread-Safety Patterns
SeniorTheoryTrickBig TechAmazonGoogle

What is ThreadLocal, and why can it cause a memory leak?

ThreadLocal<T> gives each thread its own independent copy of a variable: get() and set() read and write a value scoped to the calling thread, so the value is implicitly confined and needs no synchronization. It causes leaks on long-lived (pooled) threads when entries are never removed.

How it works and what it's for

Each Thread carries a ThreadLocalMap field. A ThreadLocal instance is the key into that per-thread map. So the value lives on the thread, keyed by the ThreadLocal object. Typical uses: per-thread non-thread-safe tools (SimpleDateFormat), per-request context (user/trace IDs in a web request bound to its worker thread), and avoiding parameter-passing through deep call stacks.

private static final ThreadLocal<SimpleDateFormat> FMT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

String format(Date d) {
    try {
        return FMT.get().format(d); // each thread has its own formatter
    } finally {
        // on a pooled thread, leave it set; on truly per-task state, remove()
    }
}

The leak

The danger lives in that ThreadLocalMap. Its keys are weak references to the ThreadLocal object, but its values are strong references. On a thread pool, worker threads are reused indefinitely and never die, so their ThreadLocalMap never gets discarded. If you set() a large value and never remove() it:

  • The value is strongly held by the still-alive thread's map, so it can never be garbage-collected.
  • Worse, if the ThreadLocal key becomes unreachable, the entry becomes a stale entry: a null key with a live value. The map only cleans these opportunistically, so memory accumulates.
  • If the value (or its classloader) belongs to a redeployed web app, this also pins the classloader — the classic "PermGen/Metaspace leak after redeploy."

The fix

Always pair set() with remove() in a finally block at the end of the unit of work (e.g. a servlet filter or request boundary on a pooled thread):

ctx.set(requestId);
try {
    handle(request);
} finally {
    ctx.remove(); // critical on pooled threads
}

Mark your status