Scoped Values (JEP 446): Thread-Safe Context Without ThreadLocal
Preview Feature — Requires
--enable-previewat compile and runtime.
The ThreadLocal Problem at Scale
ThreadLocal has been the standard way to pass context (request ID, user session, database transaction) implicitly through a call stack without threading it through every method signature. It works well with a few hundred platform threads. With virtual threads, it breaks down.
Problem 1 — Memory overhead with inheritance
When you create a child thread with InheritableThreadLocal, Java copies the thread-local map from parent to child:
static InheritableThreadLocal<UserContext> CONTEXT = new InheritableThreadLocal<>();
// Parent sets context
CONTEXT.set(new UserContext("alice", "req-123"));
// Child gets a COPY — not a reference
Thread child = new Thread(() -> {
UserContext ctx = CONTEXT.get(); // independent copy
});
child.start();
With 1,000,000 virtual threads each inheriting context, you create 1,000,000 copies of the context object. For a UserContext object of 1 KB, that’s 1 GB of heap just for copies.
Problem 2 — Unbounded lifetime
ThreadLocal values live as long as the thread lives. A pooled platform thread that processes millions of requests will carry the thread-local value from request one forward unless explicitly cleared:
try {
CONTEXT.set(new UserContext(request));
handleRequest();
} finally {
CONTEXT.remove(); // easy to forget — causes bugs, memory leaks
}
Problem 3 — Mutability
Any code on the call stack can call CONTEXT.set(newValue), silently replacing the context for all subsequent callers in the same thread. This side effect is invisible to callers.
ScopedValue — Immutable, Bounded, Inherited by Reference
// Declare a ScopedValue — similar to declaring a ThreadLocal
static final ScopedValue<UserContext> CURRENT_USER = ScopedValue.newInstance();
// Bind a value for the duration of a method call
void handleRequest(Request request) {
UserContext ctx = new UserContext(request.userId(), request.traceId());
ScopedValue.where(CURRENT_USER, ctx)
.run(() -> {
processOrder(); // can call CURRENT_USER.get()
sendNotification(); // can call CURRENT_USER.get()
updateAnalytics(); // can call CURRENT_USER.get()
});
// Binding automatically gone when run() returns — no remove() needed
}
// Deep in the call stack
void sendNotification() {
UserContext ctx = CURRENT_USER.get(); // same object, no copy
emailService.send(ctx.email(), "Order confirmed");
}
How Scoped Values Work
flowchart TD
Bind["ScopedValue.where(CURRENT_USER, ctx).run(...)"]
scope["Scope — bounded to run() lifetime"]
M1["processOrder()\nCURRENT_USER.get() → ctx"]
M2["sendNotification()\nCURRENT_USER.get() → ctx"]
M3["updateAnalytics()\nCURRENT_USER.get() → ctx"]
End["run() returns\nBinding gone automatically"]
Bind --> scope --> M1 & M2 & M3 --> End
The binding is:
- Immutable — no
set()method; the value cannot be changed within the scope - Bounded — lives exactly as long as the
run()orcall()closure - Inherited by reference — child threads (forked inside the scope) share the same object, no copy
ScopedValue vs ThreadLocal
| Property | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable (has set()) | Immutable (no set()) |
| Lifetime | Thread lifetime (until remove()) | Scope lifetime (bounded to run()) |
| Child inheritance | Copies the value | Shares same reference (no copy) |
| Memory at 1M virtual threads | 1M copies of value | 1 shared object |
| Cleanup | Manual remove() in finally | Automatic |
| Clarity of flow | Implicit, mutable | Explicit binding, side-effect-free |
Inheritance with Structured Concurrency
Scoped values are automatically inherited by subtasks forked within a StructuredTaskScope:
static final ScopedValue<RequestContext> CTX = ScopedValue.newInstance();
void handleRequest(Request req) throws Exception {
var context = new RequestContext(req.traceId(), req.userId());
ScopedValue.where(CTX, context).run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var orderTask = scope.fork(() -> {
// Automatically inherits CTX binding — no copy
log.info("Order fetch trace={}", CTX.get().traceId());
return fetchOrder(CTX.get().userId());
});
var profileTask = scope.fork(() -> {
log.info("Profile fetch trace={}", CTX.get().traceId());
return fetchProfile(CTX.get().userId());
});
scope.join();
scope.throwIfFailed();
return new Response(orderTask.get(), profileTask.get());
}
});
}
All forked tasks see the same CTX object — no copying, no memory overhead.
Nested Scopes — Rebinding
A child scope can rebind a scoped value to a different value. The parent’s binding is restored when the child scope exits:
static final ScopedValue<String> ROLE = ScopedValue.newInstance();
void processAsAdmin() {
ScopedValue.where(ROLE, "USER").run(() -> {
System.out.println(ROLE.get()); // "USER"
ScopedValue.where(ROLE, "ADMIN").run(() -> {
System.out.println(ROLE.get()); // "ADMIN" — rebind within scope
});
System.out.println(ROLE.get()); // "USER" — parent value restored
});
}
Rebinding is useful for privilege escalation within a bounded operation — the escalated privilege cannot outlive the nested scope.
call() — Getting a Return Value
run() is for Runnable; call() is for Callable<T>:
String result = ScopedValue.where(CURRENT_USER, ctx)
.call(() -> {
return processAndReturnSummary(); // can throw checked exceptions
});
Checking if a ScopedValue is Bound
if (CURRENT_USER.isBound()) {
UserContext ctx = CURRENT_USER.get();
// safe to use
} else {
// ScopedValue not bound in this scope — handle gracefully
log.warn("No user context bound");
}
// orElse / orElseThrow
UserContext ctx = CURRENT_USER.orElse(UserContext.ANONYMOUS);
UserContext ctx = CURRENT_USER.orElseThrow(() -> new IllegalStateException("No user context"));
Practical: Web Request Context
@Component
public class RequestContextFilter implements Filter {
static final ScopedValue<WebRequestContext> REQUEST_CTX = ScopedValue.newInstance();
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpReq = (HttpServletRequest) req;
var ctx = new WebRequestContext(
httpReq.getHeader("X-Trace-Id"),
httpReq.getHeader("X-User-Id"),
httpReq.getMethod(),
httpReq.getRequestURI()
);
ScopedValue.where(REQUEST_CTX, ctx).run(() -> {
try {
chain.doFilter(req, res);
} catch (Exception e) {
throw new RuntimeException(e);
}
});
}
}
// Access anywhere in the request handling chain
@Service
public class OrderService {
public Order createOrder(OrderRequest req) {
String traceId = RequestContextFilter.REQUEST_CTX.get().traceId();
log.info("[{}] Creating order", traceId);
// ...
}
}
Enabling Preview Features
<compilerArgs>
<arg>--enable-preview</arg>
</compilerArgs>
java --enable-preview MyApp
Key Takeaways
ScopedValueis the modern replacement forThreadLocal— designed for virtual threads and structured concurrency- Bindings are immutable (no
set()), bounded (tied torun()lifetime), and inherited by reference (no copying) - With 1,000,000 virtual threads, one
ScopedValuebinding = one object shared by all — vs. 1,000,000ThreadLocalcopies - Nested
ScopedValue.where()rebinds the value for the inner scope; the outer value is restored automatically on exit - Forked tasks inside
StructuredTaskScopeautomatically inherit all scoped value bindings from the parent isBound(),orElse(), andorElseThrow()provide safe access withoutNullPointerException- Use
call()when the scoped operation must return a value
Next: Generational ZGC (JEP 439) — how generational garbage collection delivers sub-millisecond GC pauses at any heap size.