Java 21 ScopedValue vs ThreadLocal
HardThreadLocal 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
Threadholds a map fromThreadLocalto 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. InheritableThreadLocalcopies 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)bindsKEYonly whiletaskruns, on this thread.- Inside,
KEY.get()returns the value. There's noset(). A nestedwhere(KEY, other)can shadow it for an inner block. - When
runreturns or throws, the binding is gone. Nothing to clean up, nothing to leak. - Child tasks forked in a
StructuredTaskScopesee the parent's bindings without copying them. (StructuredTaskScopeis still a preview API.)
Example
// 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 unboundScopedValuethrowsNoSuchElementException. Check withisBound()or useorElse(fallback).- Bindings don't flow into tasks you hand to an ordinary
ExecutorServiceor anew Thread. OnlyStructuredTaskScopeforks inherit them. - To return a result, use
call(...)instead ofrun(...). On Java 21 the static helpers likeScopedValue.runWhereexisted in preview and were removed later, so prefer thewhere(...).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
ScopedValueas a drop-inThreadLocal. It can't be set later in the call chain; caches that fill lazily per thread still needThreadLocal. - Saying ScopedValue is final in 21. It's a preview there and needs
--enable-preview. - Assuming virtual threads make
ThreadLocalfree. 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