Looking at Java 21: Scoped Values
Storing and Sharing Data Within and Across Thread Boundaries
Today we look at Scoped Values, another interesting preview feature that’s incubating since Java 20 (JEP 429). It’s a new way to store and share immutable data with a bounded lifetime within a thread and its child threads.
If you think “hey, that’s what ThreadLocal<T>
is for!”, you’re thinking the right way. However, this article will highlight why another concept for storing and sharing data is needed for modern Java code.
As with the other articles of the series, this one is only a quick look to give you an overview of what’s coming, not a deep-dive.
Storing and Sharing Data Between Components
When Java programs grow, usually a need arises to share data between different components. A classical example is a web framework utilizing the “one thread per request” concept.
For example, a security Principal
is shared between multiple framework components, so not each one has to retrieve it again and again. Sharing it directly between caller and callee as a method argument is one option to pass it around, but it would result in a myriad of interconnected method calls, creating tightly coupled components which isn’t what you really want.
Instead, having a well-defined location for the Principal
that’s available for the current thread is the preferrable approach. That’s where ThreadLocal<T>
enters the scene.
What’s ThreadLocal?
As the name suggests, the ThreadLocal<T>
type stores a variable of type T
that is local to the current thread. That means, if we access such a variable from any thread, this thread gets its own instance and can’t access the other ones.
Usually, a ThreadLocal<T>
is provided directly via a public static
field, or indirectly via a public static
getter that accesses a private static
field, so other components can easily access the underlying value inside the boundaries of the current thread.
Even though ThreadLocal<T>
is an essential and widely used feature that’s available since Java 1.2, it has four major downsides. These downsides can be a limiting factor in its usability, but they don’t automatically disqualify ThreadLocal<T>
as a good tool for your requirements. As with all types, containers, algorithms, patterns, etc., it highly depends on your requirements and the context you use them in.
Downside 1: Unconstrained Mutability
Any code that can access the ThreadLocal<T>
instance to call its get()
method can also change the current value by calling set(T value)
. At first glance, this seems reasonable, even necessary to support the bi-directional flow of data between components.
However, being mutable can easily lead to spaghetti-like data flow, which makes it hard to reason with it, and identify which component mutates the state when and in which order.
As mentioned before, you could hide the ThreadLocal<T>
behind a getter method, but that still doesn’t affect how the ThreadLocal<T>
works internally. It still needs to be able to provide a mutable container and isn’t optimized for read-only operations.
Downside 2: Unbounded Lifetime
Variables written to a ThreadLocal<T>
has the same lifetime as, you guessed it, the thread it was written from unless you explicitly call remove()
. That makes it easy to not properly clean up and accidentally leak data to another task.
Even if you remember that you need to call remove()
, it might be not as obvious when it’s safe to call it.
Downside 3: Expensive Inheritance
Container-types always add an inevitable overhead to fulfill their intended task. That’s the trade-off we must accept for getting the enhanced functionality. However, a thread bequeaths its child thread their own ThreadLocal<T>
instances, as the children can’t access the parent’s one. That increases the required memory footprint, even if the children only need read-only access.
Downside 4: Overhead in Virtual Threads
Another upcoming Java feature I’m going to talk about in the future, Virtual Threads (JEP 444), introduces more lightweight threads where multiple virtual threads implemented by the JDK share the same operating-system thread. This change allows us to use many more virtual threads compared to non-virtual ones, as they are way cheaper to use.
Virtual threads are still Threads, though. That means they can use ThreadLocal<T>
, but now, the previously mentioned downsides affect your program at another scale. If you think the overhead or lifetime issues and coordination of a few ThreadLocal<T>
are problematic when handling a handful of threads, you better don’t think about these downsides when handling 10.000+ virtual threads!
Scopes Values to the Rescue
To mitigate the downsides associated with ThreadLocal<T>
, a new type to efficiently store and share immutable data between multiple components was created alongside the other new concurrent-related modern Java features: ScopedValue<T>
.
Superficially, Scoped values have many similarities with thread-local variables, like being bound to their thread. They are even typically declared in a static
manner:
private static final
ThreadLocal<Principal> PRINCIPAL_TL = new ThreadLocal();
// - VS -
private static final
ScopedValue<Principal> PRINCIPAL_SV = ScopedValue.newInstance();
The main difference between ScopedValue<T>
and ThreadLocal<T>
is the mutability of their inner value, and how the value’s lifetime is actually scoped.
Binding a Value
ThreadLocal<T>
has two ways to set a value, either via the static withInitial(...)
method on declaration, or by calling set(T value)
on the instance later. The value has the same lifetime as its container, which is coupled to the current thread’s lifespan. The only way to reduce the lifetime is to call removed()
or set a new value.
ScopedValue<T>
itself is still coupled to the current threads lifetime, but handles its value differently. A value is bound into a new scope which then can execute a Runnable
or Callable
:
// This code is for illustration purposes only and won't work as-is.
private static final
ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
var currentPrinipal = this.securityService.getSubject().getPrincipal();
ScopedValue.where(PRINICIPAL, currentPrincipal).run(() -> {
// Do the work
});
Let’s go over what’s happening here.
The ScopedValue<Principal>
is declared as a static
field so any component can access it.
Some code loads the Principal
and binds it to the Scoped Value and runs a Runnable
, which can access the Scoped Value. Outside of the Runnable
, the Principal
cannot be accessed via the Scoped Value.
That’s quite a different approach than ThreadLocal<T>
!
Instead of providing a container to store a value, ScopedValue<T>
isn’t holding the value itself, but instead, facilitates binding values to a ScopedValue.Carrier
to execute a Runnable
or Callable
.
The magic happens in the static where
method of ScopedValue
. It accepts a ScopedValue<T>
and a T value
to create an instance of the nested type ScopedValue.Carrier
. This carrier is the actual scope of the bound variable, which gives us methods to either bind even more ScopedValue<T>
instances by calling where
again, or use that scope with a Runnable
or Callable
:
<T> ScopedValue.Carrier where(ScopedValue<T> key, T value)
void run(Runnable op)
<R> R call(Callable<? extends R> op)
To access the scoped value, you either use the ScopedValue<T>
instance in the new scope directly, or you can call <T> T get(ScopedValue<T> key)
on the Carrier
instance:
private static final
ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.Carrier carrier = ScopedValue.where(USER, "ben");
var boundUser = carrier.get(USER);
// => "ben"
carrier.run(() -> {
var currentUser = USER.get();
// => "ben"
});
The get()
method isn’t the only option to access a possible inner value. Similar to Optionals, there are methods for alternative values:
T orElse(T other)
<X extends Throwable> T orElseThrow(Supplier<? extends X> exceptionSupplier)
Like the boolean isPresent()
of ThreadLocal<T>
, there’s a boolean isBound()
method available on any ScopedValue<T>
.
Rebinding Scoped Values
As the ScopedValue.Carrier
defines the actual scope of the bound variable, it can be reused, or you can use multiple per thread:
private static final
ScopedValue<String> USER = ScopedValue.newInstance();
// CREATE TWO CARRIERS WITH DIFFERENT VALUES IN THE SAME THREAD
ScopedValue.Carrier carrierForBen = ScopedValue.where(USER, "ben");
ScopedValue.Carrier carrierForBob = ScopedValue.where(USER, "bob");
// USE THE SAME RUNNABLE FOR EACH CARRIER
Runnable r = () -> {
var currentUser = USER.get();
System.out.println("Current: " + currentUser);
};
// RUN RUNNABLE WITH DIFFERENT CARRIERS
carrierForBen.run(r);
// => "Current: ben"
carrierForBob.run(r);
// => "Current: bob"
carrierForBen.run(r);
// => "Current: ben"
This allows for more fine-grained scopes compared to ThreadLocal<T>
, as it gives us a handy tool to create new scopes within the current thread.
Inheriting Scoped Values
Scoped Values are inherited by child threads if we use another feature that’s in preview in Java 21: Structured Concurrency (JEP 453). There’s hopefully going to be an article about it in the future, but long story short, it’s a mechanism that treads related tasks running in different threads as a single unit of work. This simplifies thread control like error handling and cancellation, and more.
Parent threads bequeath their Scoped Values automatically to child threads if used in a StructuredTaskScope. The cross-thread shared values are immutable, so the required overhead is minimized, as the parent’s thread value doesn’t need to be copied.
Conclusion
The new scoping mechanism is a great addition to the JDK, as it counters many of the downsides that ThreadLocal<T>
has, especially if you want to use scoped values with modern Java code.
There’s still room for ThreadLocal<T>
, however, like for expensive values or mutable shared data. For example, a DateFormat
instance is mutable and cannot be shared without synchronization, so coupling its lifetime to a thread makes sense.
I’m really excited about how Scoped Values, especially in tandem with Structured Concurrency and Virtual Threads, will affect how we interact with concurrent code in the future.
Concurrency is hard to get right and easy to get wrong, so any simplification or more reasonable tools are always welcome in my opinion.
Interested in using functional concepts and techniques in your Java code?Check out my book “A Functional Approach to Java”!