Java Scoped Values (Java 21+)
ScopedValue (preview in Java 20, finalized in Java 21 as JEP 446) is a new way to share immutable per-call-tree context (such as the current user, tenant ID, or request ID) without explicitly passing it through every method signature. Unlike ThreadLocal, a ScopedValue is immutable within its scope, automatically cleaned up when the scope exits, and inherited safely by child virtual threads.
Contents
Why Not ThreadLocal?
ThreadLocalScopedValue
/*
ThreadLocal problems:
─────────────────────────────────────────────────────────
1. Memory leaks — values live as long as the thread,
not as long as the logical operation. In thread pools
a previous request's data can leak into a new one
unless remove() is called explicitly.
2. Mutability — any code can call set() at any time,
making it hard to reason about what the current value is.
3. Poor virtual-thread fit — ThreadLocal was designed for
platform threads. With millions of virtual threads, the
per-thread storage overhead becomes significant.
─────────────────────────────────────────────────────────
*/
Basic Usage
Declare a static ScopedValueRunnableCallableScopedValue.where().run()
import java.lang.ScopedValue;
public class RequestContext {
// Declare as a static final constant — one per logical context value
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
}
// Bind values at the entry point (e.g. a servlet filter or web handler)
ScopedValue.where(RequestContext.USER_ID, "alice")
.where(RequestContext.REQUEST_ID, "req-abc-123")
.run(() -> {
// All code called from here can access the scoped values
orderService.processOrder(orderId);
});
run()call()Reading the Value
Call .get().isBound()
public class OrderService {
public void processOrder(String orderId) {
// Read from any depth in the call stack
String userId = RequestContext.USER_ID.get();
String requestId = RequestContext.REQUEST_ID.get();
System.out.printf("[%s] User %s processing order %s%n",
requestId, userId, orderId);
auditLog.record(userId, orderId);
}
}
public class AuditLog {
public void record(String userId, String orderId) {
// Or read directly from the scoped value — no parameters needed
if (RequestContext.REQUEST_ID.isBound()) {
String reqId = RequestContext.REQUEST_ID.get();
System.out.println("Audit [" + reqId + "]: " + userId + " → " + orderId);
}
}
}
Rebinding Within a Scope
Inner ScopedValue.where().run()
ScopedValue.where(RequestContext.USER_ID, "alice").run(() -> {
System.out.println(RequestContext.USER_ID.get()); // alice
// Shadow with a different value for a sub-operation
ScopedValue.where(RequestContext.USER_ID, "system").run(() -> {
System.out.println(RequestContext.USER_ID.get()); // system
systemTask();
});
System.out.println(RequestContext.USER_ID.get()); // alice (restored)
});
ThreadLocal.set()Inheritance with Virtual Threads
Child virtual threads started inside a ScopedValueInheritableThreadLocal
ScopedValue.where(RequestContext.USER_ID, "alice").run(() -> {
// Spawn a virtual thread — it inherits USER_ID = "alice"
Thread.ofVirtual().start(() -> {
String user = RequestContext.USER_ID.get(); // "alice"
System.out.println("Virtual thread sees user: " + user);
}).join();
});
Integration with Structured Concurrency
ScopedValueStructuredTaskScope
import java.util.concurrent.StructuredTaskScope;
ScopedValue.where(RequestContext.USER_ID, "alice").run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var orderTask = scope.fork(() -> fetchOrders(RequestContext.USER_ID.get()));
var profileTask = scope.fork(() -> fetchProfile(RequestContext.USER_ID.get()));
scope.join().throwIfFailed();
System.out.println("Orders: " + orderTask.get());
System.out.println("Profile: " + profileTask.get());
}
});
ScopedValue vs ThreadLocal
Use ScopedValue
/*
ThreadLocal ScopedValue (Java 21+)
─────────────────────────────────────────────────────────────
Mutable — any code can set() Immutable within a scope
Manual remove() required Auto-cleaned at scope exit
Lives as long as the thread Lives only for the scope
Memory per thread Memory per scope (cheaper)
InheritableThreadLocal for child Inherited by child threads
threads (complex semantics) automatically (clean)
Works with platform threads Designed for virtual threads
─────────────────────────────────────────────────────────────
Migrate from ThreadLocal:
• Replace static ThreadLocal<T> with static ScopedValue<T>
• Replace set() + remove() calls with ScopedValue.where().run()
• Replace get() calls with .get() (same API)
*/