⚡ Advanced Concurrency · Advanced

Scoped values in Java

Java 25 ScopedValue as an immutable, bounded alternative to ThreadLocal.

🧩 The mysteryThreadLocal leaked Alice's name into Bob's request because someone forgot remove(). What if a value simply couldn't outlive the call that set it?

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"
🔮 Predict it

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"));
  1. false bob nobody
  2. false bob bob
  3. true 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
});
⚠️ The trap

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

✗ ThreadLocal
USER.set(name);
try {
    handle();
} finally {
    USER.remove();   // easy to forget
}

Mutable. Lasts until removed or until the thread dies; cleanup is manual.

✓ ScopedValue
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.

💼 In the real world

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

  1. static final ScopedValue<User> USER = ScopedValue.newInstance();
  2. where(USER, u).run(...) binds the value for that call only
  3. Immutable: rebind in a nested scope instead of calling set()
  4. get() outside a binding throws NoSuchElementException
🤯 Did you know?

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());
  1. alice false
  2. alice true
  3. null false
  4. 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());
});
  1. 2 1
  2. 2 2
  3. 1 1
  4. 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.

Next: how many threads should a pool have? 8? 80? 800? One small formula, and a story about a pool that deadlocks itself.