Scoped Values: Revolutionizing Java Context Management
ScopedValue in Java offers safe, immutable context propagation with clear scoping and minimal overhead—ideal for structured concurrency and virtual threads.
Join the DZone community and get the full member experience.
Join For FreeIn an application meant for concurrent execution need of sharing data (or context) between threads is imperative. The available design choices are to pass the context as method parameter(s) or enable the context to be universally accessible (viz. global variable or equivalent).
While former choice (i.e. context as method argument) is easiest it doesn’t scale well. As the application evolves, the context too grows and thus the method parameters. Moreover, the method is required to accept parameters which aren’t utilized by itself directly but instead some method deep down the call hierarchy. Thus, overall data flow isn’t clean or intuitive. In case the context is mutable any of the callee potentially could corrupt the context. Identifying this rouge behavior is unpleasant at best.
The latter choice (i.e. globally accessible context) entails need of synchronized access of context or altogether wrapping it as singleton pattern. The synchronized access comes with own overhead and performance penalty of lower throughout. In case the context is mutable, any thread could potentially update the context which renders context-per-thread use case infeasible.
As a solution, ThreadLocal was introduced in quite early days of Java. Although, ThreadLocal served the purpose initially but the design choices started showing age and the limitations/overhead doesn’t fit well with contemporary state of Java concurrent programming.
In this article we would briefly touch upon the ThreadLocal , its current state, its overhead & limitation and how ScopedValue aims to solve the context management challenges in Java.
Current Situation (Thread Local)
A thread-local variable is of type ThreadLocal and has only one current value per thread. This value depends upon which thread has invoked ThreadLocal.get or ThreadLocal.set to read or write value. The thread-local is typically restricted to a class as static and final.
Using thread-local avoids the need to pass the context as method parameter. Once thread-local value is set for a thread, the local copy of thread-local can be read within the class across methods.
While ThreadLocals have a distinct value set in each thread, the value that is currently set in one thread can be automatically inherited by another thread that the current thread creates by using the InheritableThreadLocal class rather than the ThreadLocal class
Limitations of Thread Local
Unfortunately, below are the inherent design flaw of thread-local →
- Unconstrained Mutability — Thread-local variables are always mutable — any method accessing them can alter their value. This flexibility supports bidirectional data flow but often results in tangled logic and unclear update paths. Most use cases benefit more from controlled, one-way data transmission.
- Unbounded Lifetime — Values set in thread-local storage persist for the thread’s lifetime unless explicitly removed. Developers often overlook calling
remove(), leading to data leaks—especially in thread pools—causing security issues and memory retention long after relevance. - Expensive Inheritance — Child threads inherit all thread-local data from the parent, requiring separate allocations for each variable. This isolation preserves thread safety but dramatically increases memory usage, even though child threads seldom modify inherited values.
In summary, thread-local variables have more complexity than is usually needed for sharing data, and significant costs that cannot be avoided. Moreover, thread-local variables pose challenges especially in the context of virtual threads due to scale and efficiency. Virtual threads are lightweight and highly scalable, enabling millions of concurrent tasks. While their short lifespan reduces memory leak risks, assigning unique thread-local copies to each can drastically increase memory usage. Thread-local semantics are overly complex for simple data sharing across these ephemeral concurrent units. More efficient, immutable, and bounded-lifetime alternatives are needed to support safe and performant data propagation in massive virtual thread workloads. Enter ScopedValue .
Scoped Values
To quote from JEP 506 itself, Scoped Values are built on below fundamentals →
Ease of use — It should be easy to reason about dataflow.
Comprehensibility — The lifetime of shared data should be apparent from the syntactic structure of code.
Robustness — Data shared by a caller should be retrievable only by legitimate callees.
Performance — Data should be efficiently sharable across a large number of threads.
Above pretty much summarizes the limitations/overhead being addressed w.r.t thread-local. Its imperative to note that scoped values do not intent to mandate migration away from thread-local variables, or to deprecate the existing ThreadLocal API.
Meaning of “Scoped”
In context of Java, there are two types of scope viz. lexical (static) or dynamic scope.
Lexical or static scope is the space within the program text where it is legal to refer to the variable with a simple name (JLS §6.3). In other words, a local variable with bounded scope between { and } .
Dynamic scope of a thing refers to the parts of a program that can use the thing as the program executes. E.g. if method A invokes B which internally invokes C then the execution of C is bounded by execution of B which in turn is bounded by C . The unfolding execution of those methods defines a dynamic scope; the binding is in scope during the execution of those methods, and nowhere else. This dynamic scope is what referenced in ScopedValue i.e. direct or indirect invocation of methods viz threads run or call method.
Details
As of Java 25, below are the key details or characteristics for scoped-value →
- A scoped-value is a container object of type
ScopedValue - Data value represented by scoped-value is shared by a method with its direct and indirect callees within the same thread, and with child threads, without resorting to method parameters.
- Scoped-value has dynamic binding i.e. its binding is destroyed as soon as the execution of outermost caller finishes (either success or fail). Thus eliminating need of explicit removal and subsequent potential memory leak.
- Scoped-value is typically declared as a
private static finalfield so that it cannot be directly accessed by code in other classes. - Scoped-value could have multiple value associated with it, one per thread — similar to a thread-local. The value seen by
ScopedValue.getis associated with the thread it is being executed. - Scoped-value is written only once (i.e. immutable). Thus, eliminating any potential corruption of data by child methods and or threads.
- Above also helps in performance as the reading is as fast as local variable irrespective of the stack distance between caller and callees.
- Scoped-value if invoked outside the bounded thread fails with
NoSuchElementExceptionthus enforcing the scope boundaries. - Although a scoped-value is immutable, a different value can be associated to an existing scoped-value only for a nested scope. This is termed as nested binding. For more details, with code, refer next section.
- Context data shared by a code running in the request-handling thread needs to be available to code running in child threads. To enable cross-thread sharing, scoped-value can be inherited by child threads. For more details, with code, refer next section.
Code Samples
Enough talk, show me the code!!!
Single Scoped Value Binding
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
ScopedValue.where(NAME, "Ravi Kumar").run(() -> doSomething());
Here, in its minimalistic form, a scoped-value viz. NAME is bounded to a runnable which invokes doSomething . The code within doSomething and all its callees (which has access to scoped-value NAME ) can read its value as duke with NAME.get(). The moment doSomething finishes its execution, the NAME is unbounded again. Note that if NAME.get() is accessed outside call hierarchy of doSomething a NoSuchElementException is thrown.
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
var result = ScopedValue.where(NAME, "Ravi Kumar")
.call(() -> doSomethingElseOrThrow());
In case a method returns a value call(..) can be utilized instead of run(..) . The behavior with call is exactly similar as compared to run— except a value is returned or an exception is thrown.
Multiple Scoped Value Binding
private static final ScopedValue<String> FIRST_NAME = ScopedValue.newInstance();
private static final ScopedValue<String> LAST_NAME = ScopedValue.newInstance();
ScopedValue.where(FIRST_NAME, "Ravi")
.where(LAST_NAME, "Kumar")
.run(() -> doSomething());
Here, multiple scoped-values viz. FIRST_NAME and LAST_NAME are bounded to the runnable which invokes doSomething . The rules for each scoped-value remains same i.e. their respective value can be accessed only by doSomething or its callees and are bounded until the execution of doSomething finishes.
Note that although there is no upper limit to how many scoped-values could be bounded to a thread, it imperative to know that by design scoped-values are meant to be used in fairly small numbers. This limitation stems from the fact that for each invocation to get() the whole scope is scanned to find the scoped-value innermost binding. Although these values are cached for faster subsequent lookup, the cache size plays a crucial role in memory vs performance trade off. Thus, the program should be written considering these tradeoffs utilizing minimal number of scoped-value bindings. However, with the JDK system property viz. java.lang.ScopedValue.cacheSize the cache value can be tweaked accordingly — in integer power of 2 . The default cache value being 16 i.e. per thread at max 16 scoped-values are cached.
Another important consideration is usage of scoped-value with Virtual Threads. In case the virtual thread is blocked the scope-value cache is preserved so that it can be re-used immediately when the virtual thread unblocks. However, if too many virtual threads are blocked at a time its best suited to invalidate the cache when virtual thread block and regenerate them once virtual thread unblocks. This behavior can be tweaked via system property viz. jdk.preserveScopedValueCache — default value being true meaning cache is preserved. As with the cache size, preserving cache too has trade off between memory and performance thus should be carefully decided upon.
Nested Binding (Rebinding Within Scope)
Although a scoped-value is immutable, a different value can be associated to an existing scoped-value only for a nested scope — within the same thread. This is termed as nested binding. In essence it implies that for all method calls, within a thread, a scoped-value will have respective value based on the scope it is being invoked i.e. get will provide the value of the scope it is within (recall the dynamic scope discussed earlier).
private static final ScopedValue<String> X = ScopedValue.newInstance();
void foo() {
where(X, "hello").run(() -> bar());
}
void bar() {
System.out.println(X.get()); // prints hello
where(X, "goodbye").run(() -> baz());
System.out.println(X.get()); // prints hello
}
void baz() {
System.out.println(X.get()); // prints goodbye
}
Here within foo the scoped-value X is bounded with hello . The same value is available for bar as well since bar is directly invoked within foo . However, a re-binding of X is done within bar with value goodbye but only for baz. This re-binding initiates a different scope for X and is available only for baz and its callees. Even after X re-bind to goodbye the value still remains as hello for bar (i.e. the value from earlier scope binding done in foo).
In summary, a method cannot change the binding seen by that method itself but can change the binding seen by its callees. This nesting guarantees a bounded lifetime for sharing of the new value and avoid any potential corruption within scope or unintended value update towards overall scope.
Inheriting Scoped Values
Context data shared by a code running in the request-handling thread needs to be available to code running in child threads. To enable cross-thread sharing, scoped-values can be inherited by child threads.
Legacy thread management classes such as ForkJoinPool do not support inheritance of scoped-values because they cannot guarantee that a child thread forked from some parent thread scope will exit before the parent leaves that scope.
However, when virtual threads are used along with Structured Concurrency the inheritance of scoped-values is inbuilt for the request-handling thread and the child threads within scope of StructuredTaskScope.
private static final ScopedValue<String> NAME = ScopedValue.newInstance();
ScopedValue.where(NAME, "Ravi Kumar").run(() -> handle());
Response handle() throws InterruptedException {
try (var scope = StructuredTaskScope.open()) {
Subtask<String> user = scope.fork(() -> findUser());
Subtask<Integer> order = scope.fork(() -> fetchOrder());
scope.join();
return new Response(user.get(), order.get());
}
}
Here, both findUser as well as fetchOrder automatically inherits the scoped-value viz. NAME and can get its value as bounded to the handle . Unlike with thread-local variables, there is no copying of a parent thread’s scoped value bindings to the child thread, thus ensuring minimal overhead.
StructuredTaskScope ensures the dynamic scope of the scoped-value binding is still bounded by the lifetime of the call to ScopedValue.run(...) i.e. the request handling thread. This binding is implicitly enforced via scope.join() by ensuring that all child threads terminates before run can return — at this point the binding is destroyed between scoped-value and the run . Thus, the problem of unbounded lifetimes seen when using thread-local variables is completely avoided.
Note that any nested structured scope too inherits the scoped-value i.e. any child threads which may be spawned by either or both findUser or fetchOrder can access the respective value bounded with NAME . In case any of the child thread(s) starts their own nested StructuredTaskScope and doesn’t close it then a StructureViolationException is thrown.
Use Cases
Scoped-value suits majority of the use case where a thread-local is required. However, additionally scoped-value can be utilized to detect re-entrant code, nested transactions, sharing of framework context etc. Since the scoped-value API is much richer in nature, as compared to thread-local, newer use cases would certainly emerge.
Migration from Thread Local to Scoped Value
Migration to scoped-value should be done only if an immutable data is required to be transmitted from caller to callees. If data is mutable or objects which are expensive to create should still prefer thread-local for transmission as they cannot be shared reliably between threads without synchronization.
Moreover, if the use case demands value mutation, somewhere deep in call stack via ThreadLocal.set , or unstructured transmission (i.e. sharing between threads bearing no relation with each other) migration to scoped-value is not an option.
Conclusion
ScopedValue offers a modern, lightweight solution for propagating context in concurrent Java applications — especially with virtual threads. By prioritizing immutability, bounded lifetime, and dynamic scoping, it addresses key shortcomings of ThreadLocal while encouraging cleaner and safer code. For developers embracing structured concurrency, ScopedValue marks a pivotal step toward robust and scalable context management.
Reference and Further Read
Published at DZone with permission of Ammar Husain. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments