Java 21 Scoped Values: A Deep Dive with Examples

In Java programming, sharing data across different parts of the application running within a single thread is often a crucial task. In the past, developers have relied on “ThreadLocal” variables to achieve this. However, with the introduction of VirtualThread and the need for scalability, sharing data through ThreadLocal variables is not always efficient. This is where Scoped Values come into play.

The Scoped values were proposed as an incubator preview feature in Java 20 (JEP-429) in addition to structured concurrency. In JDK 21 (JEP-446), this feature is no longer incubating; instead, it is a preview API.

See Also: Java 21 New Features

1. Understanding the Challenges with ThreadLocal

Since Java 1.2, ThreadLocal variables were the recommended way to share data within the boundary of the thread, without passing them explicitly as method arguments. We can set them in one part of the application (say controller) and later access their value in another part of the application (say dao).

In the old programming paradigm, ThreadLocal variables were handy back then because we didn’t have too many threads. Such programs were allowed to change the shared data as it went through various steps within the same thread in different parts of the application.

1.1. Understanding ThreadLocal with a Simple Program

Syntactically, ThreadLocal variables were public static fields accessible anywhere in the application. But the value they contained was different for each thread, whatever they set. We accessed these thread local variables by their name and used their set() and get() methods for setting and getting the values.

Let us understand thread-local variables with a code example. In the following program, we are starting a thread and setting a ThreadLocal value for CONTEXT variable. When we access its value in insideParentThread() method, we are able to get the value that is expected.

public static ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);

Thread parentThread = new Thread(() -> {



void insideParentThread() {
    System.out.println("ThreadLocal Value in insideParentThread(): " + CONTEXT.get());

// Prints 'ThreadLocal Value in insideParentThread(): TestValue'

1.2. Child Threads do not have access to Parent’s ThreadLocal

The problem starts when we create child threads within parent threads. There could be any reason, such as batch processing, remote API calls etc. Child threads do not get access to thread-local values of the parent thread.

Let us understand with an example.

public static ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);
//public static InheritableThreadLocal<String> CONTEXT = new InheritableThreadLocal();

public static void main(String[] args) throws InterruptedException {

  Thread parentThread = new Thread(() -> {


    Thread childThread = new Thread(() -> {



static void insideParentThread_1() {
    System.out.println("ThreadLocal Value in insideParentThread_1(): " + CONTEXT.get());

static void insideParentThread_2() {
    System.out.println("ThreadLocal Value in insideParentThread_2(): " + CONTEXT.get());

static void insideChildThread() {
    System.out.println("ThreadLocal Value in insideChildThread(): " + CONTEXT.get());

Program output will be:

ThreadLocal Value in insideParentThread_1(): TestValue
ThreadLocal Value in insideParentThread_2(): TestValue
ThreadLocal Value in insideChildThread(): null

We can see that the value of CONTEXT is not accessible in the child thread. To access this value, we must either pass it thread as a parameter and then the thread must create its own ThreadLocal variables. As a relief, we can use InheritableThreadLocal which was designed for this specific reason.

1.3. InheritableThreadLocal is a ‘Better Alternative’, but It is also Inefficient

The InheritableThreadLocal class does the same thing as ThreadLocal, but it also allows child thread to access the thread-local values from the parent thread.

If, in the previous program, we replace ThtreadLocal with InheritableThreadLocal, then we can see the output changes as expected.

//public static ThreadLocal<String> CONTEXT = ThreadLocal.withInitial(() -> null);
public static InheritableThreadLocal<String> CONTEXT = new InheritableThreadLocal();


The program output:

ThreadLocal Value in insideParentThread_1(): TestValue
ThreadLocal Value in insideParentThread_2(): TestValue
ThreadLocal Value in insideChildThread(): TestValue     // Thread local value is accessible now

At first sight, InheritableThreadLocal seems to solve the data-sharing issues among parent and child threads, but when we peek into the memory footprint, it seems concerning.

By design, Java threads do not share the memory space, and each thread works its own separate memory area in the heap. So when we create a child thread, even though we say that data has been shared, effectively, a copy of data is created in the thread space.

So if we create 100 child threads, then 100 new instances are created in memory. For a small number of threads, it does not bother much, but when we are talking about virtual threads that can be created in millions, then suddenly, this memory footprint becomes a concern.

So if we create a million threads, we must create a million ThreadLocal instances also. Such a large number of instances can lead to degraded performance in resource-intensive applications. Scoped values intend to solve this issue.

2. Scoped Values help in Sharing Data with a Million Virtual Threads

By definition, a Scoped Value is a value that is set once and is then available for reading for a bounded period of execution by a thread. In the bounded period, the thread can fork child threads, and those child threads will also access the same copy of the scoped value.

2.1. How Scoped Values Work?

Note that scoped values work differently than ThreadLocal variables, and they are not similar constructs. A scoped value is essentially a method parameter that we do not need to declare, and Java automatically binds it to all method calls in the thread. We can directly access it in its bounded context.

In effect, a ScopedValue is an implicit method parameter.

A typical syntax to create a scoped value and bind to a thread is as follows. In the next example, we can access the value of CONTEXT in the method doSomething(). When the thread finishes, the value is said to be unbound.

private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();

ScopedValue.runWhere(CONTEXT, "TestValue", () -> doSomething());

Similarily, when we fork new threads from the parent thread, then all child virtual threads automatically can access to scoped value.

In the following example, we can access the CONTEXT value inside and all three methods i.e. insideParentThread, insideChildThread1 and insideChildThread2.

private static final ScopedValue<String> CONTEXT = ScopedValue.newInstance();

ScopedValue.runWhere(CONTEXT, "TestValue", () -> {


  try (var scope = new StructuredTaskScope<String>()) {

    scope.fork(() -> insideChildThread1());
    scope.fork(() -> insideChildThread2());


Scoped values are only bound to virtual threads that are forked using the StructuredTaskScope.fork() method. If we create new threads using other means (such as extending Thread class) then we will not be able to access the scoped value in them.

2.2. Scoped Values Solve the Memory Footprint Problem

Now let us focus on our area of interest i.e. memory footprint, which was the major concern in thread-local variables.

As mentioned earlier, Scoped values are invisible method parameters. We know that Java is ‘pass by value’, and when we pass an object as a method parameter, only the reference of the variable is copied, and it does not create a new instance in the memory.

So when we bind a scoped value to a thread, and it forks a million other threads, even then the number of objects in memory remains only one. This is where the scoped values solve the memory footprint issue.

Another important thing to remember is that Scoped values are immutable. So we can pass them to any level of depth or any number of forked threads without worrying that anyone can corrupt them.

2.3. What happens to Scoped Values outside the Bounded Context?

Once the bounded context is over i.e. thread having access to scoped value finishes, it becomes unbounded. This means if we try to get the value of scoped value outside the bounded context, we will get NoSuchElementException.

So it is always a best practice to use isBound() method before accessing the scoped value to prevent unwanted exceptions.

ScopedValueTest instance = new ScopedValueTest();

ScopedValue.runWhere(CONTEXT, "Test Value", () -> {


System.out.println("Outside bounded scope isBound() is: " + CONTEXT.isBound()); 
System.out.println("Outside bounded scope the scoped value is: " + CONTEXT.orElse(null));

The program output:

Outside bounded scope isBound() is: false
Outside bounded scope the scoped value is: null

3. Forked Virtual Threads can have Rebounded Values

The scoped values are bound to the thread context for which it is created. To make things even better, we can rebind a value to a scoped variable, and that will be available in the context of the child thread only.

Let us understand with an example. In the following example, initially the value bound to the parent thread is “Test Value“. But in the forked child thread, we changed the value to “Changed Value“. During the child thread scope, the value of CONTEXT will be “Changed Value” and outside the child thread i.e. in parent thread the value will be “Test Value“.

ScopedValue.runWhere(CONTEXT, "Test Value", () -> {

  System.out.println("In parent thread start the scoped value is: " + CONTEXT.get());
  System.out.println("In parent thread end the scoped value is: " + CONTEXT.get());

public void doSomething() {

  System.out.println("In doSomething() and parent scope: " + CONTEXT.get());

  ScopedValue.runWhere(CONTEXT, "Changed Value", () -> {
    System.out.println("In doSomething() and child scope: " + CONTEXT.get());

public void doSomethingAgain() {
  System.out.println("In doSomethingAgain() and child scope: " + CONTEXT.get());

Check out the program output:

In start the scoped value is: Test Value
In doSomething() and parent scope: Test Value

In doSomething() and child scope: Changed Value
In doSomethingAgain() and child scope: Changed Value

In end the scoped value is: Test Value

4. Passing Multiple Scoped Values

When we want to share multiple values in the thread context and child threads, it is always better to create a record containing all values and share it through normal means.

A big advantage of this approach is that if, in the future, we want to add more values or remove certain values from the scope, code changes will be minimal and more maintainable.

public record ApplicationContext (Principal principal, Role role, Region region) { }

private final ApplicationContext CONTEXT = new ApplicationContext(...);

ScopedValue.runWhere(ApplicationContext, CONTEXT, () -> {


5. Conclusion

Scoped values solve a very specific problem of sharing data with virtual threads that can be created in millions. If not taken care, it can bring down the performance of any resource-intensive application.

In this Java concurrency tutorial, we learned about the Scoped Values, bounded contexts, data sharing within/outside the bounded context and finally, accessing the scoped values in the forked child threads.

Happy Learning !!

Source Code on Github


Notify of
Inline Feedbacks
View all comments

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.