从 Java 21 到 Java 25:ThreadLocal 以外的选择—— ScopedValue

前言

书接上文。

上一篇(从 JDK1.2 到 JDK21:ThreadLocal的进化解决了什么问题)写了从 JDK 1.2 到 JDK 21,ThreadLocal 是怎么一步步演进到现在这个形态的。

到了 Java 21 之后,一个意想不到的事情发生了:

ThreadLocal 本身不再有任何变化。

从 Java 21 到 Java 25,ThreadLocal 的公开 API 基本没有变化:

scss 复制代码
get()
set()
remove()
initialValue()
withInitial()

Java 21 是这些方法,Java 25 还是这些方法。

但这并不意味着 JDK 不再解决 ThreadLocal 的问题。

恰恰相反。

JDK 选择了另一条路:

与其继续给 ThreadLocal 打补丁,不如重新设计一个更适合现代 Java 并发模型的上下文传递机制。

这个东西就是:

复制代码
ScopedValue

它从 Java 21 开始 Preview,经过 Java 22、23、24 连续调整,最终在 Java 25 正式转正

Java 21:线程变了

Java 21 最大的变化之一是:

复制代码
Virtual Thread

正式发布。

也就是 JEP 444。

以前 Java 服务可能只有:

yaml 复制代码
200
500
1000

个线程。

Virtual Thread 出现之后,一个应用同时存在几十万甚至上百万个虚拟线程都成为可能。

这时候 ThreadLocal 的一个问题被重新放大了:

ThreadLocal 是 per-thread 的。

每一个线程都有自己的:

复制代码
ThreadLocalMap

如果你有:

yaml 复制代码
1000 个平台线程

ThreadLocal 仅占用一点内存,问题可能不明显。

但如果变成:

复制代码
1000000 个虚拟线程

每个虚拟线程都保存一份 ThreadLocal 状态,情况就完全不同了。

JEP 444 因此明确提醒:

Virtual Thread 支持 ThreadLocal,但在大量虚拟线程场景下应该谨慎使用。

尤其不能再用 ThreadLocal 做这种事情:

swift 复制代码
ThreadLocal<Connection>
ThreadLocal<ByteBuffer>
ThreadLocal<ExpensiveObject>

然后试图达到:

复制代码
一个线程缓存一份昂贵资源

因为 Virtual Thread 的设计理念已经从:

复制代码
少量线程复用大量任务

变成:

复制代码
一个任务一个线程

这两个模型完全不同。

一个需要修正的认知:虚拟线程并没有让 ThreadLocal 更简单

很多人第一次看到 Virtual Thread,会有一个直觉:

虚拟线程生命周期短,所以 ThreadLocal 的问题是不是自然消失了?

是,也不是,只对了一半。

传统线程池:

css 复制代码
请求 A
  ↓
Thread-1
  ↓
ThreadLocal.set(A)

请求结束
  ↓
忘记 remove()

请求 B
  ↓
继续复用 Thread-1

这种因为线程长期复用导致的数据残留问题,在"一任务一虚拟线程"的模式下确实会明显减弱。

因为任务结束:

markdown 复制代码
VirtualThread
    ↓
生命周期结束
    ↓
ThreadLocalMap 一起变成可回收对象

但是另一边的问题又出现了:

复制代码
ThreadLocal 是每线程状态

Virtual Thread 最大的特点恰恰是:

复制代码
线程特别多

所以:

线程复用导致的长期驻留问题弱化了,但 per-thread 状态的规模问题反而被放大了。

这也是为什么 JDK 不再继续单纯优化 ThreadLocal,而是转头开始设计 ScopedValue。

Java 21:ScopedValue 第一次 Preview

Java 21 中,JEP 446 将 ScopedValue 作为 Preview API 引入。

它并没有试图去解决线程本地变量的问题,而是处理了 ThreadLocal 长期承担的另一个职责:

复制代码
上下文传递

比如:

复制代码
UserContext
TraceId
TenantId
SecurityContext
RequestContext

我们过去经常这样写:

swift 复制代码
private static final ThreadLocal<UserContext> CONTEXT =
        new ThreadLocal<>();

请求进入:

bash 复制代码
CONTEXT.set(context);

业务深处:

ini 复制代码
UserContext context = CONTEXT.get();

请求结束:

csharp 复制代码
try {
    service();
} finally {
    CONTEXT.remove();
}

问题就在最后这一步:

scss 复制代码
remove()

必须由业务代码自己进行处理。

一旦遗漏,就可能留下隐患。

ScopedValue 换了一个思路。

Java 21 最初的写法是:

scss 复制代码
private static final ScopedValue<UserContext> CONTEXT =
        ScopedValue.newInstance();

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

业务深处依然可以:

ini 复制代码
UserContext context = CONTEXT.get();

但最大的区别是:

scss 复制代码
service() 开始
    ↓
CONTEXT = context
    ↓
执行
    ↓
service() 结束
    ↓
绑定自动失效

无论正常返回还是抛异常,作用域结束之后绑定都会自动恢复。

没有 remove()。

ScopedValue 真正解决了 ThreadLocal 哪三个问题?

Oracle 在 Java 25 的 API 文档里直接列出了 ThreadLocal 用于"单向上下文传递"时的三个问题。

1. 生命周期没有边界

ThreadLocal:

scss 复制代码
CONTEXT.set(context);

service();

service() 执行结束之后:

复制代码
CONTEXT

依然存在。

除非:

csharp 复制代码
CONTEXT.remove();

ScopedValue:

scss 复制代码
ScopedValue.where(CONTEXT, context)
        .run(() -> service());

它的生命周期天然就是:

scss 复制代码
run() 的动态作用域

run 结束:

复制代码
绑定结束

所以它从机制上就直接避免了:

因为忘记 remove() 导致 ThreadLocal value 长期驻留的问题。

2. ThreadLocal 可以被任意下游代码修改

ThreadLocal:

bash 复制代码
CONTEXT.set(userA);

调用:

scss 复制代码
service();

但是 service 深处的某段代码完全可以:

bash 复制代码
CONTEXT.set(userB);

于是上层的上下文就会被更改。

ScopedValue 没有:

scss 复制代码
set()

读取方只能:

csharp 复制代码
CONTEXT.get();

不能修改当前绑定。

如果真的需要一个新的值,只能创建新的嵌套作用域:

scss 复制代码
ScopedValue.where(CONTEXT, userB)
        .run(() -> serviceB());

结束以后又自动恢复:

复制代码
userA
   ↓
进入子作用域
   ↓
userB
   ↓
退出
   ↓
userA

所以 ScopedValue 更接近 隐式 final 参数 ,而不是 线程全局变量

3. InheritableThreadLocal 的继承成本太高

如果要让子线程继承 ThreadLocal,通常会使用:

复制代码
InheritableThreadLocal

但它创建子线程时,还得处理父线程的 ThreadLocal 状态。

大量线程场景下,这种方式并不理想。

ScopedValue 从一开始就是为了:

diff 复制代码
Virtual Thread
+
Structured Concurrency

设计的。

配合 StructuredTaskScope 时,子任务可以继承当前 ScopedValue。

Java 25 文档描述它的实现成本,本质上接近 复制一个指针

而不是给每个子线程复制整套 ThreadLocal 的映射。

Java 22:ScopedValue 第二次 Preview

Java 22 对应:

复制代码
JEP 464

这一次没有修改 API。

官方给出的原因是要继续 Preview,收集更多使用反馈。

也就是说 Java 22 的重点不是继续加功能,而是验证这个模型到底靠不靠谱。

到这里 JDK 的方向已经很明显:

markdown 复制代码
ThreadLocal
    ↓
仍然保留

上下文传递
    ↓
逐渐交给 ScopedValue

6. Java 23:开始打磨异常处理

Java 23:

复制代码
JEP 481

ScopedValue 第三次 Preview。

这次出现了一个非常 Java 的调整。

新增:

php 复制代码
ScopedValue.CallableOp<T, X extends Throwable>

它允许:

scss 复制代码
callWhere()

更准确地保留 Lambda 抛出的异常类型。

例如:

ini 复制代码
String result = ScopedValue.callWhere(
        CONTEXT,
        context,
        () -> loadUser()
);

loadUser() 如果抛:

复制代码
IOException

编译器可以更准确地推导异常,而不是把异常处理搞得过于宽泛。

这个变化说明 ScopedValue 已经正式进入 API 的打磨阶段。

Java 24:接近最终形态

Java 24:

复制代码
JEP 487

第四次 Preview。

这次最大的变化如下:

删除 runWhere()callWhere()

之前:

scss 复制代码
ScopedValue.runWhere(
        CONTEXT,
        context,
        () -> service()
);

变成:

scss 复制代码
ScopedValue.where(CONTEXT, context)
        .run(() -> service());

有返回值:

scss 复制代码
User user = ScopedValue.where(CONTEXT, context)
        .call(() -> loadUser());

API 完全改成 Fluent 风格。

这个设计确实更容易理解:

scss 复制代码
where(...)
    ↓
绑定什么
    ↓
run / call
    ↓
在哪个作用域执行

如果有多个上下文:

scss 复制代码
ScopedValue.where(USER, user)
        .where(TRACE_ID, traceId)
        .where(TENANT, tenant)
        .run(() -> service());

整个作用域一目了然。

不得不赞叹 JDK 团队的深厚内力。

Java 25:ScopedValue 正式转正

Java 25:

复制代码
JEP 506

ScopedValue 结束了从 Java 21 开始的 Preview 阶段,成为正式 API。

最终 API 基本就是 Java 24 的形态:

scss 复制代码
private static final ScopedValue<UserContext> CONTEXT =
        ScopedValue.newInstance();

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

获取:

csharp 复制代码
CONTEXT.get();

判断:

ini 复制代码
CONTEXT.isBound();

默认值:

ini 复制代码
CONTEXT.orElse(defaultContext);

Java 25 最后还做了一个很小的调整:

scss 复制代码
orElse(null)

不再被允许。

到这里 ScopedValue 的 API 基本就定型了。

ThreadLocal 和 ScopedValue 的本质区别

最重要的区别在于生命周期模型。

ThreadLocal:

css 复制代码
Thread
│
├── ThreadLocalMap
│     │
│     ├── Context A
│     ├── Context B
│     └── Context C
│
└── 一直存在到 remove() 或线程结束

ScopedValue:

scss 复制代码
Thread
│
└── Dynamic Scope
      │
      ├── bind context
      │
      ├── service()
      │     └── service2()
      │           └── context.get()
      │
      └── scope end
            ↓
         自动解绑

ThreadLocal 的思想是:

复制代码
这个数据属于线程

ScopedValue 的思想是:

复制代码
这个数据属于这段调用链

这才是两者真正的区别。

一个典型的实践

以前:

csharp 复制代码
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();
    }
}

调用:

csharp 复制代码
try {
    UserContextHolder.set(context);

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

改成 Java 25 后:

csharp 复制代码
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);
    }
}

调用:

scss 复制代码
UserContextHolder.run(
        context,
        () -> orderService.createOrder()
);

最大的变化就是:

scss 复制代码
set()
remove()

全部消失了。

生命周期已经包含在代码结构里。

11. Structured Concurrency 才是 ScopedValue 的完整形态

如果只是单线程调用链:

复制代码
Controller
   ↓
Service
   ↓
Repository

ScopedValue 已经很好用。

但真正体现它设计价值的是:

diff 复制代码
Virtual Thread
+
ScopedValue
+
Structured Concurrency

例如 Java 25 中:

scss 复制代码
ScopedValue.where(CONTEXT, context).run(() -> {

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

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

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

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

        scope.join();
    }
});

这些由 StructuredTaskScope 创建的子任务可以继承父作用域中的 ScopedValue 绑定。

于是上下文传播变成:

css 复制代码
Request
   │
   └── ScopedValue
          │
          ├── VirtualThread A
          │
          ├── VirtualThread B
          │
          └── VirtualThread C

不需要:

scss 复制代码
context.set()
context.copy()
context.remove()

需要注意:

Java 25 中 ScopedValue 已正式发布,但 Structured Concurrency 仍然是 Preview API。

对于想体验结构化并发,甚至想在 Java 8 中使用结构化并发的朋友们,不妨来看看我的 ThreadForge ,在 Structured Concurrency 正式发布前不失为一个选择。

传送门:github.com/wuuJiawei/T...

ThreadLocal 会被 ScopedValue 淘汰吗?

不会。

因为它们解决的是不同的问题。

如果需要的是:

复制代码
当前线程拥有一份可变状态

ThreadLocal 依然合理。

例如某些真正意义上的:

perl 复制代码
thread-local state

但是如果需求是:

复制代码
把一个上下文沿调用链向下传递

例如:

复制代码
UserContext
TenantId
TraceId
RequestContext
Security Context

Java 25 的官方 API 文档已经明确建议:

对于不通过方法参数进行"单向数据传递"的场景,应优先考虑 ScopedValue,而不是 ThreadLocal。

所以它们未来更合理的分工应该是:

markdown 复制代码
ThreadLocal
    ↓
线程局部可变状态

ScopedValue
    ↓
调用链上下文

直接看完整变化

Java 版本 变化 解决的问题
Java 21 Virtual Thread 正式发布 Thread 模型发生变化,ThreadLocal 的规模问题被放大
Java 21 ScopedValue 第一次 Preview 解决 ThreadLocal 做上下文传递时生命周期无边界、可修改等问题
Java 22 ScopedValue 第二次 Preview API 不变,继续验证设计
Java 23 引入 CallableOp 改善异常类型推导
Java 24 删除 runWhere/callWhere API 改成统一 Fluent 风格
Java 25 ScopedValue 正式发布 上下文传递方案正式稳定
Java 25 orElse(null) 不再允许 收紧最终 API 语义

ThreadLocal 下一步可能是什么?

如果从 JDK 1.2 一直看到 Java 25,会发现 ThreadLocal 的演进其实分成了两个阶段。

第一阶段:

复制代码
JDK 1.2
   ↓
解决线程之间如何隔离变量

然后不断解决:

复制代码
GC
内存泄漏
哈希冲突
清理机制
易用性

但到了 Virtual Thread 时代,问题发生变化了。

大家大量使用 ThreadLocal,其实已经不只是为了:

sql 复制代码
Thread Local

而是在拿它做:

复制代码
Context Propagation

于是 JDK 没有继续往 ThreadLocal 里面塞功能。

而是重新抽象出:

复制代码
ScopedValue

所以从 Java 21 到 Java 25,真正发生的变化是:

JDK 开始把本来就不应该属于 ThreadLocal 的能力,从 ThreadLocal 身上拆出去。

最终形成:

markdown 复制代码
ThreadLocal
    ↓
线程局部状态

ScopedValue
    ↓
调用链上下文

Virtual Thread
    ↓
一个任务一个线程

Structured Concurrency
    ↓
管理任务之间的父子关系

这几块组合起来,才是 Loom 之后 Java 新并发模型真正想解决的问题。

从这个角度来理解,就能够看出来,ScopedValue 并不是 ThreadLocal 2.0。

它更像是 JDK 在用了二十多年 ThreadLocal 之后,对 "上下文到底应该属于线程,还是属于一次调用" 这一话题给出的新答案。

相关推荐
用户3169353811832 小时前
SpringBoot + Vue 项目生产环境部署完整指南
前端·后端
步行cgn2 小时前
MyBatis 多对一关联映射详解
java
仍然.2 小时前
SpringCloud---Eureka介绍
java·spring cloud·eureka
霸道流氓气质2 小时前
Spring 事务同步机制 —— AfterTransactionActionCollector 的异步解耦原理
java·后端·spring
Lightpwd2 小时前
PostgreSQL jsonb 写入踩坑:一条 `\u0000` 如何让整批同步失败,还把异常伪装成“数据完整性冲突”
java
Csvn2 小时前
📊 SQL 入门 Day 19:事务与隔离级别
后端·sql
这个DBA有点耶2 小时前
读懂半连接:IN/EXISTS子查询什么时候快、什么时候慢?
数据库·性能优化·架构
周周哈哈哈2 小时前
线程创建、执行、退出、回收
java·开发语言