Scoped values in Java
Java 25 ScopedValue as an immutable, bounded alternative to ThreadLocal.
Bound to a call, not a thread
A ScopedValue (final in Java 25) shares an immutable value with everything a call touches. Bind it with ScopedValue.where(KEY, value).run(...); inside, KEY.get() returns the value, in every method that call reaches. When run() returns, the binding is gone.
static final ScopedValue<String> USER =
ScopedValue.newInstance();
ScopedValue.where(USER, "alice")
.run(() -> handle());
// inside handle(): USER.get() is "alice"Your turn
What does this print?
var who = ScopedValue.<String>newInstance();
System.out.println(who.isBound());
ScopedValue.where(who, "bob")
.run(() -> System.out.println(who.get()));
System.out.println(who.orElse("nobody"));false bob nobodyfalse bob bobtrue bob nobody
Show the answer
Before binding, isBound() is false. Inside run(), get() sees "bob". After run() returns the binding has vanished, so orElse falls back to "nobody".
No set(), no remove()
A scoped value is immutable: there's no set() and no remove(), so there's nothing to forget and nothing to leak. Need a different value for a sub-call? Rebind it in a nested scope. The inner binding temporarily shadows the outer one; when the inner run() returns, the outer value is back.
ScopedValue.where(LVL, 1).run(() -> {
ScopedValue.where(LVL, 2).run(() ->
log(LVL.get())); // 2
log(LVL.get()); // 1 again
});get() outside a binding
Calling get() when no binding exists doesn't return null: it throws **NoSuchElementException**. If a missing value is expected, check isBound() first or use orElse(default).
ThreadLocal vs ScopedValue
USER.set(name);
try {
handle();
} finally {
USER.remove(); // easy to forget
}Mutable. Lasts until removed or until the thread dies; cleanup is manual.
ScopedValue.where(USER, name)
.run(() -> handle());Immutable. Lasts only for that call; cleanup is automatic.
Shared, not copied
InheritableThreadLocal copies its value into every child thread. With a million virtual threads and a large context, memory spikes. Scoped values are immutable, so child subtasks in a structured scope share the parent's binding without copying, and nothing lingers after the call.
Where it fits
Use scoped values for the same jobs as ThreadLocal context: the current user, tenant, request id or trace id, set once at the edge of a request and read deep inside. With virtual threads you may run a million concurrent requests, so cheap, leak-proof context matters.
Key takeaways
- static final ScopedValue<User> USER = ScopedValue.newInstance();
- where(USER, u).run(...) binds the value for that call only
- Immutable: rebind in a nested scope instead of calling set()
- get() outside a binding throws NoSuchElementException
ScopedValue went through an incubator round and four previews (Java 20 to 24) before becoming final in Java 25.
Practice questions
What does this print?
var user = ScopedValue.<String>newInstance();
ScopedValue.where(user, "alice")
.run(() -> System.out.println(user.get()));
System.out.println(user.isBound());- alice false
- alice true
- null false
- alice alice
Check your answer
alice false. Inside run(), the value is bound to "alice". As soon as run() returns, the binding is gone, so isBound() is false.
What does this print?
var lvl = ScopedValue.<Integer>newInstance();
ScopedValue.where(lvl, 1).run(() -> {
ScopedValue.where(lvl, 2).run(() ->
System.out.println(lvl.get()));
System.out.println(lvl.get());
});- 2 1
- 2 2
- 1 1
- 1 2
Check your answer
2 1. A nested where(...).run() temporarily shadows the outer binding. When the inner run() returns, the outer value 1 is visible again.