跪拜 Guibai
← Back to the summary

ScopedValue Graduates in Java 25: The JDK's New Answer to Context Propagation

Preface

Continuing from the previous article.

The previous article (From JDK 1.2 to JDK 21: What Problems Did the Evolution of ThreadLocal Solve?) covered how ThreadLocal evolved step by step from JDK 1.2 to JDK 21.

After Java 21, something unexpected happened:

ThreadLocal itself no longer underwent any changes.

From Java 21 to Java 25, the public API of ThreadLocal remained essentially unchanged:

get()
set()
remove()
initialValue()
withInitial()

These methods were the same in Java 21, and they are still the same in Java 25.

But this does not mean the JDK stopped addressing the problems of ThreadLocal.

Quite the opposite.

The JDK chose another path:

Rather than continuing to patch ThreadLocal, it's better to redesign a context propagation mechanism more suitable for modern Java concurrency models.

This thing is:

ScopedValue

It started as a Preview in Java 21, underwent continuous adjustments through Java 22, 23, and 24, and finally graduated to a stable feature in Java 25.

Java 21: Threads Changed

One of the biggest changes in Java 21 was:

Virtual Thread

officially released.

That is JEP 444.

Previously, a Java service might only have:

200
500
1000

threads.

After the emergence of Virtual Threads, it became possible for an application to have hundreds of thousands or even millions of virtual threads simultaneously.

At this point, a problem with ThreadLocal was magnified again:

ThreadLocal is per-thread.

Every thread has its own:

ThreadLocalMap

If you have:

1000 platform threads

ThreadLocal only occupies a little memory, and the problem might not be obvious.

But if it becomes:

1000000 virtual threads

where each virtual thread saves a copy of the ThreadLocal state, the situation is completely different.

JEP 444 therefore explicitly warns:

Virtual Threads support ThreadLocal, but they should be used with caution in scenarios with a large number of virtual threads.

In particular, you can no longer use ThreadLocal for things like:

ThreadLocal<Connection>
ThreadLocal<ByteBuffer>
ThreadLocal<ExpensiveObject>

and then try to achieve:

caching one expensive resource per thread

Because the design philosophy of Virtual Threads has shifted from:

a small number of threads reusing a large number of tasks

to:

one thread per task

These two models are completely different.

A Misconception to Correct: Virtual Threads Did Not Make ThreadLocal Simpler

Many people, upon first seeing Virtual Threads, have an intuition:

Since virtual threads have short lifecycles, do the problems of ThreadLocal naturally disappear?

Yes and no, only half right.

Traditional thread pool:

Request A
  ↓
Thread-1
  ↓
ThreadLocal.set(A)

Request ends
  ↓
Forget to remove()

Request B
  ↓
Continue reusing Thread-1

This kind of data residue problem caused by long-term thread reuse is indeed significantly weakened under the "one task per virtual thread" model.

Because when the task ends:

VirtualThread
    ↓
Lifecycle ends
    ↓
ThreadLocalMap becomes a collectible object together

But another problem emerges:

ThreadLocal is per-thread state

The biggest characteristic of Virtual Threads is precisely:

Threads are very numerous

Therefore:

The long-term residency problem caused by thread reuse is weakened, but the scale problem of per-thread state is instead amplified.

This is also why the JDK no longer continues to simply optimize ThreadLocal, but instead turns to designing ScopedValue.

Java 21: ScopedValue First Preview

In Java 21, JEP 446 introduced ScopedValue as a Preview API.

It did not try to solve the problem of thread-local variables, but rather addressed another responsibility that ThreadLocal had long shouldered:

Context Propagation

For example:

UserContext
TraceId
TenantId
SecurityContext
RequestContext

We used to often write:

private static final ThreadLocal<UserContext> CONTEXT =
        new ThreadLocal<>();

When a request enters:

CONTEXT.set(context);

Deep in the business logic:

UserContext context = CONTEXT.get();

When the request ends:

try {
    service();
} finally {
    CONTEXT.remove();
}

The problem lies in this last step:

remove()

It must be handled by the business code itself.

Once missed, it can leave hidden dangers.

ScopedValue takes a different approach.

The initial writing style in Java 21 was:

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

ScopedValue.runWhere(
        CONTEXT,
        context,
        () -> service()
);

Deep in the business logic, you can still:

UserContext context = CONTEXT.get();

But the biggest difference is:

service() starts
    ↓
CONTEXT = context
    ↓
Execution
    ↓
service() ends
    ↓
Binding automatically invalidated

Whether it returns normally or throws an exception, the binding is automatically restored after the scope ends.

No remove().

What Three Problems of ThreadLocal Does ScopedValue Really Solve?

Oracle's API documentation for Java 25 directly lists three problems with ThreadLocal when used for "one-way context propagation."

1. Unbounded Lifecycle

ThreadLocal:

CONTEXT.set(context);

service();

After service() finishes executing:

CONTEXT

still exists.

Unless:

CONTEXT.remove();

ScopedValue:

ScopedValue.where(CONTEXT, context)
        .run(() -> service());

Its lifecycle is naturally:

the dynamic scope of run()

When run ends:

Binding ends

So it mechanically avoids:

The problem of ThreadLocal values lingering due to forgetting remove().

2. ThreadLocal Can Be Modified by Any Downstream Code

ThreadLocal:

CONTEXT.set(userA);

Call:

service();

But some code deep inside service can perfectly well:

CONTEXT.set(userB);

Thus, the upper-level context gets changed.

ScopedValue has no:

set()

Readers can only:

CONTEXT.get();

They cannot modify the current binding.

If a new value is truly needed, only a new nested scope can be created:

ScopedValue.where(CONTEXT, userB)
        .run(() -> serviceB());

After it ends, it automatically restores:

userA
   ↓
Enter child scope
   ↓
userB
   ↓
Exit
   ↓
userA

So ScopedValue is closer to an implicit final parameter rather than a thread global variable.

3. The Inheritance Cost of InheritableThreadLocal is Too High

To allow child threads to inherit ThreadLocal, one usually uses:

InheritableThreadLocal

But when creating a child thread, it also has to process the parent thread's ThreadLocal state.

In scenarios with a large number of threads, this approach is not ideal.

ScopedValue was designed from the start for:

Virtual Thread
+
Structured Concurrency

When used with StructuredTaskScope, child tasks can inherit the current ScopedValue.

The Java 25 documentation describes its implementation cost as essentially close to copying a pointer,

rather than copying the entire set of ThreadLocal mappings for each child thread.

Java 22: ScopedValue Second Preview

Java 22 corresponds to:

JEP 464

This time, the API was not modified.

The official reason given was to continue the Preview and collect more usage feedback.

In other words, the focus of Java 22 was not to add more features, but to verify whether this model was truly reliable.

By this point, the JDK's direction was already clear:

ThreadLocal
    ↓
Still retained

Context Propagation
    ↓
Gradually handed over to ScopedValue

6. Java 23: Starting to Polish Exception Handling

Java 23:

JEP 481

ScopedValue's third Preview.

This time, a very Java-esque adjustment appeared.

Newly added:

ScopedValue.CallableOp<T, X extends Throwable>

It allows:

callWhere()

to more accurately preserve the exception type thrown by the Lambda.

For example:

String result = ScopedValue.callWhere(
        CONTEXT,
        context,
        () -> loadUser()
);

If loadUser() throws:

IOException

the compiler can more accurately infer the exception, rather than making exception handling overly broad.

This change indicates that ScopedValue has officially entered the API polishing stage.

Java 24: Approaching the Final Form

Java 24:

JEP 487

Fourth Preview.

The biggest change this time was as follows:

Removal of runWhere() and callWhere().

Before:

ScopedValue.runWhere(
        CONTEXT,
        context,
        () -> service()
);

Became:

ScopedValue.where(CONTEXT, context)
        .run(() -> service());

With a return value:

User user = ScopedValue.where(CONTEXT, context)
        .call(() -> loadUser());

The API was completely changed to a Fluent style.

This design is indeed easier to understand:

where(...)
    ↓
What to bind
    ↓
run / call
    ↓
In which scope to execute

If there are multiple contexts:

ScopedValue.where(USER, user)
        .where(TRACE_ID, traceId)
        .where(TENANT, tenant)
        .run(() -> service());

The entire scope is clear at a glance.

One cannot help but admire the profound skill of the JDK team.

Java 25: ScopedValue Officially Graduates

Java 25:

JEP 506

ScopedValue ended its Preview phase that started in Java 21 and became a stable API.

The final API is essentially the form from Java 24:

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

ScopedValue.where(CONTEXT, context)
        .run(() -> service());

Get:

CONTEXT.get();

Check:

CONTEXT.isBound();

Default value:

CONTEXT.orElse(defaultContext);

Java 25 also made one final small adjustment:

orElse(null)

is no longer allowed.

At this point, the ScopedValue API is basically finalized.

The Essential Difference Between ThreadLocal and ScopedValue

The most important difference lies in the lifecycle model.

ThreadLocal:

Thread
│
├── ThreadLocalMap
│     │
│     ├── Context A
│     ├── Context B
│     └── Context C
│
└── Exists until remove() or thread ends

ScopedValue:

Thread
│
└── Dynamic Scope
      │
      ├── bind context
      │
      ├── service()
      │     └── service2()
      │           └── context.get()
      │
      └── scope end
            ↓
         Automatic unbinding

The philosophy of ThreadLocal is:

This data belongs to the thread

The philosophy of ScopedValue is:

This data belongs to this call chain

This is the real difference between the two.

A Typical Practice

Before:

public class UserContextHolder {

    private static final ThreadLocal<UserContext> CONTEXT =
            new ThreadLocal<>();

    public static void set(UserContext context) {
        CONTEXT.set(context);
    }

    public static UserContext get() {
        return CONTEXT.get();
    }

    public static void remove() {
        CONTEXT.remove();
    }
}

Call:

try {
    UserContextHolder.set(context);

    orderService.createOrder();
} finally {
    UserContextHolder.remove();
}

After changing to Java 25:

public class UserContextHolder {

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

    public static UserContext get() {
        return CONTEXT.get();
    }

    public static void run(
            UserContext context,
            Runnable runnable) {

        ScopedValue.where(CONTEXT, context)
                .run(runnable);
    }
}

Call:

UserContextHolder.run(
        context,
        () -> orderService.createOrder()
);

The biggest change is:

set()
remove()

have all disappeared.

The lifecycle is already embedded in the code structure.

11. Structured Concurrency is the Complete Form of ScopedValue

If it's just a single-threaded call chain:

Controller
   ↓
Service
   ↓
Repository

ScopedValue is already very useful.

But what truly demonstrates its design value is:

Virtual Thread
+
ScopedValue
+
Structured Concurrency

For example, in Java 25:

ScopedValue.where(CONTEXT, context).run(() -> {

    try (var scope = StructuredTaskScope.open()) {

        scope.fork(() -> loadUser());

        scope.fork(() -> loadOrder());

        scope.fork(() -> loadCoupon());

        scope.join();
    }
});

These child tasks created by StructuredTaskScope can inherit the ScopedValue bindings from the parent scope.

Thus, context propagation becomes:

Request
   │
   └── ScopedValue
          │
          ├── VirtualThread A
          │
          ├── VirtualThread B
          │
          └── VirtualThread C

No need for:

context.set()
context.copy()
context.remove()

Note:

In Java 25, ScopedValue has been officially released, but Structured Concurrency is still a Preview API.

For those who want to experience structured concurrency, or even use structured concurrency in Java 8, feel free to check out my ThreadForge, which is an option before Structured Concurrency is officially released.

Link: https://github.com/wuuJiawei/ThreadForge

Will ThreadLocal Be Eliminated by ScopedValue?

No.

Because they solve different problems.

If what is needed is:

The current thread owns a piece of mutable state

ThreadLocal is still reasonable.

For example, some truly:

thread-local state

But if the requirement is:

Passing a context down the call chain

For example:

UserContext
TenantId
TraceId
RequestContext
Security Context

The official API documentation for Java 25 has already explicitly recommended:

For scenarios of "one-way data transmission" not through method parameters, ScopedValue should be preferred over ThreadLocal.

So their more reasonable division of labor in the future should be:

ThreadLocal
    ↓
Thread-local mutable state

ScopedValue
    ↓
Call chain context

See the Complete Changes Directly

Java Version Change Problem Solved
Java 21 Virtual Thread officially released Thread model changes, the scale problem of ThreadLocal is amplified
Java 21 ScopedValue first Preview Solves problems like unbounded lifecycle and mutability when ThreadLocal is used for context propagation
Java 22 ScopedValue second Preview API unchanged, continued design validation
Java 23 Introduced CallableOp Improved exception type inference
Java 24 Removed runWhere/callWhere API changed to a unified Fluent style
Java 25 ScopedValue officially released Context propagation solution officially stabilized
Java 25 orElse(null) no longer allowed Tightened final API semantics

What Might Be Next for ThreadLocal?

If you look from JDK 1.2 all the way to Java 25, you'll find that the evolution of ThreadLocal is actually divided into two stages.

The first stage:

JDK 1.2
   ↓
Solving how to isolate variables between threads

Then continuously solving:

GC
Memory leaks
Hash collisions
Cleanup mechanisms
Ease of use

But in the era of Virtual Threads, the problem changed.

Many people were using ThreadLocal extensively, not just for:

Thread Local

but were using it for:

Context Propagation

So the JDK did not continue to cram features into ThreadLocal.

Instead, it re-abstracted:

ScopedValue

Therefore, the real change from Java 21 to Java 25 is:

The JDK started to detach capabilities from ThreadLocal that shouldn't have belonged to it in the first place.

Ultimately forming:

ThreadLocal
    ↓
Thread-local state

ScopedValue
    ↓
Call chain context

Virtual Thread
    ↓
One task per thread

Structured Concurrency
    ↓
Managing parent-child relationships between tasks

The combination of these pieces is what the new Java concurrency model after Loom truly aims to solve.

Understanding it from this perspective, one can see that ScopedValue is not ThreadLocal 2.0.

It is more like the JDK's new answer, after using ThreadLocal for over twenty years, to the question of "whether context should belong to a thread or to a single invocation."

Comments

Top 1 from juejin.cn, machine-translated. The original thread is authoritative.

用户034362532444 1 likes

Love learning, love watching, just learning right here.