Java 25 Scoped Values: How Do You Compare Them with ThreadLocal?
Prompt and scope
An interviewer may ask: “What problem does Java 25 ScopedValue solve? Compare it with ThreadLocal and explain correct use with thread pools or virtual threads.”
JEP 506 finalized Scoped Values in JDK 25. They let a caller share immutable data with deep callees and child threads inside a lexical scope, reducing the need to thread context through every method parameter. The question tests lifetime, visibility, and concurrency boundaries; it is not answered by calling ScopedValue “a new ThreadLocal.”
What the interviewer is testing
- Whether you understand ScopedValue as an immutable, scope-controlled implicit parameter.
- Whether you can explain the binding and restoration of
where(...).run(...)orcall(...). - Whether you recognize differences from mutable ThreadLocal, pooled-thread reuse, and virtual-thread inheritance.
- Whether you consider key access control, binding count, exception propagation, and cancellation.
- Whether you know when explicit parameters or ThreadLocal remain appropriate.
Clarifying questions
- Is the shared value a request ID, principal, tenant, or mutable transaction state?
- Must it be read-only, and should child tasks inherit it?
- Is execution on platform threads, virtual threads, structured concurrency, or an existing pool?
- Could code read the key outside its scope or expose a Carrier to untrusted code?
- Does the framework rely on ThreadLocal cleanup, mutation, or MDC integration?
A 30-second answer
You can say:
ScopedValue is Java 25's immutable, lexically scoped context mechanism. A caller creates a binding withScopedValue.where(key, value).run(...)orcall(...); it is restored when the scope exits, and only code holding the key can read it. Compared with ThreadLocal, it reduces pooled-thread cleanup and mutable-state leaks, making it useful for request context, virtual threads, and structured concurrency. It does not replace a ThreadLocal that must be updated step by step, and a value must not escape its scope. I would test inheritance, exceptions, cancellation, and key visibility on the target JDK.
Step-by-step reasoning
Understand the binding model
The value is readable during the dynamic execution range, while the binding is made explicit by code structure:
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
ScopedValue.where(REQUEST_ID, "req-42").run(() -> {
audit("start");
handleRequest();
});
static void audit(String message) {
logger.info("{} {}", REQUEST_ID.orElse("missing"), message);
}audit does not need a request-ID parameter, but it reads a meaningful value only inside the binding scope. Once the scope exits, the binding cannot pollute the caller thread.
Compare ThreadLocal mutability
ThreadLocal gives each thread a mutable value; pooled-thread reuse requires cleanup or the next request may observe stale context. ScopedValue is designed for immutable sharing, with nested bindings that temporarily shadow an outer value and restore it afterward. It reduces cleanup responsibility but does not carry a state machine that deep code must repeatedly set.
Handle child tasks and virtual threads
JEP 506 targets predictable sharing costs with virtual threads and structured concurrency. Whether inheritance occurs, when it is captured, and how an executor behaves must be verified against the target JDK and API contract; do not transfer assumptions from an ordinary thread pool. The shared object should remain immutable so a reference cannot bypass the context boundary.
Design keys, exceptions, and compatibility
Make a key a private static final object or part of a controlled API. Do not treat a globally shared key as a security boundary. Use orElse or an explicit exception for a missing value; in Java 25, orElse no longer accepts null. An exception leaving run or call still exits the scope and restores the outer binding. For older JDKs, keep explicit parameters or a ThreadLocal implementation behind a tested compatibility path.
Model high-quality answer
I treat ScopedValue as an immutable implicit parameter, not a mutable replacement for ThreadLocal. The request entry point creates a structured scope withwhere(key, value).runorcall; deep methods read a request ID, tenant, or principal through the key, and the binding is restored on exit. It fits read-only context in virtual threads and structured concurrency and avoids pooled-thread cleanup leaks. ThreadLocal remains useful for state that must be updated step by step, with cleanup in finally. I would restrict key visibility, test child-task inheritance, exceptions, cancellation, nested shadowing, and reads outside scope, and keep an explicit or compatible implementation for older JDKs.
Common mistakes
- Calling ScopedValue a mutable ThreadLocal with automatic cleanup.
- Saving a Carrier or value outside the scope, or reading a key after its lifetime.
- Ignoring pooled-thread reuse, child-task inheritance, and virtual-thread differences.
- Storing a mutable object while claiming the whole context is immutable.
- Sharing a key across arbitrary modules and mistaking access control for encryption.
- Forgetting Java 25's non-null
orElserule or omitting an older-JDK path.
Follow-up questions and responses
1. Can ScopedValue replace every ThreadLocal?
No. It fits read-only, scope-bounded context. State that must be updated through a call chain can still use ThreadLocal or explicit parameters, with the required cleanup responsibility.
2. What happens with nested bindings?
An inner scope can temporarily bind a new value for the same key; exiting it restores the outer value. Exception paths also leave the scope, so the inner value does not leak outward.
3. How do you test concurrent inheritance?
Cover platform threads, virtual threads, structured tasks, and pooled-thread reuse. Check the value seen by child tasks, cleanup after cancellation, nested shadowing, and reads outside scope. Fix the JDK version and executor configuration in the test matrix.