ByteScrollGet the app
☰ Topics
scopedvalue-vs-threadlocal8 / 200‹›
JAVA / CONCURRENCY2 minute read

Java 21 ScopedValue vs ThreadLocal

Hard

ThreadLocal is a mutable per-thread slot that lives until you remove it. ScopedValue binds an immutable value for one block of code and unbinds it when the block ends, and is cheap with many virtual threads. Preview in Java 21, final in Java 25.

How it works

ThreadLocal

  • Each Thread holds a map from ThreadLocal to value. set(), get(), remove() work on the current thread's copy.
  • Any code can call set() at any time, so you can't tell who changed it.
  • The value stays until remove() or the thread dies. In a pool, threads never die.
  • InheritableThreadLocal copies values into every child thread at creation, which is costly with lots of threads.

ScopedValue (preview in 21 via JEP 446, standard in Java 25 via JEP 506)

  • ScopedValue.where(KEY, value).run(task) binds KEY only while task runs, on this thread.
  • Inside, KEY.get() returns the value. There's no set(). A nested where(KEY, other) can shadow it for an inner block.
  • When run returns or throws, the binding is gone. Nothing to clean up, nothing to leak.
  • Child tasks forked in a StructuredTaskScope see the parent's bindings without copying them. (StructuredTaskScope is still a preview API.)
RepositoryOrderServiceRequest handlerRepositoryOrderServiceRequest handlerwhere(REQUEST_ID, "r-42").run(...)placeOrder()save()REQUEST_ID.get() returns "r-42"donedonescope ends, REQUEST_ID unbound
RepositoryOrderServiceRequest handlerRepositoryOrderServiceRequest handlerwhere(REQUEST_ID, "r-42").run(...)placeOrder()save()REQUEST_ID.get() returns "r-42"donedonescope ends, REQUEST_ID unbound

Example

Example.javaJava
// ThreadLocal: you must clean up yourself
static final ThreadLocal<String> TENANT = new ThreadLocal<>();

void handleOld(Request req) {
    TENANT.set(req.tenant());
    try {
        billing.charge(req);       // deep code calls TENANT.get()
    } finally {
        TENANT.remove();           // forget this and the next request on this thread sees it
    }
}

// ScopedValue (Java 25, or Java 21 with --enable-preview)
static final ScopedValue<String> TENANT_SV = ScopedValue.newInstance();

void handleNew(Request req) {
    ScopedValue.where(TENANT_SV, req.tenant())
               .run(() -> billing.charge(req)); // deep code calls TENANT_SV.get()
}

Edge cases

  • get() on an unbound ScopedValue throws NoSuchElementException. Check with isBound() or use orElse(fallback).
  • Bindings don't flow into tasks you hand to an ordinary ExecutorService or a new Thread. Only StructuredTaskScope forks inherit them.
  • To return a result, use call(...) instead of run(...). On Java 21 the static helpers like ScopedValue.runWhere existed in preview and were removed later, so prefer the where(...).run(...) form.
  • The value itself is shared, not copied. Bind an immutable object, or the "immutable" binding can still be mutated through it.

Common mistakes

  • Treating ScopedValue as a drop-in ThreadLocal. It can't be set later in the call chain; caches that fill lazily per thread still need ThreadLocal.
  • Saying ScopedValue is final in 21. It's a preview there and needs --enable-preview.
  • Assuming virtual threads make ThreadLocal free. A million virtual threads each with their own copy is a million copies.

Likely follow-up

"Why was ScopedValue added alongside virtual threads?" Virtual threads are created per task in huge numbers. Per-thread mutable maps and inheritable copies scale badly there, while a scoped, read-only binding costs little and is shared cheaply with structured child tasks.

Get every deep dive in the app

Coming soon to the App StoreComing soon to Google Play