1. Overview
Scoped values enable developers to store and share immutable data within and across threads. This new API is introduced in Java 20 as an incubator preview feature proposed in JEP 439.
In this tutorial, we’ll first compare scoped values with thread-local variables, an older API serving a similar purpose. Then, we’ll look at applying scoped values to share data between threads, rebinding values, and inheriting them in child threads. Next, we’ll see how to apply scoped values in a classic web framework.
Finally, we’ll look at how to enable this incubator feature in Java 20 in order to experiment with it.
2. Motivation
Complex Java applications typically contain several modules and components that need to share data between themselves. When those components run in multiple threads, developers need a way to share immutable data between them.
However, different threads may require different data and shouldn’t be able to access or override data owned by other threads.
2.1. Thread Local
Since Java 1.2, we can make use of thread-local variables to share data between components without resorting to method arguments. A thread-local variable is simply a variable of a special type ThreadLocal.
Even though they look like ordinary variables, thread-local variables have multiple incarnations, one per thread. The particular incarnation that will be used depends on which thread calls the getter or setter methods to read or write its value.
Thread-local variables are typically declared as public static fields so they can be easily accessed by many components.
2.2. Shortcomings
Despite the fact that thread-local variables have been available since 1998, the API contains three major design flaws.
First, every thread-local variable is mutable and enables any code to call the setter method at any time. Therefore, data can flow in any direction between components, making it difficult to understand which component updates the shared state and in what order.
Second, when we use the set method to write the thread’s incarnation, the data is retained for the complete lifetime of the thread or until the thread calls the remove method. In case a developer forgets to call the remove method, the data will be retained in memory longer than necessary.
Last, thread-local variables of a parent thread can be inherited by child threads. When we create a child thread that inherits thread-local variables, the new thread will need to allocate additional storage for all parent thread-local variables.
2.3. Virtual Threads
The shortcomings of thread-local variables become more pressing with the availability of virtual threads in Java 19.
Virtual threads are lightweight threads managed by the JDK rather than the operating system. As a result, many virtual threads share the same operating-system thread, allowing developers to use a large number of virtual threads.
As virtual threads are instances of Thread, they may use thread-local variables. However, if million virtual threads have mutable thread-local variables, the memory footprint may be significant.
Therefore, Java 20 introduces the scoped values API as a solution to maintain immutable and inheritable per-thread data built to support millions of virtual threads.
3. Scoped Values
Scoped values enable safe and efficient sharing of immutable data between components without resorting to method arguments. They were developed together with virtual threads and structured concurrency as part of the project Loom.
3.1. Sharing Data Between Threads
Similar to thread-local variables, scoped values use multiple incarnations, one per thread. Also, they are typically declared as public static fields that can easily be accessed by many components:
public final static ScopedValue<User> LOGGED_IN_USER = ScopedValue.newInstance();
On the other hand, a scoped value is written once and is then immutable. The scoped value is available only for a bounded period of the execution of the thread:
ScopedValue.where(LOGGED_IN_USER, user.get()).run(
() -> service.getData()
);
The where method requires a scoped value and an object to which it should be bound. When calling the run method, the scoped value is bound, creating an incarnation that’s unique to the current thread, and then the lambda expression is executed.
During the lifetime of the run method, any method, whether called directly or indirectly from the expression, has the ability to read the scoped value. However, when the run method finishes, the binding is destroyed.
The bounded lifetime and immutability of scoped variables help to simplify reasoning about thread behavior. Immutability helps secure better performance, and data is only transmitted in one-way: from caller to callee.
3.2. Inheriting Scoped Values
Scoped values are automatically inherited by all child threads created using the StructuredTaskScope. The child thread can use bindings established for a scoped value in the parent thread:
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<Optional<Data>> internalData = scope.fork(
() -> internalService.getData(request)
);
Future<String> externalData = scope.fork(externalService::getData);
try {
scope.join();
scope.throwIfFailed();
Optional<Data> data = internalData.resultNow();
// Return data in the response and set proper HTTP status
} catch (InterruptedException | ExecutionException | IOException e) {
response.setStatus(500);
}
}
In this case, we can still access the scoped values from the services running in child threads created via the fork method. However, unlike thread-local variables, there is no copying of scoped values from the parent to the child thread.
3.3. Rebinding Scoped Values
Since scoped values are immutable, they do not support set methods for changing stored values. However, we can rebind a scoped value for the invocation of a limited code section.
For example, we can use the where method to hide the scoped value from the method called in run by setting it to null:
ScopedValue.where(Server.LOGGED_IN_USER, null).run(service::extractData);
However, as soon as that code section is terminated, the original value will be available again. We should note that the return type of the run method is void. If our service was returning a value, we can make use of the call method to enable the handling of returned values.
4. Web Example
Let’s now see a practical example of how we can apply scoped values in a classic web framework use case for sharing logged-in users’ data.
4.1. Classic Web Framework
A web server authenticates the user on an incoming request and makes the logged-in user’s data available to the code processing the request:
public void serve(HttpServletRequest request, HttpServletResponse response) throws InterruptedException, ExecutionException {
Optional<User> user = authenticateUser(request);
if (user.isPresent()) {
Future<?> future = executor.submit(() ->
controller.processRequest(request, response, user.get())
);
future.get();
} else {
response.setStatus(401);
}
}
A controller that processes the request, receives the logged-in user’s data via a method argument:
public void processRequest(HttpServletRequest request, HttpServletResponse response, User loggedInUser) {
Optional<Data> data = service.getData(request, loggedInUser);
// Return data in the response and set proper HTTP status
}
A service also receives the logged-in user’s data from the controller but does not use it. Instead, it just passed the information to the repository:
public Optional<Data> getData(HttpServletRequest request, User loggedInUser) {
String id = request.getParameter("data_id");
return repository.getData(id, loggedInUser);
}
In the repository, we finally make use of the logged-in user’s data to check if the user has sufficient privileges:
public Optional<Data> getData(String id, User loggedInUser) {
return loggedInUser.isAdmin()
? Optional.of(new Data(id, "Title 1", "Description 1"))
: Optional.empty();
}
In a more complex web application, the request processing can extend over a large number of methods. Even though the logged-in user’s data may only be required in a few methods, we might need to pass it through all of them.
Passing down information using method arguments will make our code noisy, and we’ll quickly exceed the recommended three parameters per method.
4.2. Applying Scope Values
An alternative approach is to store the logged-in user’s data in a scoped value that can be accessed from any method:
public void serve(HttpServletRequest request, HttpServletResponse response) {
Optional<User> user = authenticateUser(request);
if (user.isPresent()) {
ScopedValue.where(LOGGED_IN_USER, user.get())
.run(() -> controller.processRequest(request, response));
} else {
response.setStatus(401);
}
}
We can now remove the loggedInUser parameter from all methods:
public void processRequest(HttpServletRequest request, HttpServletResponse response) {
Optional<Data> data = internalService.getData(request);
// Return data in the response and set proper HTTP status
}
Our service doesn’t have to care about passing logged-in user’s data to the repository:
public Optional<Data> getData(HttpServletRequest request) {
String id = request.getParameter("data_id");
return repository.getData(id);
}
Instead, the repository can retrieve the logged-in user’s data by calling the get method of the scoped value:
public Optional<Data> getData(String id) {
User loggedInUser = Server.LOGGED_IN_USER.get();
return loggedInUser.isAdmin()
? Optional.of(new Data(id, "Title 1", "Description 1"))
: Optional.empty();
}
In this example, applying a scope variable ensures our code is more readable and maintainable.
4.3. Running Incubator Preview
To run the example above and experiment with scoped values in Java 20, we need to enable preview features and add the concurrent incubator module:
$ javac --enable-preview -source 20 --add-modules jdk.incubator.concurrent *.java
$ java --enable-preview --add-modules jdk.incubator.concurrent Server.class
The same can be achieved with Maven by adding the same two arguments to the compiler and surefire plugins:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>20</source>
<target>20</target>
<compilerArgs>
<arg>--enable-preview</arg>
<arg>--add-modules=jdk.incubator.concurrent</arg>
</compilerArgs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<argLine>--enable-preview --add-modules=jdk.incubator.concurrent</argLine>
</configuration>
</plugin>
5. Conclusion
In this article, we explored scoped values, an incubator preview feature of Java 20. We compared scoped values with thread-local variables and explained the motivation for creating a new API for sharing immutable data within and across threads.
We explored how to use scoped values to share data between threads, rebind their values, and inherit them in child threads. Then, we saw how to apply scoped values in a classic web framework example for sharing logged-in users’ data. Finally, we saw to enable incubator preview to experiment with scoped values in Java 20.
As always, the complete source code is available over on GitHub.