Java 21 — Scoped Values — The ThreadLocal Replacement
Understand Java's Scoped Values — why they replace ThreadLocal, how they work with virtual threads, and practical patterns for request-scoped data.
ThreadLocal has been Java’s way to pass context without method parameters since Java 1.2. But with virtual threads (potentially millions of them), ThreadLocal becomes a problem — each virtual thread gets its own copy, consuming memory and creating confusion about lifecycle.
Scoped Values are the replacement. They’re immutable within a scope, automatically cleaned up, and designed for virtual threads.
The problem with ThreadLocal
static final ThreadLocal<User> currentUser = new ThreadLocal<>();
void handleRequest(User user) {
currentUser.set(user);
try {
processRequest();
} finally {
currentUser.remove(); // easy to forget
}
}
void processRequest() {
User user = currentUser.get(); // works
// ...
}
Issues:
- Mutable — any code can call
set()and change the value - Manual cleanup — forget
remove()and you have a memory leak - Thread pool reuse — if the thread is reused, the old value leaks to the next request
- Virtual thread overhead — millions of virtual threads × ThreadLocal storage = memory pressure
Scoped Values — The fix
import java.lang.ScopedValue;
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handleRequest(User user) {
ScopedValue.runWhere(CURRENT_USER, user, () -> {
processRequest();
});
// After runWhere completes, CURRENT_USER is automatically unbound
}
void processRequest() {
User user = CURRENT_USER.get(); // works
// CURRENT_USER.set(newUser); // COMPILE ERROR — no set method
}
Key differences:
- Immutable within scope — no
set()method, value can’t change - Automatic cleanup — when
runWherefinishes, the binding is removed - Inherited by child threads — structured concurrency subtasks see the parent’s scoped values
- No memory leak — no way to forget cleanup
Basic usage
Binding a value
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void handleRequest(HttpServletRequest request) {
String requestId = request.getHeader("X-Request-ID");
ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
// REQUEST_ID is bound here
processRequest();
});
// REQUEST_ID is unbound here
}
Reading a value
void processRequest() {
if (REQUEST_ID.isBound()) {
String id = REQUEST_ID.get();
logger.info("Processing request: {}", id);
}
}
isBound() checks if the value is available in the current scope. get() throws NoSuchElementException if not bound.
With return value
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
String processWithResult(User user) throws Exception {
return ScopedValue.callWhere(CURRENT_USER, user, () -> {
return "Processed for " + CURRENT_USER.get().name();
});
}
callWhere is like runWhere but returns a value.
Multiple bindings
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
static final ScopedValue<String> TENANT_ID = ScopedValue.newInstance();
void handleRequest(User user, String tenantId) {
ScopedValue
.where(CURRENT_USER, user)
.where(TENANT_ID, tenantId)
.run(() -> {
processRequest();
});
}
With structured concurrency
Scoped values are inherited by StructuredTaskScope subtasks:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
void handleRequest(String requestId) throws Exception {
ScopedValue.runWhere(REQUEST_ID, requestId, () -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Both subtasks see REQUEST_ID
var user = scope.fork(() -> {
logger.info("Fetching user for request: {}", REQUEST_ID.get());
return fetchUser();
});
var orders = scope.fork(() -> {
logger.info("Fetching orders for request: {}", REQUEST_ID.get());
return fetchOrders();
});
scope.join();
scope.throwIfFailed();
}
});
}
Both forked tasks inherit the REQUEST_ID binding from the parent. No explicit passing needed.
Practical: Request context
public record RequestContext(
String requestId,
String userId,
String tenantId,
Instant startTime
) {}
public class RequestScope {
public static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
}
// In a servlet filter or Spring interceptor
@Component
public class RequestContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
RequestContext context = new RequestContext(
UUID.randomUUID().toString(),
httpRequest.getHeader("X-User-ID"),
httpRequest.getHeader("X-Tenant-ID"),
Instant.now()
);
ScopedValue.runWhere(RequestScope.CONTEXT, context, () -> {
chain.doFilter(request, response);
});
}
}
// Anywhere in the request processing
void processOrder() {
RequestContext ctx = RequestScope.CONTEXT.get();
logger.info("[{}] Processing order for tenant {}", ctx.requestId(), ctx.tenantId());
}
Practical: Audit logging
public class AuditScope {
public static final ScopedValue<String> ACTOR = ScopedValue.newInstance();
}
void handleAdminAction(String adminId) {
ScopedValue.runWhere(AuditScope.ACTOR, adminId, () -> {
deleteUser("user-123"); // audit log knows who did this
updatePermissions("user-456"); // and this
});
}
void deleteUser(String userId) {
userRepository.delete(userId);
auditLog.record(
"DELETE_USER",
userId,
AuditScope.ACTOR.get() // the admin who triggered this
);
}
ScopedValue vs ThreadLocal
| Feature | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable (set()) | Immutable within scope |
| Cleanup | Manual (remove()) | Automatic (scope exit) |
| Inheritance | InheritableThreadLocal | Built-in with StructuredTaskScope |
| Virtual threads | Expensive (per-thread copy) | Efficient (shared reference) |
| Memory leaks | Common (forgotten remove()) | Not possible |
| Rebinding | Anywhere | Only via nested runWhere |
Nested scopes
Scoped values can be rebound in nested scopes:
static final ScopedValue<String> ROLE = ScopedValue.newInstance();
void handleRequest() {
ScopedValue.runWhere(ROLE, "user", () -> {
System.out.println(ROLE.get()); // "user"
// Temporarily elevate privileges
ScopedValue.runWhere(ROLE, "admin", () -> {
System.out.println(ROLE.get()); // "admin"
performAdminAction();
});
System.out.println(ROLE.get()); // "user" again
});
}
The outer binding is restored when the inner scope exits.
Migration from ThreadLocal
// Before
static final ThreadLocal<User> currentUser = new ThreadLocal<>();
void handle(User user) {
currentUser.set(user);
try {
process();
} finally {
currentUser.remove();
}
}
// After
static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
void handle(User user) {
ScopedValue.runWhere(CURRENT_USER, user, this::process);
}
The migration is straightforward: replace ThreadLocal with ScopedValue, replace set/remove with runWhere.
Summary
Scoped Values:
- Immutable — no accidental overwrites
- Auto-cleanup — no memory leaks from forgotten
remove() - Virtual-thread friendly — efficient with millions of threads
- Inherited — structured concurrency subtasks see parent values
- Nestable — rebind in inner scopes, outer value restored
Use them for request context, user identity, tenant ID, audit logging, and anywhere you’d previously use ThreadLocal. They’re safer, more efficient, and designed for modern Java concurrency.