skip to content

Why should you avoid heavy or expensive ThreadLocal use with virtual threads, and what's the alternative?

level: seniorimportance: should knowfreq 45%

answer

  1. ThreadLocal = one value copy per thread
  2. Millions of VTs -> per-thread copies multiply memory
  3. Caching expensive objects per thread assumes reuse VTs don't have
  4. Keep ThreadLocals small + immutable, never pool via them
  5. Prefer ScopedValue: immutable, scoped, auto-cleaned, cheap to inherit

basics

~20 s

Each virtual thread keeps its own copy of every ThreadLocal value. With millions of virtual threads, expensive per-thread values multiply into a lot of memory. So avoid large/mutable ThreadLocals; prefer ScopedValue for sharing read-only context.

solid answer

~50 s

A ThreadLocal gives every thread its own copy of a value. That was fine when you had a few hundred pooled platform threads, but virtual threads exist in the millions and are one-per-task, so a costly ThreadLocal value (a buffer, a parser, a connection) is now potentially allocated millions of times — multiplying memory and undermining the cheapness that makes virtual threads worthwhile. Two anti-patterns to drop: using ThreadLocals to cache/pool expensive objects (that pooling assumption breaks when threads are disposable), and storing large mutable state per thread. The intended use of ThreadLocal — propagating immutable request-scoped context — is better served by ScopedValue (JEP 506, final in Java 25): it is immutable, scoped to a clear dynamic extent, automatically cleaned up, and far cheaper to inherit across structured-concurrency forks, with no leak risk from forgetting remove().

code

java · 19 lines
java
// Anti-pattern with virtual threads: an expensive per-thread cache.
// Across millions of one-shot virtual threads this allocates millions of buffers.
static final ThreadLocal<byte[]> BUFFER =
    ThreadLocal.withInitial(() -> new byte[64 * 1024]);

// Preferred: immutable, scope-bounded context via ScopedValue (Java 25).
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

void handle(Request req) {
    User user = authenticate(req);
    // Bind for exactly this dynamic scope; auto-removed on exit, no leak.
    ScopedValue.where(CURRENT_USER, user)
               .run(() -> process(req));
}

void process(Request req) {
    User u = CURRENT_USER.get(); // read the bound value deep in the call tree
    // ...
}

go deeper

for a junior

Knows each thread has its own ThreadLocal copy and that with many virtual threads this can use a lot of memory, so heavy ThreadLocals are discouraged.

for a middle

Explains that per-thread copies multiply across millions of virtual threads and that caching expensive objects per thread no longer pays off because virtual threads aren't reused.

for a senior

Distinguishes context-propagation from object-caching uses, recommends keeping ThreadLocals small/immutable, and names ScopedValue as the immutable, scope-bounded, leak-free alternative.

for a principal

Can lead a migration of context propagation from ThreadLocal to ScopedValue, reasons about inheritance under structured concurrency, weighs library/framework compatibility, and sets team conventions for per-request context at virtual-thread scale.

## What a ThreadLocal is A **`ThreadLocal<T>`** is a container that holds a *separate value per thread*. Calling `tl.get()` returns the value belonging to the *current* thread; `tl.set(v)` sets it for that thread only. It is the classic way to carry **per-thread context** — a user id, a transaction, a `SimpleDateFormat`, a reusable byte buffer — without passing it through every method parameter. ## Two historical uses of ThreadLocal 1. **Context propagation**: stash request-scoped data (current user, trace id) so deep code can read it without threading it through signatures. 2. **Caching expensive objects**: because there were few, long-lived pooled threads, people used a `ThreadLocal` as a *per-thread cache/pool* of an expensive, non-thread-safe object (e.g. a `SimpleDateFormat` or a scratch buffer) to avoid reallocating it on every call. Both uses quietly *assume threads are few and long-lived*. Virtual threads break that assumption. ## Why virtual threads change the calculus Virtual threads are **created one-per-task and exist in the millions**, then discarded. Consequences: - **Memory multiplies.** Each thread that touches a `ThreadLocal` gets its *own* copy of the value. A 64 KB buffer cached per thread is harmless across 200 pooled threads (~12 MB) but catastrophic across 1,000,000 virtual threads (~64 GB). The whole point of virtual threads is that each thread is *cheap*; a fat `ThreadLocal` makes each thread expensive again. - **The caching/pooling rationale evaporates.** Caching an object per thread only pays off if the thread is reused for many tasks. A virtual thread typically runs *one* task and dies, so the cached object is built, used once, and thrown away — pure overhead, not reuse. - **Leak risk under any leftover pooling.** `ThreadLocal.remove()` is easy to forget; in any pooled scenario stale values leak between unrelated tasks. ## The guideline - **Don't use ThreadLocal to cache/pool expensive objects** with virtual threads — that optimization depends on thread reuse you no longer have. - **Avoid large or mutable per-thread state**; keep any unavoidable `ThreadLocal` small and immutable. - For the legitimate remaining need — **propagating immutable, request-scoped context** — prefer **`ScopedValue`**. ## ScopedValue: the modern replacement **`ScopedValue<T>`** (JEP 506, finalized in **Java 25**; preview in earlier releases) is designed for the virtual-thread era. You **bind** a value for the dynamic duration of a piece of work and run code inside that scope; the binding is automatically removed when the scope exits: ```java ScopedValue.where(CURRENT_USER, user).run(() -> handleRequest()); ``` Key advantages over `ThreadLocal`: - **Immutable**: bound once for a scope, cannot be reassigned mid-scope, so reasoning is simpler and there is no stale-state hazard. - **Bounded lifetime / no leaks**: the value lives exactly for the dynamic extent of the `run`/`call` and is torn down automatically — no `remove()` to forget. - **Cheap inheritance**: child threads created with **structured concurrency** (`StructuredTaskScope`) inherit the binding cheaply by sharing it, rather than each child *copying* it as `InheritableThreadLocal` would. - **Lower footprint**, which matters precisely because there are millions of virtual threads. ## Summary ThreadLocal made sense when threads were few and pooled. Virtual threads are many and disposable, so per-thread values multiply memory and the caching rationale disappears. Keep ThreadLocals tiny and immutable, never use them to pool expensive objects, and prefer ScopedValue for immutable, scope-bounded, leak-free context propagation.

  • How does ScopedValue propagate to child tasks in structured concurrency, and why is that better than InheritableThreadLocal?
    Child threads forked inside a StructuredTaskScope inherit the binding by sharing the same immutable value, with no per-child copy. InheritableThreadLocal copies the value into every child, which is costly and mutable, so ScopedValue is cheaper and leak-free at virtual-thread scale.
  • Is all ThreadLocal use forbidden with virtual threads?
    No. Small, immutable, occasionally-read ThreadLocals are tolerable. The guidance targets heavy/expensive values and using ThreadLocal as a per-thread cache/pool of costly objects, since those multiply memory and assume thread reuse that disposable virtual threads don't provide.

saying these in an interview costs you the question

  • Using ThreadLocal as a per-thread object pool/cache under virtual threads (relies on thread reuse that no longer exists)
  • Assuming ThreadLocal memory is negligible — at millions of threads it is not
  • Forgetting ThreadLocal.remove() and assuming virtual threads make leaks impossible
  • Treating ScopedValue as a drop-in mutable replacement — it is immutable per binding by design

context