前言
书接上文。
上一篇(从 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正式发布前不失为一个选择。
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 之后,对 "上下文到底应该属于线程,还是属于一次调用" 这一话题给出的新答案。