Scoped Values (JEP 506): The Final ThreadLocal Replacement
The ThreadLocal Problem
ThreadLocal has been the standard way to pass contextual data through a call chain without polluting method signatures since Java 1.2. Every Java developer has seen it used for things like:
- Web request context (user ID, correlation ID, tenant ID)
- Database transaction binding
- Security principal propagation
- Logging MDC (Mapped Diagnostic Context)
Here is the classic pattern:
public class RequestContext {
// ThreadLocal stores one value per thread
private static final ThreadLocal<String> CURRENT_USER = new ThreadLocal<>();
public static void set(String userId) {
CURRENT_USER.set(userId);
}
public static String get() {
return CURRENT_USER.get();
}
public static void clear() {
CURRENT_USER.remove(); // MUST call this or you get memory leaks
}
}
// In a servlet / filter
public void doFilter(HttpServletRequest req, HttpServletResponse res, FilterChain chain) {
try {
RequestContext.set(req.getHeader("X-User-ID"));
chain.doFilter(req, res);
} finally {
RequestContext.clear(); // forget this and leak on thread pool reuse
}
}
// Deep in a service layer — no need to pass userId as a parameter
public Order createOrder(Cart cart) {
String userId = RequestContext.get(); // retrieved without being passed in
return new Order(userId, cart.getItems());
}
Why ThreadLocal Breaks with Virtual Threads
Java 21 introduced Virtual Threads — lightweight threads that can number in the millions. ThreadLocal was designed for platform threads (pools of ~200 threads). With virtual threads:
Memory explosion: Each
ThreadLocalvalue is stored in the thread’sthreadLocalsmap. With 100,000 virtual threads, a singleThreadLocal<byte[]>with a 1KB value uses 100MB just for that one context variable.Mutability is a bug magnet:
ThreadLocalis mutable. Any code along the call chain can write to it. In concurrent code this causes subtle, hard-to-reproduce bugs.No inheritance model: Child threads don’t inherit
ThreadLocalvalues from their parent (unless you useInheritableThreadLocal, which has its own problems with thread pools).Leak risk: Forgetting
remove()leaks data when threads are pooled and reused.
ScopedValue: The Design
ScopedValue was designed from scratch for the virtual thread era. It has four key properties:
- Immutable within a scope — once bound, the value cannot be changed
- Automatically cleaned up — when the scope ends, the binding disappears
- Inherited by child threads — structured concurrency tasks automatically see the parent’s bindings
- Zero mutation — a child scope can shadow a value with a new binding, but never mutate the parent’s binding
Basic Usage
import java.lang.ScopedValue;
public class RequestContext {
// Declare a ScopedValue — it's a carrier, not a container
public static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
public static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
}
Binding and Running
// In a web filter / entry point
String userId = request.getHeader("X-User-ID");
String correlationId = UUID.randomUUID().toString();
ScopedValue.where(RequestContext.CURRENT_USER, userId)
.where(RequestContext.CORRELATION_ID, correlationId)
.run(() -> handleRequest(request, response));
// When run() returns, bindings are gone — no cleanup needed
Reading
// Deep in service layer
public Order createOrder(Cart cart) {
// .get() throws NoSuchElementException if not bound — fail fast
String userId = RequestContext.CURRENT_USER.get();
String correlId = RequestContext.CORRELATION_ID.get();
log.info("[{}] Creating order for user {}", correlId, userId);
return new Order(userId, cart.getItems());
}
ThreadLocal vs. ScopedValue: Side-by-Side
// ─── ThreadLocal approach ───────────────────────────────────────────────────
private static final ThreadLocal<String> USER_TL = new ThreadLocal<>();
void handleWithThreadLocal(String userId, Runnable task) {
USER_TL.set(userId);
try {
task.run();
} finally {
USER_TL.remove(); // manual cleanup required
}
}
String getUserThreadLocal() {
return USER_TL.get(); // returns null if not set — silent failure
}
// ─── ScopedValue approach ───────────────────────────────────────────────────
private static final ScopedValue<String> USER_SV = ScopedValue.newInstance();
void handleWithScopedValue(String userId, Runnable task) {
ScopedValue.where(USER_SV, userId).run(task);
// No finally needed — scope cleanup is automatic
}
String getUserScopedValue() {
return USER_SV.get(); // throws NoSuchElementException — fail fast, no silent null
}
Real-World Example: Multi-Tenant Web Application
import module java.base;
public class MultiTenantApp {
public static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
public static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
// ── Entry point (simulate a web filter) ───────────────────────────────
public void handleRequest(String tenantId, String userId) {
String requestId = UUID.randomUUID().toString().substring(0, 8);
ScopedValue.where(TENANT_ID, tenantId)
.where(USER_ID, userId)
.where(REQUEST_ID, requestId)
.run(this::processRequest);
}
private void processRequest() {
log("Starting request");
var items = fetchItems();
var order = buildOrder(items);
log("Order created: " + order);
}
// ── Service layer — no userId parameter needed ─────────────────────────
private List<String> fetchItems() {
log("Fetching items for tenant " + TENANT_ID.get());
return List.of("Widget A", "Widget B", "Widget C");
}
private String buildOrder(List<String> items) {
log("Building order for user " + USER_ID.get());
return "ORDER-" + REQUEST_ID.get() + " [" + String.join(", ", items) + "]";
}
private void log(String message) {
System.out.printf("[%s][tenant=%s][user=%s] %s%n",
REQUEST_ID.get(), TENANT_ID.get(), USER_ID.get(), message);
}
public static void main(String[] args) {
var app = new MultiTenantApp();
// Simulate two concurrent requests for different tenants
Thread.ofVirtual().start(() -> app.handleRequest("acme-corp", "alice"));
Thread.ofVirtual().start(() -> app.handleRequest("globex", "bob"));
}
}
Output (order may vary):
[a3f1b2c4][tenant=acme-corp][user=alice] Starting request
[d7e9f0a1][tenant=globex][user=bob] Starting request
[a3f1b2c4][tenant=acme-corp][user=alice] Fetching items for tenant acme-corp
[d7e9f0a1][tenant=globex][user=bob] Fetching items for tenant globex
[a3f1b2c4][tenant=acme-corp][user=alice] Building order for user alice
[d7e9f0a1][tenant=globex][user=bob] Building order for user bob
[a3f1b2c4][tenant=acme-corp][user=alice] Order created: ORDER-a3f1b2c4 [Widget A, Widget B, Widget C]
[d7e9f0a1][tenant=globex][user=bob] Order created: ORDER-d7e9f0a1 [Widget A, Widget B, Widget C]
Each virtual thread has its own isolated binding. No cross-contamination between tenants.
Rebinding (Shadowing) a ScopedValue
You cannot mutate a bound ScopedValue, but you can create a child scope with a new binding. The child scope sees the new value; the parent scope is unaffected:
public static final ScopedValue<String> ROLE = ScopedValue.newInstance();
void main() {
ScopedValue.where(ROLE, "user").run(() -> {
System.out.println("Outer role: " + ROLE.get()); // user
// Temporarily elevate role for an admin operation
ScopedValue.where(ROLE, "admin").run(() -> {
System.out.println("Inner role: " + ROLE.get()); // admin
});
System.out.println("Outer role after: " + ROLE.get()); // user (unchanged)
});
}
Output:
Outer role: user
Inner role: admin
Outer role after: user
This is safe by design — a callee can never corrupt the caller’s context.
ScopedValue with Structured Concurrency
Scoped Values integrate seamlessly with Structured Concurrency (JEP 505). Child tasks automatically inherit the parent’s bindings:
import java.util.concurrent.StructuredTaskScope;
public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void processParallel() throws Exception {
ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var task1 = scope.fork(() -> {
// Child task SEES parent's REQUEST_ID binding automatically
return "Task1[" + REQUEST_ID.get() + "] done";
});
var task2 = scope.fork(() -> {
return "Task2[" + REQUEST_ID.get() + "] done";
});
scope.join().throwIfFailed();
System.out.println(task1.get());
System.out.println(task2.get());
}
});
}
Output:
Task1[req-42] done
Task2[req-42] done
With ThreadLocal and virtual threads, this context propagation required manual copying. With ScopedValue, it is automatic.
Migration Guide: ThreadLocal → ScopedValue
| ThreadLocal | ScopedValue |
|---|---|
ThreadLocal.withInitial(() -> x) | ScopedValue.newInstance() (no default — use .orElse()) |
tl.set(value) | ScopedValue.where(sv, value).run(...) |
tl.get() | sv.get() (throws if unbound) or sv.orElse(defaultValue) |
tl.remove() | Automatic — scope exit cleans up |
| Mutable mid-scope | Not possible — re-bind in a child scope |
| Child thread inherits | Only with InheritableThreadLocal |
Not every ThreadLocal can be replaced immediately — if your code reads and writes the same ThreadLocal from multiple points in a call chain (mutation pattern), you need a different approach (a mutable holder object inside a ScopedValue, or a redesign).
Summary
ScopedValue (JEP 506) is now final in Java 25. For any code that passes context through deep call chains — web request data, correlation IDs, security principals, tenant IDs — it is the correct tool. It is safer than ThreadLocal, scales to millions of virtual threads, and eliminates an entire class of leak and mutation bugs.
Next up: Structured Concurrency →