Goodbye ThreadLocal? Understanding Scoped Values in Java
Small design changes can create big productivity wins
ThreadLocal has been around since Java 1.2 as a way to store contextual data per thread. It still works, but in modern applications, especially with virtual threads, it shows a few limitations.
Scoped Values (JEP 506) bring a safer and more structured way to share immutable data across well-defined execution blocks.
Why ThreadLocal Can Be a Problem
Mutable everywhere — Any code in the thread can change the value, making data flow harder to understand.
Lives too long — Values stay attached to the thread until manually removed, which can cause leaks, especially in thread pools.
Inheritance overhead — Child threads inherit ThreadLocal values, adding extra memory and setup cost.
ThreadLocal is still valid for some cases, but many applications now need a cleaner, predictable way to propagate read-only data.
What Scoped Values Solve
Scoped Values let you share data in a scope that is:
Immutable
Automatically cleaned up after the scope ends
Perfect for Structured Concurrency
This gives you predictable behavior without the long-lived, mutable state of ThreadLocal.
How to Use Scoped Values ☕
public class ScopedValueExample {
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
public static void main(String[] args) {
ScopedValue.where(NAME, “Value”)
.run(() -> {
String name = NAME.get();
Thread.ofVirtual().start(() -> {
System.out.println(NAME.orElse(”Not bound”));
System.out.println(name);
});
System.out.println(Thread.currentThread().getName());
System.out.println(name);
// Works seamlessly with Structured Concurrency
// When used within a StructuredTaskScope, ScopedValue bindings are automatically inherited by all child threads created within that scope
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>allSuccessfulOrThrow()
)) {
scope.fork(() -> NAME.get() + “ from task 1”);
scope.fork(() -> NAME.get() + “ from task 2”);
var values = scope.join();
values.forEach(v -> System.out.println(v.get()));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
}
}
Understanding the Output
1. Current thread prints:
main
ValueBecause the value is bound inside the scope.
2. Virtual thread prints:
Not bound
ValueWhy?
NAME.get()→ throws if not boundSo we call
orElse(”Not bound”)→ shows Not boundBut the variable
name(copied before starting the thread) still holds Value
This demonstrates that ScopedValue is NOT inherited by virtual threads unless in a StructuredTaskScope.
3. StructuredTaskScope subtasks print:
Value from task 1
Value from task 2Inside Structured Concurrency scopes:
Scoped Values are automatically inherited
This makes them perfect for request-scoped or operation-scoped data
Conclusion
Scoped Values give Java a cleaner, safer alternative to some ThreadLocal patterns. They fit perfectly with virtual threads and Structured Concurrency, making your code easier to reason about and less error-prone.
If you want to know more about it, José Paumard recorded a short video for the Java Coding Tip - What is wrong with ThreadLocal variables?
👉 If this article was helpful, feel free to like it and subscribe for free to follow future posts!


