Understanding Java’s new ScopedValue | InfoWorld


As reported on InfoWorld, Java 22 introduces several new thread-related features. One of the most important is the new ScopedValue syntax for dealing with shared values in multithreaded contexts. Let’s take a look.

Structured concurrency and scoped values

ScopedValue is a new way to achieve ThreadLocal-like behavior. Both elements address the need to create data that is safely shared within a single thread but ScopedValue aims for greater simplicity. It is designed to work in tandem with VirtualThreads and the new StructuredTaskScope, which together simplify threading and make it more powerful. As these new features come into regular use, the scoped values feature is intended to address the increased need for managing data-sharing within threads.

ScopedValue as an alternative to ThreadLocal

In a multithreaded application, you will often need to declare a variable that exists uniquely for the current thread. Think of this as being similar to a Singleton—one instance per application—except it’s one instance per thread.

Although ThreadLocal works well in many cases, it has limitations. These problems boil down to thread performance and mental load for the developer. Both issues will likely increase as developers use the new VirtualThreads feature and introduce more threads to their programs. The JEP for scoped values does a good job of describing the limitations of virtual threads.

A ScopedValue instance improves on what is possible using ThreadLocal in three ways:

  • It is immutable.
  • It is inherited by child threads.
  • It is automatically disposed of when the containing method completes.

As the ScopedValue specification says:

The lifetime of these per-thread variables should be bounded: Any data shared via a per-thread variable should become unusable once the method that initially shared the data is finished.

This is different from ThreadLocal references, which last until the thread itself ends or the ThreadLocal.remove() method is called.

Immutability both makes it easier to follow the logic of a ScopedValue instance and enables the JVM to aggressively optimize it.

Scoped values use a functional callback (a lambda) to define the life of the variable, which is an unusual approach in Java. It might sound strange at first, but in practice, it works quite well.

How to use a ScopedValue instance

There are two aspects to using a ScopedValue instance: providing and consuming. We can see this in three parts in the following code.

Step 1: Declare the ScopedValue:


final static ScopedValue<...> MY_SCOPED_VALUE = ScopedValue.newInstance();

Step 2: Populate the ScopedValue instance:


ScopedValue.where(MY_SCOPED_VALUE, ACTUAL_VALUE).run(() -> { 
  /* ...Code that accesses ACTUAL_VALUE... */
});

Step 3: Consume the ScopedValue instance (code that is called somewhere down the line by Step 2):


var fooBar = DeclaringClass.MY_SCOPED_VALUE

The most interesting part of this process is the call to ScopedValue.where(). This lets you associate the declared ScopedValue with an actual value, and then call .run() method, providing a callback function that will execute with the so-defined value for the ScopedValue instance.

Remember: The actual value associated to the ScopedValue instance will change according to the thread that is running. That’s why we’re doing all this! (The particular thread-specific variable set on the ScopedValue is sometimes called its incarnation.)

A code example is often worth a thousand words, so let’s take a look. In the following code, we create several threads and generate a random number unique to each one. We then use a ScopedValue instance to apply that value to a thread-associated variable:


import java.util.concurrent.ThreadLocalRandom;

public class Simple {
  static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance();

  public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
      new Thread(() -> {
       int randomNumber = ThreadLocalRandom.current().nextInt(1, 101);

       ScopedValue.where(RANDOM_NUMBER, randomNumber).run(() -> {
         System.out.printf("Thread %s: Random number: %d\n", Thread.currentThread().getName(), RANDOM_NUMBER.get());
        });
      }).start();
    }
  }
}

The  static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance(); call gives us the RANDOM_NUMBER ScopedValue to be used anywhere in the application. In each thread, we generate a random number and associate it to RANDOM_NUMBER.

Then, we run inside ScopedValue.where(). All code inside the handler will resolve RANDOM_NUMBER to the specific one set on the current thread. In our case, we just output the thread and its number to the console.

Here’s what a run looks like:


$ javac --release 23 --enable-preview Simple.java 

Note: Simple.java uses preview features of Java SE 23.
Note: Recompile with -Xlint:preview for details.

$ java --enable-preview Simple

Thread Thread-1: Random number: 45
Thread Thread-2: Random number: 100
Thread Thread-3: Random number: 51
Thread Thread-4: Random number: 74
Thread Thread-5: Random number: 37
Thread Thread-0: Random number: 32
Thread Thread-6: Random number: 28
Thread Thread-7: Random number: 43
Thread Thread-8: Random number: 95
Thread Thread-9: Random number: 21

Note that we currently need to turn on the enable-preview switch to run this code. That won’t be necessary once the scoped values feature is promoted.

Each thread gets a distinct version of RANDOM_NUMBER. Wherever that value is accessed, no matter how deeply nested, it will get that same version—so long as it originates from inside that run() callback.

In such a simple example, you can imagine passing the random value as a method parameter; however, as the application code grows, that quickly becomes unmanageable and leads to tightly coupled components. Using a ScopedValue instance is an easy way to make the variable universally accessible while keeping it constrained to a given value for the current thread.

Using ScopedValue with StructuredTaskScope

Because ScopedValue is intended to make dealing with high numbers of virtual threads easier, and StructuredTaskScope is a recommended way to use virtual threads, you will need to know how to combine these two features.

The overall process of combining ScopedValue with StructuredTaskScope is similar to our previous Thread example; only the syntax differs:


import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.StructuredTaskScope;

public class ThreadScoped {
  static final ScopedValue<Integer> RANDOM_NUMBER = ScopedValue.newInstance();

  public static void main(String[] args) throws Exception {
    try (StructuredTaskScope scope = new StructuredTaskScope()) {
      for (int i = 0; i < 10; i++) {
        scope.fork(() -> {
          int randomNumber = ThreadLocalRandom.current().nextInt(1, 101);
          ScopedValue.where(RANDOM_NUMBER, randomNumber).run(() -> { 
            System.out.printf("Thread %s: Random number: %d\n", Thread.currentThread().threadId(), RANDOM_NUMBER.get());
          });
          return null;
        });
      }
      scope.join();
    }
  }
}

The overall structure is the same: we define the ScopedValue and then we create threads and use the ScopedValue (RANDOM_NUMBER) in them. Instead of creating Thread objects, we use scope.fork().

Notice that we return null from the lambda we pass to scope.fork(), because in our case we aren’t using the return value—we are just outputting a string to the console. It’s possible to return a value from scope.fork() and use it. 

Also, note that I’ve just thrown Exception from main(). The scope.fork() method throws an InterruptedException that should be handled properly in production code.

The above sample is typical of both StructuredTaskScope and ScopedValue. As you can see, they work well together—in fact, they were designed for it.

Scoped values in the real world

We’ve been looking at simple examples to see how the scoped values feature works. Now let’s think about how it’ll work in more complex scenarios. In particular, the Scoped Values JEP highlights usage in a large web application where many components are interacting. The request-handling component can be responsible for obtaining a user object (a “principle”) that represents the authorization for the present request. This is a thread-per-request model used by many frameworks. Virtual threads make this far more scalable by divorcing JVM threads from operating system threads.

Once the request handler has obtained the user object, it can expose it to the rest of the application with the ScopedValue annotation. Then, any other components called from inside the where() callback can access the thread-specific user object. For example, in the JEP, the code sample shows a DBAccess component relying on the PRINCIPAL ScopedValue to verify authorization:


/** https://openjdk.org/jeps/429 */
class Server {
  final static ScopedValue<Principal> PRINCIPAL =  ScopedValue.newInstance();

  void serve(Request request, Response response) {
    var level     = (request.isAdmin() ? ADMIN : GUEST);
    var principal = new Principal(level);
    ScopedValue.where(PRINCIPAL, principal)
      .run(() -> Application.handle(request, response));
    }
}

class DBAccess {
    DBConnection open() {
        var principal = Server.PRINCIPAL.get();
        if (!principal.canOpen()) throw new  InvalidPrincipalException();
        return newConnection(...);
    }
}

Here you can see the same outlines as our first example. The only difference is there are different components. The serve() method is imagined to be creating threads per request, and somewhere down the line, the Application.handle() call will interact with DBAccess.open(). Because the call originates from inside the ScopedValue.where(), we know that PRINCIPAL will resolve to the value set for this thread by the new Principal(level) call.

Another important point is that subsequent calls to scope.fork() will inherit the ScopedValue instances defined by the parent. So, for example, even if the serve() method above were to call scope.fork() and access the child task within PRINCIPAL.get(), it would get the same thread-bound value as the parent. (You can see the pseudocode for this example in the “Inheriting scoped values“ section of the JEP.)

The immutability of scoped values means that the JVM can optimize this child-thread sharing, so we can expect low-performance overhead in these cases.

Conclusion

Although multithreaded concurrency is inherently complex, newer Java features go a long way to making it simpler and more powerful. The new scoped values feature is another effective tool for the Java developer’s toolkit. Altogether, Java 22 offers an exciting and radically improved approach to threading in Java. 

Copyright © 2024 IDG Communications, Inc.



Source link