ScopedValue is an immutable, dynamically-scoped way to share context (current user, request ID, transaction) with a thread and its subtasks. It is the Loom-era replacement for ThreadLocal: instead of a mutable per-thread slot that you set and must remember to clear, you bind a value for the duration of a run call, and it is automatically unbound when that call returns.
Why ThreadLocal is a poor fit for Loom
ThreadLocal has three problems amplified by virtual threads:
- Mutable and unbounded lifetime — anyone can
setit, and if you forget toremove()it leaks, especially on pooled threads. - Memory cost at scale — a
ThreadLocalvalue is retained per thread; with millions of virtual threads that adds up fast. - Awkward inheritance —
InheritableThreadLocalcopies values to children eagerly, which is expensive and surprising.
How ScopedValue works
A ScopedValue is bound for a bounded region. Inside the region, get() returns the value; outside it, the value simply does not exist. Immutability means there is no set to forget to undo and no visibility hazard.
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handle(Request req) {
User user = authenticate(req);
ScopedValue.where(CURRENT_USER, user)
.run(() -> processRequest(req)); // bound only inside run()
// CURRENT_USER is unbound again here
}
void processRequest(Request req) {
User u = CURRENT_USER.get(); // reads the binding from the dynamic scope
...
}
Inheritance to forked subtasks
The key synergy with structured concurrency: scoped values are automatically and cheaply visible to subtasks forked inside the bound region via StructuredTaskScope. No eager copying — children read the parent's binding directly. So a request's context flows to all its fan-out subtasks for free.
Trade-offs
ScopedValue cannot be mutated in place, so code that relied on ThreadLocal.set mid-flight must be restructured around nested scopes. As of Java 21–24 it has been a preview API alongside structured concurrency, so confirm availability for your JDK.