skip to content

What is a ScopedValue in Java, and how do you bind and read one?

level: juniorimportance: should knowfreq 35%

answer

  1. newInstance() makes the key; value lives only in a scope
  2. where(KEY, v).run(task) binds for the call's dynamic extent
  3. KEY.get() inside, NoSuchElementException if unbound
  4. immutable: no set(), only nested rebinding
  5. auto-cleanup on scope exit, no remove()

basics

~20 s

A 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 s

A 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

for a junior

Knows a ScopedValue lets you share data with called code without passing parameters, and that you bind with where(...).run(...) and read with get().

for a middle

Can explain dynamic extent, the immutability (no set), automatic teardown on scope exit, and using isBound/orElse to avoid NoSuchElementException.

for a senior

Frames it as the virtual-thread-friendly replacement for ThreadLocal, explains nested rebinding semantics, and knows .run vs .call and binding multiple keys.

for a principal

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)

context