Java - Scoped Values Examples

This tutorial explains Scoped Values feature in Java and how it works along with code examples.

Java 20 introduced a new feature called scoped values (JEP 429). It allows immutable data sharing within a thread and across threads without resorting to method arguments. The similar functionality is supported by thread-local variables. However, scoped values have some different concepts that make them more preferred than thread-local variables.

Scoped values are designed to allow lightweight data sharing. The introduction of virtual threads highlights the weakness of thread-local variables. Thread-local variables are usually too complex and use too many resources for data sharing. Just imagine if an application has millions of virtual threads with each thread having mutable thread-local variables. It may result in memory leak. Therefore, Java created scoped values which are immutable, have a bounded lifetime and allow you to control how data is inherited to child threads.

This tutorial explains how to use scoped values including how to perform rebinding and inherit the values to other threads. I am going to explain the difference with thread-local variables as well. To demonstrate how it works, we are going to create a simple web server with thread per request model.

Using Scoped Values

You can use any data type for a scoped value. In this example, we are going to use the class below as a data type that stores the user data. To comply with the immutability concept, the class doesn't have any setter method.

  @AllArgsConstructor
  @NoArgsConstructor
  @Builder
  @Getter
  public class User {

    private int id;

    private String name;
  }

Scoped values are created using ScopedValue class. It has a parameterized type where you can define the type of the variable to be shared. The class has a static method newInstance which creates a value that's initially unbound. A ScopedValue variable is usually defined using public static to provide easy access from other classes. The final modifier is also necessary to prevent re-assignment of the variable. Below is an example that uses the class above.

  public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

To bind scoped values, you need to call ScopedValue.where. Below are the method signatures

  public static <T> Carrier where(ScopedValue<T> key, T value)
  public static <T, R> R where(ScopedValue<T> key, T value, Callable<? extends R> op)
  public static <T> void where(ScopedValue<T> key, T value, Runnable op)

The first one returns a Carrier and allows you to bind multiple values by chaining multiple .where() calls. Then, you can call the run (for Runnable) or call (for Callable) to execute a method. The scoped values will be accessible in the invoked method as well as other methods called indirectly, as long as it's in the same thread. Actually it's also possible to share the values to different threads and it will be explained later.

  ScopedValue
      .where(CURRENT_USER, user)
      .where(REQUEST_TIME, ZonedDateTime.now())
      .run(userService::handleRequest);

The second and third methods have the similar concept, except they directly accept and execute a Callable or Runnable. If you look at the source code, actually they call the first method. The drawback is, the second and third methods do not support multiple scoped values.

After the execution of the run or call method finishes, the binding is destroyed. With that concept, it's obvious that the data is always transmitted from the caller to callee. The callee cannot modify that data from the caller. Yet it's still possible for the callee to create another binding when calling another method. In addition, the lifetime of scoped variables are only inside the run or call method executed by the Carrier.

For a deeper understanding, let's see the example below. There is a controller class that accepts HTTP requests. Each request is handled by a dedicated thread. When a request is received, it binds some scoped values and calls UserService#handleRequest to handle the request.

  @RequiredArgsConstructor
  public class MyController {
  
    public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
    public static final ScopedValue<ZonedDateTime> REQUEST_TIME = ScopedValue.newInstance();
  
    private final UserService userService;
  
    @GetMapping
    public void test(int id, String name) {
      User user = new User(id, name);
      ScopedValue
          .where(CURRENT_USER, user)
          .where(REQUEST_TIME, ZonedDateTime.now())
          .run(userService::handleRequest);
    }
  }

In the UserService, you can access the ScopedValue variables. To retrieve a value, call the get method of a ScopedValue instance.

  @RequiredArgsConstructor
  public class UserService {
  
    private final LoggerService loggerService;
  
    public void handleRequest() {
      final User user = MyController.CURRENT_USER.get();
      System.out.printf(
          "[%s] [ProfileService], ID: %s, Name: %s\n",
          Thread.currentThread().threadId(),
          user.getId(),
          user.getName()
      );
  
      this.loggerService.log();
    }
  }

The handleRequest method then calls LoggerService#log. Since the log method is executed in the same thread, it's also possible to access the value without additional setup.

  @Slf4j
  public class LoggerService {

    public void log() {
      final User user = MyController.CURRENT_USER.get();

      log.info(
          "[{}] [LoggerService] ID: {}, Name: {}",
          Thread.currentThread().threadId(),
          user.getId(),
          user.getName()
      );
    }
  }

Below is another example that handles the logging in a different thread.

  @RequiredArgsConstructor
  public class UserService {

    private final LoggerService loggerService;

    public class LogThread extends Thread {
      public void run() {
        loggerService.log();
      }
    }

    public void handleRequestWithDifferentThread() {
      final User user = MyController.CURRENT_USER.get();
      System.out.printf(
          "[%s] [ProfileService], ID: %s, Name: %s\n",
          Thread.currentThread().threadId(),
          user.getId(),
          user.getName()
      );

      LogThread logThread = new LogThread();
      logThread.start();
    }
  }

If you run the above code, you'll get the following error.

  Exception in thread "Thread-4" java.util.NoSuchElementException
    at jdk.incubator.concurrent/jdk.incubator.concurrent.ScopedValue.slowGet(ScopedValue.java:570)
    at jdk.incubator.concurrent/jdk.incubator.concurrent.ScopedValue.get(ScopedValue.java:563)

Rebinding Scoped Values

Scoped values are immutable and the ScopedValue class doesn't provide any method to set the value once it has been set. However, it's possible to rebind the values. For example, we have a requirement that the LoggerService above has to log the names in upper case. We can create a new instance of User with an upper cased name. To rebind the values so that the LoggerService gets the user with upper cased name, call the ScopedValue.where again to create a new Carrier. For the second argument, pass the new User instance. By doing that, if you try to get the value from the ScopeValue variable in the method executed by the new Carrier, you'll get the rebound value.

  public void handleRequestWithRebinding() {
    final User user = MyController.CURRENT_USER.get();
    System.out.printf(
        "[%s] [ProfileService], ID: %s, Name: %s\n",
        Thread.currentThread().threadId(),
        user.getId(),
        user.getName()
    );

    final User userWithUpperCasedName = User.builder()
        .id(user.getId())
        .name(user.getName().toUpperCase())
        .build();
// Rebind ScopedValue.where(MyController.CURRENT_USER, userWithUpperCasedName) .run(this.loggerService::log); // If you call MyController.CURRENT_USER.get(); here, you'll get the original value, not the rebound value. }

Keep in mind that only the code executed by the new Carrier will get the rebound values. If you try to get a scoped value on the line not executed by the new Carrier (see the comment in the example above), you'll get the original value, even after the new Carrier finishes executing its operation.

Inheriting Scoped Values

Sometimes, you want to execute some processes in different threads. Based on the explanation above, the scoped values will not be accessible by default. Still, you can pass the values to different threads without resorting to method arguments.

For example, we have the PartnerService class below in which we need to access the User object.

  @Slf4j
  public class PartnerService {

    public String sendRequest() {
      final User user = MyController.CURRENT_USER.get();

      log.info(
          "[{}] [PartnerService] ID: {}, Name: {}",
          Thread.currentThread().threadId(),
          user.getId(),
          user.getName()
      );

      return "OK";
    }
  }

We also want to access the User object in the TrackerService below.

  @Slf4j
  public class TrackerService {

    public String trackAccess(String activityType) {
      final User user = MyController.CURRENT_USER.get();

      log.info(
          "[{}] [TrackerService] ID: {}, Name: {}",
          Thread.currentThread().threadId(),
          user.getId(),
          user.getName()
      );

      return "OK";
    }
  }

Let's assume those services need to run in different threads. A possible mechanism is by using StructuredTaskScope, which has the capability to create virtual threads. The child threads created using StructuredTaskScope inherit all scoped values in the parent thread. However, the scoped value bindings in the parent thread will not be copied to the child threads - which differs to the behavior of thread-local variables. Child threads can utilizes the established bindings in the parent thread.

  public void handleRequestWithInheritingScopeValue() {
    final User user = MyController.CURRENT_USER.get();
    System.out.printf(
        "[%s] [ProfileService], ID: %s, Name: %s\n",
        Thread.currentThread().threadId(),
        user.getId(),
        user.getName()
    );

    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
      Future<String> trackResult = scope.fork(
          () -> this.trackerService.trackAccess("ACCESS_PROFILE")
      );
      Future<String> partnerResponse = scope.fork(
          this.partnerService::sendRequest
      );

      try {
        scope.join();
        scope.throwIfFailed();
      } catch (InterruptedException | ExecutionException ex) {
        log.error("Error processing request", ex);
      }
    }
  }

Comparison with Thread-Local Variables

Thread-local variables have been used for a long time by Java programmers to share data without resorting to method arguments. Despite similar functionality, they have some different concepts.

Immutability

Scoped values are immutable. The ScopedValue class doesn't have any setter method. The immutability concept results in a performance advantage that it can be as fast as reading a local variable.

On the other hand, thread-local variables are mutable. There is a set method that can be used to set the value of a thread-local variable. That means it allows data flow in any direction. In some cases, it becomes difficult for programmers to understand the data flow.

Lifetime

Scoped values are only available for a bounded period. In the examples above, the values are only available inside the Callable or Runnable invoked by the Carrier. After the execution is done, all bindings are destroyed.

Thread local variables have an unbounded lifetime. Once the value has been set by calling the set method, it's retained for as long as the thread is still alive. Actually it's possible to call the remove method, but many programmers do not do it.

Inheritance

Scoped values are not inherited to child threads by default. But it's possible to pass the values to the child threads by using StructuredTaskScope. The child threads can re-use the bidings of the parent thread without copying the bindings. That results in cheaper inheritances with less overhead.

Meanwhile, all thread-local variables owned by a parent thread will be inherited to the child threads. A child thread needs to allocate storage for each thread-local variable that its parent has, despite it may not be necessary to use it. That leads to overhead especially if there are a lot of child threads. That's getting worse with the introduction of Java virtual threads. If an application creates millions of virtual threads, each inheriting thread-local variables, the memory usage will increase significantly.

Summary

In this tutorial, we have learned that scoped values can be used to share data within and across threads. Because of their immutability, bounded lifetime, and cheap inheritance, which leads to performance advantage, they can overcome the problems of thread-local variables. You can download the code for this tutorial here. If this feature is still in incubator, you need to add --add-modules jdk.incubator.concurrent flag when running the code.