WEBKT

Java 21 虚拟线程来了,别再到处乱用 ThreadLocal 了

3 0 0 0

在 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)

落地建议:

  1. 上下文传递(Context Propagation):如果你的目的是在调用链路中传递 TraceID、当前登录用户、租户 ID 等,请毫无保留地迁移到 ScopedValue。这能让你的虚拟线程免于 OOM 的折磨。
  2. 重型对象复用(Object Reuse):如果你是为了避免频繁创建大对象(例如 SimpleDateFormat,或者某种缓冲 Buffer)而在线程中做缓存,ThreadLocal 目前依然有其存在的价值(因为需要往里面 set 缓存对象)。
  3. 框架支持:目前主流的 Spring Boot 3.2+、Quarkus 等框架正在逐步原生适配虚拟线程和 ScopedValue。在编写业务代码时,多留意框架底层是否已经帮你完成了这些上下文的转换。

技术在进化,并发的范式也在改变。在 Java 21 时代,是时候改掉随手 new ThreadLocal() 的习惯了。

码农老崔 Java 21虚拟线程

评论点评