Java 21 虚拟线程来了,别再到处乱用 ThreadLocal 了
在 Java 21 迎来虚拟线程(Virtual Threads)时代后,很多传统的并发编程习惯都在被颠覆。
过去,为了在线程中传递上下文(比如用户 Session、TraceID、事务信息),我们几乎毫无保留地选择 ThreadLocal。但在虚拟线程动辄创建几十万、上百万个的背景下,继续无脑使用 ThreadLocal 可能会让你的应用程序面临严重的性能灾难。
为了解决这个痛点,JDK 引入了一个全新的、更现代化的上下文存储方案——Scoped Values(作用域值)。
一、 为什么虚拟线程天生排斥 ThreadLocal?
要搞清楚为什么要换掉 ThreadLocal,首先得看看它在虚拟线程环境下的“三宗罪”:
1. 内存开销直接爆表
在平台线程(Platform Threads)时代,JVM 线程数一般也就几百到上千个。每个线程里挂载一个 ThreadLocalMap,即便里面塞点大对象,总内存占用也还算可控。
但是,虚拟线程是极度廉价的,一个 JVM 实例可以并发运行几十万甚至上百万个虚拟线程。如果你的框架或业务代码依然在每个虚拟线程里都往 ThreadLocal 塞一堆上下文数据:
$$\text{几十万个虚拟线程} \times \text{每个线程的 ThreadLocal 副本} = \text{OOM (内存溢出)}$$
虚拟线程的设计初衷是“用完即用,随起随消”,把它们当作轻量级的任务来对待,而不是当作长期承载大量状态的实体。
2. 继承开销是场灾难
在异步编程或多线程协作时,我们经常需要把主线程的上下文传递给子线程。Java 提供了 InheritableThreadLocal 来干这件事。
它的实现原理非常简单粗暴:创建子线程时,把父线程的 ThreadLocalMap 整个复制一份过去。
当你要启动上万个虚拟子线程去并发处理任务时,这种“全量复制”的内存和 CPU 开销会让你瞬间窒息。
3. 莫名其妙的内存泄漏与脏数据
ThreadLocal 的生命周期是跟线程绑定的。在传统的线程池(如 Tomcat、ExecutorService)中,如果用完不手动调用 remove(),下一个请求复用该线程时就会读到脏数据,甚至导致内存泄漏。
为了安全,我们不得不写出这种恶心的代码:
try {
threadLocal.set(context);
doSomething();
} finally {
threadLocal.remove(); // 必须手动擦屁股
}
二、 救世主:Scoped Value 登场
为了彻底解决上述痛点,JEP 429(在 Java 21 中作为预览特性引入,并在后续版本持续演进)推出了 Scoped Value(作用域值)。
ScopedValue 是一种隐式地、只读地、在受限生命周期内传递数据的机制。它非常适合用来替代 ThreadLocal 传递上下文。
1. 基础语法对比
我们先来看一段最直观的代码对比。
以前用 ThreadLocal:
public class LegacyContext {
public static final ThreadLocal<User> CURRENT_USER = new ThreadLocal<>();
public static void handle(User user) {
CURRENT_USER.set(user);
try {
executeBusiness();
} finally {
CURRENT_USER.remove(); // 极其重要,漏掉就出 Bug
}
}
}
现在用 ScopedValue:
import java.lang.ScopedValue;
public class ModernContext {
// 声明一个全局静态的 ScopedValue
public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
public static void handle(User user) {
// 绑定数据并运行,只在 run 块的生命周期内有效
ScopedValue.where(CURRENT_USER, user).run(() -> {
executeBusiness();
});
// 运行结束自动失效,无需手动 remove!
}
}
在 run() 方法(或者带返回值的 call())执行期间,任何在调用栈下游的方法(如 executeBusiness())都可以通过 CURRENT_USER.get() 拿到绑定的 user 对象。一旦超出 run() 的作用域范围,绑定关系自动解除。
三、 为什么 Scoped Value 完美契合虚拟线程?
ScopedValue 绝不仅仅是少写一行 remove() 那么简单,它的底层设计完美避开了 ThreadLocal 的所有坑。
1. 它是不可变的(Immutable)
ThreadLocal 支持 set() 修改。在多线程环境下,这允许下游方法篡改上游的数据,增加了代码的不确定性。
ScopedValue 没有 set() 方法。一旦通过 where() 绑定,在当前作用域内它就是只读的。如果下游想要临时修改,只能通过“重新绑定(Rebinding)”开启一个新的嵌套作用域:
ScopedValue.where(CURRENT_USER, adminUser).run(() -> {
// 此时 CURRENT_USER.get() 是 adminUser
ScopedValue.where(CURRENT_USER, guestUser).run(() -> {
// 在这个嵌套的作用域里,CURRENT_USER.get() 是 guestUser
});
// 退出来后,CURRENT_USER.get() 又变回了 adminUser
});
这种设计让数据流向极其清晰,消除了并发修改的安全隐患。
2. 超高效率的共享,无复制开销
当你配合 Java 21 的**结构化并发(Structured Concurrency)**使用时,ScopedValue 的威力才能发挥到极致。
使用 StructuredTaskScope 派生子线程(无论是虚拟线程还是普通线程)时,子线程会直接共享父线程绑定的 ScopedValue,底层不需要拷贝任何 Map,仅仅是通过一个类似于链表的数据结构向上追溯寻找值。
// 父线程绑定 ScopedValue
ScopedValue.where(TRACE_ID, "TX-999").run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// 派生出两个虚拟子线程
var task1 = scope.fork(() -> callServiceA());
var task2 = scope.fork(() -> callServiceB());
scope.join().throwIfFailed();
} // task1 和 task2 内部可以直接拿到 "TX-999",无需任何复制成本!
});
3. 极低的内存占用
既然没有了每个线程独立的、基于线性探测的 ThreadLocalMap 哈希表,也就没有了各种扩容和内存碎片的烦恼。ScopedValue 的实现对 JVM 进行了深度优化,它的读取性能(get() 操作)在很多高并发场景下甚至比 ThreadLocal 还要快。
四、 总结与迁移指南:我该怎么选?
是不是有了 ScopedValue,我们就要彻底废弃 ThreadLocal 呢?也不尽然。下面是它们的应用场景对比:
| 特性 / 维度 | ThreadLocal |
ScopedValue (Java 21+) |
|---|---|---|
| 可变性 | 可变(支持 set()) |
不可变(仅能通过 Scope 重新绑定) |
| 生命周期 | 与线程绑定(不易控制,易泄露) | 显式作用域绑定(生命周期结束自动销毁) |
| 子线程继承 | 极其昂贵(复制 Map) | 极度廉价(指针级共享) |
| 内存开销 | 高(每个线程都有 Map 副本) | 极低(共享结构) |
| 主打场景 | 线程内缓存(如 StringBuilder 复用) |
上下文传播(Session, TraceID, SecurityContext) |
落地建议:
- 上下文传递(Context Propagation):如果你的目的是在调用链路中传递 TraceID、当前登录用户、租户 ID 等,请毫无保留地迁移到
ScopedValue。这能让你的虚拟线程免于 OOM 的折磨。 - 重型对象复用(Object Reuse):如果你是为了避免频繁创建大对象(例如
SimpleDateFormat,或者某种缓冲 Buffer)而在线程中做缓存,ThreadLocal目前依然有其存在的价值(因为需要往里面set缓存对象)。 - 框架支持:目前主流的 Spring Boot 3.2+、Quarkus 等框架正在逐步原生适配虚拟线程和
ScopedValue。在编写业务代码时,多留意框架底层是否已经帮你完成了这些上下文的转换。
技术在进化,并发的范式也在改变。在 Java 21 时代,是时候改掉随手 new ThreadLocal() 的习惯了。