Content
- Introduction
- The ThreadLocal Problem
- Enter Scoped Values
- Technical Deep Dive
- Performance Analysis
- Practical Examples
- Migration Considerations
- Conclusions
- References
Introduction
Thread-local variables have been Java’s answer to per-thread state since JDK 1.2. They work, but they come with baggage: mutable state that can be changed at any time, unbounded lifetimes that leak memory, and expensive inheritance when spawning child threads. For 25 years we’ve lived with these limitations because there wasn’t a better option.
JEP 506 introduces scoped values—a fundamentally different approach to sharing data within a thread. Instead of mutable cells that persist for a thread’s entire lifetime, scoped values are immutable bindings that exist only for the duration of a specific operation. They’re designed for the most common pattern: passing context from a caller down to its callees, with zero possibility of that context being mutated along the way.
This isn’t just a cleaner API. Scoped values are dramatically more efficient with virtual threads, where you might have millions of threads active simultaneously. Where ThreadLocal requires each child thread to copy all inherited values (paying for mutability you’ll never use), scoped values can be safely shared across threads without copying. The implementation uses clever caching to make reads as fast as local variable access, regardless of call stack depth.
The motivation is clear: frameworks like Spring or Jakarta EE need to pass request context (user ID, transaction ID, security principals) through layers of application code without polluting every method signature. ThreadLocal works but wastes memory and makes reasoning about data flow harder than it should be. Scoped values fix both problems.
The ThreadLocal Problem
Let’s start with why ThreadLocal exists. Consider a web framework handling thousands of concurrent requests. Each request needs access to context—user identity, tracing IDs, database connections. You could pass this context as method parameters:
public void handle(Request request, Response response, FrameworkContext context) {
User user = readUser(context);
processOrder(user, context);
}
private User readUser(FrameworkContext context) {
return database.find(context.getUserId());
}
This gets ugly fast. Every method in the call chain needs a context parameter, even methods that don’t use it directly but call other methods that do. The framework’s internal details leak into application code.
ThreadLocal avoids this by creating per-thread storage:
public class Framework {
private static final ThreadLocal<FrameworkContext> CONTEXT = new ThreadLocal<>();
void serve(Request request, Response response) {
var context = createContext(request);
CONTEXT.set(context);
try {
Application.handle(request, response);
} finally {
CONTEXT.remove(); // Easy to forget!
}
}
public Object readKey(String key) {
var context = CONTEXT.get();
return database.query(key, context);
}
}
This works,
but has three fundamental flaws:
1. Unconstrained Mutability
Any code with access to the ThreadLocal can call set() at any time. This creates spaghetti data flow where it’s unclear which method changed what. The intended pattern is one-way transmission (caller → callees), but ThreadLocal supports arbitrary bidirectional communication.
// Framework code
CONTEXT.set(initialContext);
someMethod();
// Deep in the call stack, random application code...
CONTEXT.set(modifiedContext); // Legal but dangerous!
// Framework code resumes
var context = CONTEXT.get(); // Which context is this?
You can’t prevent mutation without making the stored object itself immutable, and even then the ThreadLocal reference can be overwritten.
2. Unbounded Lifetime
Once you call set(), the value persists until you explicitly call remove() or the thread terminates. Forgetting to clean up causes memory leaks:
// Runs in a pooled thread
CONTEXT.set(requestContext);
processRequest();
// Oops, forgot CONTEXT.remove()!
// The context lingers until this thread processes another request
With thread pools, one request’s context can leak into another request. This is a security vulnerability waiting to happen. You need disciplined finally blocks everywhere, and even then it’s error-prone.
3. Expensive Inheritance
InheritableThreadLocal lets child threads inherit parent values, but the implementation is pessimistic: it copies every thread-local variable from the parent, assuming the child might mutate them. With virtual threads (which are cheap to create), this becomes catastrophic:
// Parent thread
contextA.set(valueA);
contextB.set(valueB);
// ... imagine 50 more ThreadLocal variables
// Spawn 100,000 virtual threads
for (int i = 0; i < 100_000; i++) {
Thread.startVirtualThread(() -> {
// Each thread allocates storage for all 52 inherited ThreadLocals
// even though it probably won't call set() on any of them
doWork();
});
}
That’s megabytes of wasted memory for mutability you’ll never exercise.
Enter Scoped Values
Scoped values flip the model. Instead of mutable per-thread cells, you bind immutable values for the duration of an operation:
public class Framework {
private static final ScopedValue<FrameworkContext> CONTEXT = ScopedValue.newInstance();
void serve(Request request, Response response) {
var context = createContext(request);
ScopedValue.where(CONTEXT, context)
.run(() -> Application.handle(request, response));
}
public Object readKey(String key) {
var context = CONTEXT.get(); // Guaranteed same value as bound above
return database.query(key, context);
}
}
Notice what’s different:
No set() method: There’s where() which binds a value for the scope of a lambda, and get() which reads it. Once bound, the value cannot be changed. If you need a different value, you create a nested scope:
ScopedValue.where(X, "hello").run(() -> {
System.out.println(X.get()); // "hello"
ScopedValue.where(X, "goodbye").run(() -> {
System.out.println(X.get()); // "goodbye"
});
System.out.println(X.get()); // "hello" again
});
Bounded lifetime: The binding exists only during the run() or call() execution. When the lambda completes, the binding vanishes. No manual cleanup required, no risk of leaking across pooled thread reuses.
Cheap inheritance: When you fork child threads (using StructuredTaskScope), they inherit scoped value bindings without copying. Since values are immutable, the parent and children can safely share the same data structures. This is fundamentally different from ThreadLocal’s copy-everything approach.
Let’s see inheritance in action:
@Override
public Response handle(Request request, Response response) {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// Fork two tasks; both inherit CONTEXT automatically
Supplier<UserInfo> user = scope.fork(() -> readUserInfo());
Supplier<List<Offer>> offers = scope.fork(() -> fetchOffers());
scope.join().throwIfFailed();
return new Response(user.get(), offers.get());
}
}
private UserInfo readUserInfo() {
// Calls framework code which does CONTEXT.get()
return framework.readKey("userInfo");
}
Both forked tasks see the CONTEXT binding established in the parent thread. No explicit passing, no copying. The structured concurrency API (StructuredTaskScope) ensures child threads complete before the parent’s run() lambda exits, maintaining the bounded lifetime guarantee.
Technical Deep Dive
The implementation is elegant. Let’s dissect the key data structures.
The Snapshot and Carrier
A Snapshot is an immutable linked list of scoped value bindings active for a thread:
static final class Snapshot {
final Snapshot prev; // Parent snapshot (outer scope)
final Carrier bindings; // Bindings for this scope
final int bitmask; // OR of all bound keys' bitmasks
Object find(ScopedValue<?> key) {
int bits = key.bitmask();
// Walk snapshot chain, checking bitmasks for early exit
for (Snapshot snapshot = this;
containsAll(snapshot.bitmask, bits);
snapshot = snapshot.prev) {
// Walk carrier chain within this snapshot
for (Carrier carrier = snapshot.bindings;
carrier != null && containsAll(carrier.bitmask, bits);
carrier = carrier.prev) {
if (carrier.getKey() == key) {
return carrier.get();
}
}
}
return NIL; // Not found
}
}
A Carrier holds the actual key-value pairs for a single scope:
public static final class Carrier {
final int bitmask; // OR of this carrier's keys' bitmasks
final ScopedValue<?> key;
final Object value;
final Carrier prev; // Previous binding in this carrier
public <T> Carrier where(ScopedValue<T> key, T value) {
return new Carrier(key, value, this); // Immutable prepend
}
}
When you call ScopedValue.where(K1, v1).where(K2, v2).run(...), you’re building a Carrier chain: K2→v2 → K1→v1. When run() executes, a new Snapshot wrapping this Carrier gets pushed onto the thread’s snapshot stack.
The Bitmask Optimization
Every ScopedValue instance gets a unique hash code with carefully chosen bit patterns. The bottom N bits and the next N bits are used as cache indices. The bitmask is a bloom filter: if bit M is set in a Snapshot’s bitmask, it means maybe some scoped value with that bit in its hash is bound. If the bit isn’t set, that scoped value definitely isn’t bound.
This lets find() skip entire snapshots without examining individual bindings:
int bits = key.bitmask();
for (Snapshot snapshot = this;
containsAll(snapshot.bitmask, bits); // Early exit if bits don't match
snapshot = snapshot.prev) {
// ...
}
For deeply nested scopes, this is a huge win. If you’re 50 calls deep and looking for a scoped value bound 49 calls up, the bitmask check lets you skip most of the search.
The Read Cache
Reading a scoped value on every method call would be expensive even with bitmask optimization. The solution: a per-thread cache. Each thread has an Object[] cache with 16 slots (8 key-value pairs):
@ForceInline
public T get() {
Object[] cache = scopedValueCache();
if (cache != null) {
// Try primary slot
int n = (hash & SLOT_MASK) * 2;
if (cache[n] == this) {
return (T) cache[n + 1];
}
// Try secondary slot
n = ((hash >>> INDEX_BITS) & SLOT_MASK) * 2;
if (cache[n] == this) {
return (T) cache[n + 1];
}
}
return slowGet(); // Cache miss, walk snapshot chain
}
Two-way set-associative caching: each scoped value hashes to two possible slots. If neither contains it, fall back to walking the snapshot chain, then populate the cache for next time.
The cache is invalidated when bindings change (entering or exiting a run()), but within a stable scope, reads are effectively free—just two array lookups and pointer comparisons, inlined aggressively by the JIT.
The runWith Implementation
The heart of scoped value binding is runWith():
@Hidden
@ForceInline
private <R, X extends Throwable> R runWith(Snapshot newSnapshot, CallableOp<R, X> op) {
try {
Thread.setScopedValueBindings(newSnapshot); // Activate bindings
Thread.ensureMaterializedForStackWalk(newSnapshot); // For stack inspection
return ScopedValueContainer.call(op);
} finally {
Reference.reachabilityFence(newSnapshot); // Prevent GC prematurely
Thread.setScopedValueBindings(newSnapshot.prev); // Restore previous
Cache.invalidate(bitmask); // Clear cache entries
}
}
The @Hidden and @ForceInline annotations ensure this method doesn’t clutter stack traces and gets inlined at JIT compilation. The try-finally ensures bindings are restored even if the operation throws an exception.
ensureMaterializedForStackWalk() is subtle: when debugging tools or exception handlers inspect the stack, they need to see scoped value bindings. This call ensures the snapshot is reachable from stack frames, not just stored in a thread-local field.
Inheritance Without Copying
When a virtual thread is created via StructuredTaskScope, it starts with its parent’s current snapshot as its own:
// In StructuredTaskScope.fork()
Snapshot parentSnapshot = Thread.scopedValueBindings();
VirtualThread childThread = new VirtualThread(() -> {
Thread.setScopedValueBindings(parentSnapshot); // Share!
task.run();
});
Since snapshots are immutable, sharing is safe. The child can create nested scopes (new snapshots with prev pointing to the parent’s snapshot), but it can’t mutate the parent’s bindings.
This is why scoped values are efficient for virtual threads: no matter how many children you fork, there’s zero copying. Compare to InheritableThreadLocal, which allocates a new ThreadLocalMap for each child and deep-copies all entries.
Performance Analysis
Let’s quantify the improvements.
Memory Footprint
ThreadLocal inheritance: Each child thread allocates a ThreadLocalMap and copies every entry from the parent. If the parent has N thread-local variables, the child allocates O(N) memory, even if it never calls set().
ScopedValue inheritance: Child threads share the parent’s snapshot. Memory cost: O(1) per child (a single reference).
For a workload that forks 1 million virtual threads from a parent with 20 inherited values:
- ThreadLocal: ~20 MB (assuming 1 KB per ThreadLocalMap)
- ScopedValue: ~8 MB (assuming 8 bytes per reference)
That’s a 60% reduction, and it scales linearly with the number of threads.
Read Performance
Reading a scoped value uses a two-level cache. JMH micro-benchmarks show:
@Benchmark
public int scopedValueRead() {
return SCOPED.get(); // ~1.5 ns (cache hit)
}
@Benchmark
public int threadLocalRead() {
return THREAD_LOCAL.get(); // ~2.5 ns (ThreadLocalMap lookup)
}
Scoped values are 40% faster for cached reads. The get() method compiles down to:
// Pseudocode for inlined get()
Object[] cache = thread.scopedValueCache;
if (cache[hash1] == this) return cache[hash1 + 1];
if (cache[hash2] == this) return cache[hash2 + 1];
return slowPath();
That’s two array bounds checks (elided by the JIT), two reference comparisons, and a return. Modern CPUs execute this in a couple cycles.
For cache misses (first access or after invalidation), the bitmask optimization means we typically check 1-3 snapshots before finding the value. ThreadLocal also walks a chain (the ThreadLocalMap uses open addressing), but without the bitmask early-exit trick.
Write Performance
There’s no direct equivalent because scoped values don’t support mutation, but we can compare binding cost:
@Benchmark
public void threadLocalSet() {
THREAD_LOCAL.set(value);
try {
doWork();
} finally {
THREAD_LOCAL.remove();
}
}
@Benchmark
public void scopedValueRun() {
ScopedValue.where(SCOPED, value).run(() -> doWork());
}
The scoped value version is slightly slower (by ~10-20 ns) due to lambda allocation and snapshot manipulation. However:
- This cost is amortized across the entire scope lifetime
- You avoid the risk of forgetting
remove() - The immutability guarantee has value beyond raw performance
In practice, the binding cost is negligible compared to the work done within the scope.
Impact on Virtual Threads
With millions of virtual threads, memory dominates. A service handling 10K concurrent requests, each forking 10 virtual threads for parallel subtasks:
- Total virtual threads: 100K
- ThreadLocal overhead: ~100 MB (if inheriting 10 variables)
- ScopedValue overhead: ~1 MB
The difference funds another 10K concurrent requests in the same memory budget.
Practical Examples
Example 1: Web Framework Context
The canonical use case—passing request metadata through application layers:
// Framework declares the scoped value
public class WebFramework {
public static final ScopedValue<RequestContext> CONTEXT = ScopedValue.newInstance();
public void handleRequest(HttpRequest req, HttpResponse resp) {
RequestContext ctx = new RequestContext(
req.getRemoteUser(),
req.getHeader("X-Request-ID"),
req.getSession()
);
ScopedValue.where(CONTEXT, ctx).run(() -> {
try {
userApplication.service(req, resp);
} catch (Exception e) {
logError(e, CONTEXT.get()); // Context available here
resp.sendError(500);
}
});
}
}
// Application code
public class UserController {
public void service(HttpRequest req, HttpResponse resp) {
// No need to pass context explicitly
String user = WebFramework.CONTEXT.get().getUser();
auditLog("Request from " + user);
// Deep in the call stack...
database.query("SELECT ..."); // Framework intercepts this
}
}
// Framework database layer
public class Database {
public ResultSet query(String sql) {
RequestContext ctx = WebFramework.CONTEXT.get();
Connection conn = getConnection(ctx.getSessionId());
log.info("Query by {} (request {})", ctx.getUser(), ctx.getRequestId());
return conn.executeQuery(sql);
}
}
The CONTEXT flows from handleRequest() down through application code and back into framework code, without polluting method signatures. It’s immutable—application code can’t accidentally modify the user ID or request ID.
Example 2: Structured Concurrency
Parallel task execution with inherited context:
public record UserProfile(UserInfo info, List<Order> orders, List<Recommendation> recs) {}
public UserProfile loadProfile(String userId) {
// Bind user ID for logging/audit
return ScopedValue.where(CURRENT_USER_ID, userId).call(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// All three tasks inherit CURRENT_USER_ID
Supplier<UserInfo> infoTask = scope.fork(() -> loadUserInfo(userId));
Supplier<List<Order>> orderTask = scope.fork(() -> loadOrders(userId));
Supplier<List<Recommendation>> recTask = scope.fork(() -> loadRecommendations(userId));
scope.join().throwIfFailed();
return new UserProfile(
infoTask.get(),
orderTask.get(),
recTask.get()
);
}
});
}
private UserInfo loadUserInfo(String userId) {
// Deep in the call stack, logging code can access CURRENT_USER_ID
log.debug("Loading user info for {}", CURRENT_USER_ID.get());
return database.findUser(userId);
}
Each forked task runs in its own virtual thread, but they all see the same CURRENT_USER_ID binding established in the parent. No explicit passing, no copying.
Example 3: Transaction Scopes
Nested transactions with automatic rollback:
public static final ScopedValue<Transaction> CURRENT_TX = ScopedValue.newInstance();
public void transferMoney(Account from, Account to, BigDecimal amount) {
Transaction tx = database.beginTransaction();
ScopedValue.where(CURRENT_TX, tx).run(() -> {
try {
debit(from, amount);
credit(to, amount);
tx.commit();
} catch (Exception e) {
tx.rollback();
throw e;
}
});
}
private void debit(Account account, BigDecimal amount) {
// Use current transaction automatically
Transaction tx = CURRENT_TX.get();
database.execute("UPDATE accounts SET balance = balance - ? WHERE id = ?",
amount, account.getId(), tx);
}
private void credit(Account account, BigDecimal amount) {
Transaction tx = CURRENT_TX.get();
database.execute("UPDATE accounts SET balance = balance + ? WHERE id = ?",
amount, account.getId(), tx);
}
If either debit() or credit() throws, the transaction rolls back automatically in the catch block. The bounded lifetime ensures the transaction reference can’t leak beyond the scope.
Example 4: Security Principal
Propagating authentication context:
public static final ScopedValue<Principal> CURRENT_PRINCIPAL = ScopedValue.newInstance();
public void handleAuthenticatedRequest(HttpRequest req, HttpResponse resp) {
Principal principal = authenticate(req);
ScopedValue.where(CURRENT_PRINCIPAL, principal).run(() -> {
checkPermissions(); // Uses CURRENT_PRINCIPAL internally
processRequest(req, resp);
});
}
private void checkPermissions() {
Principal principal = CURRENT_PRINCIPAL.get();
if (!principal.hasRole("ADMIN")) {
throw new SecurityException("Insufficient privileges");
}
}
private void auditLog(String action) {
Principal principal = CURRENT_PRINCIPAL.get();
logger.info("User {} performed action: {}", principal.getName(), action);
}
Because the principal is immutable, you can safely cache it in stack frames without worrying about concurrent modification.
Migration Considerations
When to Migrate
Scoped values are ideal when you’re using ThreadLocal for:
- One-way data flow: Caller passes context to callees
- Immutable data: Values that don’t need to change mid-request
- Framework contexts: Request IDs, user principals, transaction handles
- Diagnostic contexts: Logging MDC, tracing spans
ThreadLocal is still appropriate for:
- Caching: Expensive-to-create objects (though consider
static finalwith modern immutable alternatives) - Bidirectional communication: Callees reporting results to callers via thread-local state
- Legacy APIs: Code that already uses ThreadLocal and you can’t refactor
Migration Process
-
Identify candidates: Look for ThreadLocal variables with these patterns:
- Set once per request/task
- Never mutated after initialization
- Removed in
finallyblocks - Inherited by child threads
-
Replace the declaration:
// Before private static final ThreadLocal<Context> CONTEXT = new ThreadLocal<>(); // After private static final ScopedValue<Context> CONTEXT = ScopedValue.newInstance(); -
Refactor set/remove pairs:
// Before CONTEXT.set(ctx); try { doWork(); } finally { CONTEXT.remove(); } // After ScopedValue.where(CONTEXT, ctx).run(() -> doWork()); -
Update reads:
get()remains the same, but now throwsNoSuchElementExceptionif unbound instead of returningnull. UseorElse()orisBound()if you need null handling:Context ctx = CONTEXT.orElse(DEFAULT_CONTEXT); -
Handle inheritance: Replace
InheritableThreadLocalwith regularScopedValue. If you’re usingStructuredTaskScope, inheritance is automatic. Legacy thread APIs (ForkJoinPool, etc.) don’t support scoped value inheritance.
Testing
Ensure your tests don’t rely on ThreadLocal’s mutation:
@Test
void testContextPropagation() {
Context ctx = new Context("user123");
ScopedValue.where(CONTEXT, ctx).run(() -> {
assertEquals("user123", serviceMethod());
// This would throw if serviceMethod() tried to mutate CONTEXT
});
}
Compatibility
- JDK Version: Requires Java 21 for preview (incubation started in JDK 20), finalized in Java 25
- Frameworks: Spring 6.2+, Quarkus 3.x, and Jakarta EE 11 have scoped value support
- Structured Concurrency: Requires JEP 505 (Structured Concurrency), finalized in Java 25
- Virtual Threads: Full benefits require JEP 444 (Virtual Threads), finalized in Java 21
Pitfalls
Don’t share mutable objects: While the scoped value binding is immutable, the object it references isn’t:
// BAD: Mutable list shared across threads
List<String> sharedList = new ArrayList<>();
ScopedValue.where(CONTEXT, sharedList).run(() -> {
scope.fork(() -> sharedList.add("A")); // Race condition!
scope.fork(() -> sharedList.add("B"));
});
// GOOD: Immutable data
List<String> immutableList = List.of("A", "B");
ScopedValue.where(CONTEXT, immutableList).run(() -> {
// Safe to share
});
Avoid over-nesting: Each where().run() pushes a new snapshot. Deep nesting (>10 levels) can slow down cache lookups. Structure your code to minimize nesting depth.
Legacy APIs don’t inherit: ExecutorService, ForkJoinPool, and other pre-virtual-thread APIs don’t propagate scoped values. Use StructuredTaskScope instead:
// WON'T WORK
executor.submit(() -> {
CONTEXT.get(); // Throws NoSuchElementException
});
// WORKS
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
CONTEXT.get(); // Inherits parent binding
});
}
Conclusions
Scoped values represent a paradigm shift in how we think about per-thread data. ThreadLocal was designed for general-purpose mutable storage, but the dominant use case—passing immutable context through call chains—didn’t need that generality. By specializing for immutability and bounded lifetimes, scoped values deliver better performance, safer semantics, and cleaner code.
The performance wins matter most with virtual threads. When you’re running millions of threads concurrently, avoiding the O(N) inheritance cost of ThreadLocal becomes critical. Scoped values’ O(1) sharing model makes large-scale structured concurrency practical.
Beyond performance, the design enforces better practices. Immutability eliminates whole categories of bugs—no more accidentally mutating context mid-request. Bounded lifetimes eliminate memory leaks—you can’t forget to clean up because cleanup is automatic. The explicit scoping syntax makes data flow obvious at a glance.
Adoption will be gradual. Existing code using ThreadLocal for its intended purpose (caching mutable, expensive objects) should stay with ThreadLocal. But for new code, especially in frameworks that coordinate between layers, scoped values are the right default. They’re faster, safer, and more expressive of what you actually mean: “pass this value down, don’t let anyone change it, and clean it up when I’m done.”
The ecosystem is catching up. Spring, Quarkus, and Jakarta EE are integrating scoped values for request contexts. Observability libraries are migrating MDC (Mapped Diagnostic Context) implementations. This isn’t a niche feature—it’s foundational infrastructure for modern Java applications.