

Scoped values were developed – together with Virtual Threads and Structured Concurrency – in Project Loom. They have been included in the JDK since Java 20 as an incubator preview feature (JEP 429).
In this article, you will learn:
- What is a scoped value?
- How to use
ScopedValue
? - How are scoped values inherited?
- What is the difference between
ScopedValue
andThreadLocal
?
What is a Scoped Value?
Scoped values allow a value (i.e., any object) to be stored for a limited time in such a way that only the thread that wrote the value can read it.
Scoped values are usually created as public static fields, so they can be retrieved from any method without us having to pass them as parameters to those methods.
If multiple threads use the same ScopedValue field, it may contain a different value from the point of view of each thread.
If you are familiar with ThreadLocal
variables, this will sound familiar. In fact, scoped values are a modern alternative for thread locals.
I can best explain scoped values with an example.
ScopedValue Example
The classic usage scenario is a web framework that authenticates the user on an incoming request and makes the logged-in user's data available to the code that processes the request.
That can be done, for example, using a method argument.
Now, in complex applications, the processing of a request can extend over hundreds of methods – but the information about the logged-in user may only be required in a few methods. Nevertheless, we would have to pass the user through all methods that eventually lead to invoking a method for which the logged-in user is relevant.
In the following example, the logged-in user is passed from the Server
through the RestAdapter
and UseCase
to the Repository
, where it is eventually evaluated:
class Server {
private void serve(Request request) {
// ...
User user = authenticateUser(request);
restAdapter.processRequest(request, user);
// ...
}
}
class RestAdapter {
public void processRequest(Request request, User loggedInUser) {
// ...
UUID id = extractId(request);
useCase.invoke(id, loggedInUser);
// ...
}
}
class UseCase {
public void invoke(UUID id, User loggedInUser) {
// ...
Data data = repository.getData(id, loggedInUser);
// ...
}
}
class Repository {
public Data getData(UUID id, User loggedInUser) {
Data data = findById(id);
if (loggedInUser.isAdmin()) {
enrichDataWithAdminInfos(data);
}
}
}
Code language: Java (java)
The additional loggedInUser
parameter makes our code noisy quite quickly.
And what if, at some point deep in the call stack, we also needed the user's IP address? Then we would have to pass another argument through countless methods.
The alternative is to store the user in a scoped value that can be accessed from anywhere.
This works as follows: We create a static field of type ScopedValue
in a publicly accessible place. With ScopedValue.where(…)
, we bind the scoped value to the concrete user object; and to the run
method, we supply – in the form of a Runnable
– the code for whose call duration the scoped value should be valid:
class Server {
public final static ScopedValue<User> LOGGED_IN_USER = ScopedValue.newInstance();
private void serve(Request request) {
// ...
User loggedInUser = authenticateUser(request);
ScopedValue.where(LOGGED_IN_USER, loggedInUser)
.run(() -> restAdapter.processRequest(request));
// ...
}
}
Code language: Java (java)
Alternatively, the Runnable
can be passed directly to the where
method as the third parameter:
ScopedValue.where(LOGGED_IN_USER, loggedInUser,
() -> restAdapter.processRequest(request));
Code language: Java (java)
Instead of a Runnable
, you can also pass a Callable
(i.e., a method with a return value) – for this, you invoke call(…)
instead of run(…)
or pass the Callable
as the third parameter to the where
method. You can find an example further below.
We can then remove the loggedInUser
parameter from all method signatures:
class RestAdapter {
public void processRequest(Request request) {
// ...
UUID id = extractId(request);
useCase.invoke(id);
// ...
}
}
class UseCase {
public void invoke(UUID id) {
// ...
Data data = repository.getData(id);
// ...
}
}
Code language: Java (java)
And where we need the logged-in user, we can read it with ScopedValue.get()
:
class Repository {
public Data getData(UUID id) {
Data data = findById(id);
User loggedInUser = Server.LOGGED_IN_USER.get();
if (loggedInUser.isAdmin()) {
enrichDataWithAdminInfos(data);
}
}
}
Code language: Java (java)
That makes the code much more readable and maintainable, as we no longer have to pass the logged-in user from one method to the next but can access it exactly where we need it.
If you want to experiment with scoped values: Preview features have to be explicitly enabled, and incubator modules have to be explicitly added to the module path. To do this, you need to download the Java 20 Early Access release and run the java
and javac
commands with the following parameters:
$ javac --enable-preview -source 20 --add-modules jdk.incubator.concurrent *.java
$ java --enable-preview --add-modules jdk.incubator.concurrent <class to execute>
Code language: plaintext (plaintext)
Rebinding Scoped Values
ScopedValue
has no set
method to change the stored value. This is intentional because the immutability of a value makes complex code much more readable and maintainable.
Instead, you can rebind the value for the invocation of a limited code section (e.g., for the invocation of a sub-method). That means that, for this limited code section, another value is visible … and as soon as that section is terminated, the original value is visible again.
For example, our RestAdapter
method might want to hide the information about the logged-in user from the extractId
method. To do this, we can call ScopedValue.where(…)
again and set the logged-in user to null
during the sub-method call:
class RestAdapter {
public void processRequest(Request request) {
// ...
UUID id = ScopedValue.where(LOGGED_IN_USER, null)
.call(() -> extractId(request));
useCase.invoke(id);
// ...
}
}
Code language: Java (java)
Here you can also see how we use call(…)
instead of run(…)
and pass a Callable
(i.e., a method with a return value) instead of a Runnable
.
Inheriting Scoped Values
Scoped values are automatically inherited by all child threads created via a Structured Task Scope.
Using StructuredTaskScope
, our use case could, for example, call an external service in parallel to the repository method:
class UseCase {
public void invoke(UUID id) {
// ...
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Data>. dataFuture = scope.fork(() -> repository.getData(id));
Future<ExtData> extDataFuture = scope.fork(() -> remoteService.getExtData(id));
scope.join();
scope.throwIfFailed();
Data data = dataFuture.resultNow();
ExtData extData = extDataFuture.resultNow();
// ...
}
}
}
Code language: Java (java)
This way, we can also access the logged-in user from the child threads created via fork(…)
using LOGGED_IN_USER.get()
.
Since the StructuredTaskScope
is not completed until all child threads are finished, it fits very well into the concept of scoped values.
What Is the Difference Between ScopedValue and ThreadLocal?
Those who have solved the requirements of these examples so far with thread locals may now wonder: Why do we need scoped values? What can they do that thread locals can't?
Scoped values have the following advantages:
- They are only valid during the lifetime of the
Runnable
passed to thewhere
method, and they are released for garbage collection immediately afterward (unless further references to them exist). A thread-local value, on the other hand, remains in memory until either the thread is terminated (which may never be the case when using a thread pool) or it is explicitly deleted withThreadLocal.remove()
. Since many developers forget to do this (or don't do it because the program is so complex that it's not obvious when a thread-local value is no longer needed), memory leaks are often the result. - A scoped value is immutable – it can only be reset for a new scope by rebinding, as mentioned above. This improves the understandability and maintainability of the code considerably compared to thread locals, which can be changed at any time using
set()
. - The child threads created by
StructuredTaskScope
have access to the scoped value of the parent thread. If, on the other hand, we useInheritableThreadLocal
, its value iscopied
to each child thread, which can significantly increase the memory footprint.
Like thread locals, scoped values are available for both platform and virtual threads. Especially when there are thousands to millions of virtual child threads, the memory savings from accessing the scoped value of the parent thread (instead of creating a copy) can be significant.
Summary
With scoped values, we get a handy construct to provide a thread (and, if needed, a group of child threads) with a read-only, thread-specific value during their lifetime.
Please note that as of Java 20, scoped values are still in the incubator stage and, thus, may still be subject to fundamental changes.
If you still have questions, please ask them via the comment function. Do you want to be informed about new tutorials and articles? Then click here to sign up for the HappyCoders.eu newsletter.