What is a ScopedValue in Java, and how do you bind and read one?
answer
- newInstance() makes the key; value lives only in a scope
- where(KEY, v).run(task) binds for the call's dynamic extent
- KEY.get() inside, NoSuchElementException if unbound
- immutable: no set(), only nested rebinding
- auto-cleanup on scope exit, no remove()
basics
~20 sA ScopedValue is a way to share a fixed piece of data with code called inside a block, without passing it as a parameter. You bind it with ScopedValue.where(KEY, value).run(task); inside that task, KEY.get() returns the value. Outside, it is unbound.
solid answer
~40 sA ScopedValue<T> is a holder for an immutable value that is available only for the duration of a bounded execution scope. You create a key once as a static final ScopedValue.newInstance(). To make a value visible, you wrap a block with ScopedValue.where(KEY, value).run(task) (or .call(task) for a checked-exception-throwing task). Any code reached from that block can read it via KEY.get(); KEY.isBound() tests whether it is set. When run/call returns, the binding is automatically torn down, so there is no manual cleanup and no risk of a stale value leaking. It is the modern, virtual-thread-friendly replacement for ThreadLocal for the common 'pass context implicitly down the call stack' use case.
go deeper
Knows a ScopedValue lets you share data with called code without passing parameters, and that you bind with where(...).run(...) and read with get().
Can explain dynamic extent, the immutability (no set), automatic teardown on scope exit, and using isBound/orElse to avoid NoSuchElementException.
Frames it as the virtual-thread-friendly replacement for ThreadLocal, explains nested rebinding semantics, and knows .run vs .call and binding multiple keys.
Discusses the design rationale (stack-disciplined immutable bindings, cheapness at millions of threads), migration strategy from ThreadLocal, and library/API implications of dynamically-scoped context.
## The problem ScopedValue solves Sometimes you need to make a piece of data — say the current authenticated user, a request id, or a transaction handle — available to many methods deep in a call chain, **without** threading it through every method signature as a parameter. The classic Java tool for this is `ThreadLocal`: a variable whose value is private to the current thread. But `ThreadLocal` has problems (covered in another question): it is *mutable*, its lifetime is *unbounded* (you must remember to `remove()` it), and it carries memory cost per thread — which becomes painful when you have millions of **virtual threads** (lightweight threads introduced in Java 21). **ScopedValue** (finalized in Java 25; preview in earlier releases via JEP 446/464/481/487) is the replacement designed for that world. ## What a ScopedValue is A `ScopedValue<T>` is just a **key** — think of it as a named slot. By itself it holds nothing. You declare one once, typically as a constant: ```java static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance(); ``` The value only exists while you are *inside a binding scope* you create explicitly. ## Binding: ScopedValue.where(...).run(...) To give the key a value, you wrap a region of code: ```java ScopedValue.where(CURRENT_USER, alice).run(() -> handleRequest()); ``` Read this as: "for the dynamic extent of `handleRequest()`, `CURRENT_USER` is bound to `alice`." The phrase **dynamic extent** (or dynamic scope) means *everything that executes as a consequence of that call* — `handleRequest`, every method it calls, and so on — not a lexical/textual region. Any of that code can do `CURRENT_USER.get()` and receive `alice`. Two terminators exist: - `.run(Runnable)` — for a task that returns nothing and throws only unchecked exceptions. - `.call(CallableOp)` — for a task that returns a value and/or throws a checked exception. You can bind several keys at once by chaining: `ScopedValue.where(A, x).where(B, y).run(...)`. ## Reading - `KEY.get()` returns the bound value, or throws `NoSuchElementException` if the key is not bound in the current scope. - `KEY.isBound()` returns `true`/`false` so you can check first. - `KEY.orElse(default)` returns the bound value or a fallback. ## Automatic, stack-disciplined lifetime The single most important property: **the binding is torn down automatically when `run`/`call` returns** (normally or by throwing). You never call `remove()`. Bindings nest like a stack: an inner `where` can *rebind* a key to a new value for its own sub-scope, and the previous value is restored when the inner scope exits. This makes a stale or accidentally-shared value structurally impossible, unlike a forgotten `ThreadLocal.remove()`. ## Immutability The value you bind is **immutable for that scope** — there is no `set()` method on a ScopedValue. If inner code needs a different value, it opens a *new* nested binding. This eliminates a whole class of action-at-a-distance bugs where some deep method mutates shared per-thread state that callers don't expect. ## Why this matters for virtual threads Because a binding is just a small immutable entry pushed/popped on a stack frame, it is cheap and requires no per-thread mutable storage to be allocated and reclaimed. With millions of short-lived virtual threads, that cheapness and the lack of cleanup obligation are what make ScopedValue the recommended approach. ## Summary mental model A ScopedValue is a *dynamically-scoped, immutable, automatically-cleaned-up* constant: you say "within this call, the answer to KEY is X," run the call, and the binding vanishes when the call returns.
- What happens if you call KEY.get() outside any binding scope?It throws NoSuchElementException. You can guard with KEY.isBound() or use KEY.orElse(default) to supply a fallback.
- How do you bind two scoped values at once?Chain where(): ScopedValue.where(A, x).where(B, y).run(task). Both are bound for the duration of the task.
saying these in an interview costs you the question
- Thinking a ScopedValue has a set() method — it is immutable; you rebind in a nested scope instead
- Believing the binding persists after run()/call() returns — it is torn down automatically
- Calling get() without checking isBound() and being surprised by NoSuchElementException
- Confusing the key (the ScopedValue instance) with the value (what you bind via where)