Definition
- ScopedValue – a container that binds an immutable value for the dynamic extent of a
ScopedValue.where(KEY, value).run(...)call, finalized in JDK 25. - Immutable – the value cannot be changed within the scope; there is no
set()method. - Auto-cleaned – the binding is automatically removed when the scope exits; no manual
remove()is needed. - Cheap inheritance – efficiently inherited by child virtual threads and structured concurrency forks, unlike ThreadLocal which copies per thread.
- No leaks – eliminates the classic ThreadLocal memory leak in pooled or long-lived threads.
ThreadLocal is a sticky note on each bank clerk's desk. It stays until manually removed, and if the clerk forgets, the next customer sees the wrong note. ScopedValue is a read-only visitor badge that automatically expires when the transaction ends. No cleanup, no leaks, no mutations.
Understanding the Concepts
What is ScopedValue?
ScopedValue binds an immutable value to a scope and makes it available to all code executing within that scope. The binding is automatically removed when the scope exits. It is the modern replacement for ThreadLocal in virtual-thread code.
package com.bank.service;
import java.lang.ScopedValue;
public class RequestContext {
public static final ScopedValue<String> CURRENT_USER = ScopedValue.newInstance();
public static final ScopedValue<String> CORRELATION_ID = ScopedValue.newInstance();
public void handleRequest(String user, String correlationId) {
ScopedValue.where(CURRENT_USER, user)
.where(CORRELATION_ID, correlationId)
.run(() -> {
// All code in this block can read CURRENT_USER and CORRELATION_ID
processTransfer();
});
// Bindings automatically removed here
}
private void processTransfer() {
String user = CURRENT_USER.get(); // "Rajesh"
String corrId = CORRELATION_ID.get(); // "TXN-2024-001"
System.out.println(corrId + ": Processing transfer for " + user);
}
}
Why is ThreadLocal problematic for virtual threads?
ThreadLocal has four problems when used with millions of virtual threads.
First, memory leaks: ThreadLocal values persist until explicitly removed with remove(). Forgetting to call remove() leaks memory. With millions of virtual threads, this leak is amplified. Second, mutable state: ThreadLocal allows set() at any time, making it hard to reason about what value a method sees. Third, expensive inheritance: InheritableThreadLocal copies values to child threads, which is costly with millions of virtual threads. Fourth, pool pollution: in thread pools, a ThreadLocal set by one task may be visible to the next task if remove() is missed.
// WRONG: ThreadLocal with virtual threads
ThreadLocal<String> currentUser = new ThreadLocal<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
currentUser.set("Rajesh"); // mutable!
processRequest();
// currentUser.remove(); // easy to forget!
});
}
}
// 1,000,000 ThreadLocal entries if remove() is missed
// CORRECT: ScopedValue with virtual threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
executor.submit(() -> {
ScopedValue.where(CURRENT_USER, "Rajesh").run(() -> {
processRequest();
});
// Automatically cleaned, no remove() needed
});
}
}
How is ScopedValue different from ThreadLocal?
| Aspect | ThreadLocal | ScopedValue |
|---|---|---|
| Mutability | Mutable (set() anytime) | Immutable within scope |
| Cleanup | Manual remove() required | Automatic on scope exit |
| Inheritance | Copies per child thread | Efficient sharing |
| Memory risk | Leak-prone (forgotten remove) | Leak-free by design |
| Thread model | Designed for platform threads | Designed for virtual threads |
| API | get(), set(), remove() | get(), isBound() (read-only) |
| Structured concurrency | Not compatible | Inherited by forked subtasks |
How does ScopedValue work with Structured Concurrency?
When you fork subtasks inside a StructuredTaskScope, the ScopedValue bindings from the parent scope are automatically available to the child subtasks.
public void handleTransfer(String user, String accountId) {
ScopedValue.where(CURRENT_USER, user).run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> {
// CURRENT_USER.get() returns "Rajesh" here
return checkBalance(accountId);
});
scope.fork(() -> {
// CURRENT_USER.get() returns "Rajesh" here too
return checkFraud(accountId);
});
scope.join();
scope.throwIfFailed();
}
});
}
What are the rules for using ScopedValue?
- Declare as
static final:public static final ScopedValueKEY = ScopedValue.newInstance(); - Bind with
ScopedValue.where(KEY, value).run(task)or.call(callable). - Read with
KEY.get()(throws NoSuchElementException if not bound) orKEY.isBound(). - You cannot call
set()because the value is immutable within its scope. - Nested
where()calls can rebind the same key for a narrower scope.
What are common mistakes?
// WRONG: reading outside scope
ScopedValue.where(CURRENT_USER, "Rajesh").run(() -> processRequest());
String user = CURRENT_USER.get(); // NoSuchElementException: not bound!
// WRONG: trying to mutate
ScopedValue.where(CURRENT_USER, "Rajesh").run(() -> {
// CURRENT_USER.set("Priya"); // No set() method exists!
});
// CORRECT: rebind for a narrower scope
ScopedValue.where(CURRENT_USER, "Rajesh").run(() -> {
System.out.println(CURRENT_USER.get()); // "Rajesh"
ScopedValue.where(CURRENT_USER, "Priya").run(() -> {
System.out.println(CURRENT_USER.get()); // "Priya" (rebound)
});
System.out.println(CURRENT_USER.get()); // "Rajesh" (restored)
});
When should you still use ThreadLocal?
ThreadLocal remains appropriate when you need mutable per-thread state on platform threads, such as thread-local caches, thread-local random number generators, or framework internals that require mutation.
What should a senior engineer know?
- ScopedValue was finalized in JDK 25 after previews in JDK 20-24.
- The binding is stored in the virtual thread's context, not in a hash map like ThreadLocal.
- Rebinding a ScopedValue in a nested scope creates a new binding that shadows the outer one; the outer binding is restored when the inner scope exits.
- For carrier cost: ScopedValue has near-zero overhead for reads because the JVM can inline the lookup.
What are common interview follow-up questions?
- Can ScopedValue replace all ThreadLocal uses? (No. ThreadLocal is still needed for mutable per-thread state and in platform-thread-pool scenarios.)
- What if I need to pass mutable state? (Use a final reference to a mutable object, or pass it as a method parameter.)
- How is ScopedValue different from passing parameters? (ScopedValue avoids threading context through every method signature in deep call chains.)
Visual Overview
ThreadLocal vs ScopedValue:
ThreadLocal:
┌──────────────┐
│ Thread-1 │ set("Rajesh") ──▶ stored in ThreadLocalMap
│ │ get() ──▶ "Rajesh"
│ │ // forget remove()!
│ │ Next task sees stale "Rajesh" ← LEAK
└──────────────┘
ScopedValue:
┌──────────────────────────────────────────────┐
│ ScopedValue.where(USER, "Rajesh").run(() -> │
│ │ │
│ │ get() → "Rajesh" │
│ │ fork subtask → inherits "Rajesh" │
│ │ │
│ ) ◀── scope exits, binding auto-removed │
│ │
│ get() → NoSuchElementException (not bound) │
└──────────────────────────────────────────────┘
Key Takeaway
ScopedValue replaces ThreadLocal for virtual threads. It binds immutable data for the dynamic extent of a scope and automatically cleans up when the scope exits. Unlike ThreadLocal, it has no set() method (immutable), no remove() requirement (auto-cleaned), and efficiently inherits into child virtual threads and structured concurrency forks (no per-thread copying).
The typical use case is propagating request-scoped context down the call chain: a correlation ID, the authenticated user, a tenant identifier. Instead of passing these as parameters through every method, you bind them with ScopedValue.where(KEY, value).run(task) and read them with KEY.get() anywhere in the call chain.
ThreadLocal remains useful for mutable per-thread state on platform threads. But for virtual threads, where millions of threads exist and ThreadLocal copies are expensive, ScopedValue is the designed replacement. Its immutability eliminates a class of bugs where one task's ThreadLocal leaks into another.
Remember one sentence: ScopedValue is an immutable, auto-cleaning, efficiently inheritable replacement for ThreadLocal in virtual-thread code.
---