WEBKT

Java 21 虚拟线程中大量使用 ThreadLocal 会导致 Pinning 吗?深度剖析 JVM 运行机制

3 0 0 0

在 Java 21 正式引入虚拟线程(Virtual Threads)后,高并发通道的构建变得前所未有的简单。然而,伴随这一新特性的推广,许多开发者在适配老旧代码库时产生了一个普遍的疑问:

“在虚拟线程中如果继续大量使用 ThreadLocal,会导致平台线程(Carrier Thread)被固定(Pinning)吗?”

直接给出结论:不会导致 Pinning。但它会带来比 Pinning 更致命的问题——内存海啸与严重的性能退化。

本文将从 JVM 源码设计和虚拟线程的调度原理出发,深度拆解其中的运行机制。


一、 拨乱反正:到底什么才会导致 Carrier Thread Pinning?

在虚拟线程的术语中,Pinning(固定) 是指虚拟线程在执行某些操作时,无法从底层的平台线程(Carrier Thread)上释放(Unmount)。一旦发生 Pinning,当该虚拟线程遭遇阻塞操作(如网络 I/O)时,它不仅无法让出平台线程,还会导致整条平台线程被一起阻塞,直接削弱了虚拟线程高并发的优势。

根据 OpenJDK 的设计,只有以下两种情况会导致 Carrier Thread Pinning:

  1. 进入了 synchronized 块或方法内部,并且在该同步锁内执行了阻塞操作(如 I/O、Thread.sleep() 等)。
  2. 执行了本地方法(Native Method) 或通过 FFM(Foreign Function & Memory API)调用了外部 C/C++ 函数。

ThreadLocal 的底层读写纯粹是 Java 堆内存的操作,不涉及任何 native 监视器锁(Monitor Lock)的竞争,也无需进入操作系统的核心态调用。因此,读取或写入 ThreadLocal 绝对不会触发 Pinning


二、 虚拟线程是如何处理 ThreadLocal 的?

为了理解为什么 ThreadLocal 不会导致 Pinning,我们需要看一下 java.lang.Thread 在 JDK 21 中的底层重构。

在 Java 中,每个线程(不管是平台线程还是虚拟线程)都有一个独立的 threadLocals 引用,它指向一个 ThreadLocal.ThreadLocalMap 对象。

// java.lang.Thread 源码片段
class Thread implements Runnable {
    /* 与此线程相关的 ThreadLocal 值。此 map 由 ThreadLocal 类维护 */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    /* 与此线程相关的 InheritableThreadLocal 值 */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

虚拟线程 VirtualThread 在类继承层面上也是 java.lang.Thread 的子类。这意味着,每一个虚拟线程对象,都拥有自己专属的 threadLocals 映射表

1. 挂起(Yield)与恢复(Resume)时的上下文切换

当虚拟线程在运行过程中,遇到非阻塞 I/O 并决定挂起(Unmount)时,JVM 底层发生了什么?

【运行状态】
Carrier Thread (平台线程) <--- 绑定 --- VirtualThread 实例 (在堆上)
                                          └── threadLocals Map (保存数据)

【挂起状态 (Unmount)】
Carrier Thread (回到线程池,清空上下文) 
VirtualThread 实例 (静静地躺在 JVM 堆内存中,其 threadLocals 依然保存在该实例内)

当虚拟线程被挂起时,JVM 调度器会将其从 Carrier Thread 上卸载。此时:

  • 虚拟线程的调用栈和它内部的 threadLocals 引用,会随着虚拟线程对象一起保存在 JVM 堆内存(Heap)中
  • 底层的 Carrier Thread 在回到 ForkJoinPool 之前,会清除其携带的某些特定上下文,为调度下一个虚拟线程做好准备。
  • 当虚拟线程被重新唤醒并绑定(Mount)到另一个 Carrier Thread 上时,它依然通过自身的 this 引用访问原本就属于自己的 threadLocals

这一套 Mount/Unmount 流程非常轻量,完全由 JVM 在用户态完成,不存在把底层平台线程“锁死”的物理连接


三、 不会导致 Pinning,那真正的危险是什么?

既然不会导致 Pinning,为什么社区和官方都极力告诫开发者:避免在虚拟线程中滥用 ThreadLocal

这是因为两者的设计哲学存在根本性的冲突。

1. 致命的内存暴涨(OOM)

在传统的 Web 框架(如 Tomcat)中,我们使用平台线程池。线程池的大小通常是固定的(比如 200 个线程)。

  • 平台线程场景:200 个线程 × 每个线程持有的 ThreadLocalMap(假设 5MB)= 1GB 内存。这个开销是可控且稳定的。

但在 Java 21 下,虚拟线程的生命周期非常短暂,且创建成本极低。你可能会同时运行 1,000,000(一百万)个虚拟线程。

  • 虚拟线程场景:1,000,000 个虚拟线程 × 5MB 的 ThreadLocal = 4.88TB 内存

即使你的 ThreadLocal 只存了一个轻量级的 TraceId(假设 1KB),在百万级并发下:

  • 1,000,000 × 1KB ≈ 1GB 内存

这仅仅是保存一个字符串的代价!如果你的代码或者第三方依赖(如 Spring、MyBatis 的连接管理、日志上下文等)隐式地在 ThreadLocal 里塞入了较大的对象,JVM 内存会在瞬间崩塌,直接抛出 java.lang.OutOfMemoryError: Java heap space

2. InheritableThreadLocal 导致的灾难级拷贝

如果代码中误用了 InheritableThreadLocal(可继承的线程本地变量),情况会更加糟糕。

当你在一个父虚拟线程中创建子虚拟线程时,JVM 会默认复制父线程的 InheritableThreadLocal 内容。如果这个操作发生在上百万次虚拟线程的创建过程中,CPU 几乎会全部耗费在 ThreadLocalMap 的创建和浅拷贝上,系统吞吐量呈断崖式下跌。


四、 破局者:如何优雅地替代 ThreadLocal?

为了解决虚拟线程下 ThreadLocal 的尴尬处境,Java 21 引入了全新的 ScopedValue(作用域值)

1. 什么是 ScopedValue?

ScopedValue 是一种单向、不可变、绑定生命周期的值传递机制。它专为百万级虚拟线程的高效数据共享而生。

public class SecurityContext {
    // 定义一个全局的 ScopedValue
    public final static ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();

    public void handleRequest(User user) {
        // 在特定作用域内绑定值并执行任务
        ScopedValue.where(CURRENT_USER, user).run(() -> {
            // 在这个作用域内,任何深层调用的方法都可以安全读取
            doSomething();
        });
    }

    private void doSomething() {
        User user = CURRENT_USER.get(); // 高效读取,且不可篡改
        System.out.println("User: " + user.name());
    }
}

2. ScopedValue 为什么比 ThreadLocal 更优秀?

  1. 单向不可变(Immutable)ScopedValue 一旦绑定,在其作用域内无法被修改(无 set 方法),这保证了并发安全,且极大地降低了 JVM 维护数据的复杂度。
  2. 生命周期明确:它的有效期仅限于 where(...).run(...) 块内。一旦代码块执行完毕,绑定关系立即解除,绑定的对象可以被 GC 迅速回收。而 ThreadLocal 往往需要手动 remove(),极易造成内存泄漏。
  3. 极佳的空间效率:在底层,多个虚拟线程可以共享同一个 ScopedValue 实例的引用,而不需要像 ThreadLocal 那样为每个线程都去哈希表里建格子,极大地节省了堆空间。

五、 总结与最佳实践

对于正在或者准备升级到 Java 21 虚拟线程的项目,建议遵循以下落地规范:

  1. 明确认知:大量使用 ThreadLocal 并不会导致 Carrier Thread Pinning,但会导致内存溢出(OOM)
  2. 排查第三方库:注意排查那些频繁使用 ThreadLocal 缓存大对象(如 SimpleDateFormat、编解码 Buffer 等)的旧版本框架,及时升级其版本或进行配置平替。
  3. 拥抱新特性:对于新写的方法内上下文透传,优先使用 ScopedValue,而非 ThreadLocal
  4. 兜底策略:如果必须在虚拟线程中使用 ThreadLocal,请确保:
    • 存储的对象极小(如纯文本 ID)。
    • 务必在使用完毕后,在 try-finally 块中显式调用 remove()
码农路易 Java 21虚拟线程

评论点评