我把 JDK21 虚拟线程用成了"性能灾难",复盘完发现踩了三个大坑

上周项目从 JDK17 升级到 JDK21,兴冲冲地用上了虚拟线程,结果压测一跑,IO 密集接口的 QPS 居然比原来用平台线程的时候还低。折腾了两天才找到原因------原来我把虚拟线程用成了"穿了马甲的平台线程"。这篇文章不教你什么是虚拟线程,只讲我踩过的三个坑,以及怎么绕过。

背景交代:为什么我想用虚拟线程

先说说我踩坑的背景。项目是个典型的微服务,调用的下游接口多、IO 等待时间长。以前用 ThreadPoolExecutor,200 个线程撑死,再多 JVM 就开始报警。

看到 JDK21 的虚拟线程介绍,我的第一反应是:终于不用纠结线程池大小了

官方文档说虚拟线程的创建成本极低,底层阻塞 IO 时会自动让出平台线程,不需要池化复用。我当时想当然地认为,只要把 ThreadPoolExecutor 换成虚拟线程,问题就解决了。

结果证明,我too young too simple。


第一个坑:我把虚拟线程塞进了线程池

踩坑过程

看到项目里到处是这种代码:

ini 复制代码
ExecutorService executor = Executors.newFixedThreadPool(200, 
    Thread.ofVirtual().factory());

我想都没想,直接把平台线程池换成了虚拟线程池。心想:反正都是线程池,换个实现方式而已。

然后兴冲冲地部署、压测。结果------

diff 复制代码
压测结果:
- 平台线程池(200线程):QPS 1200,平均响应时间 85ms
- 虚拟线程池(200虚拟线程):QPS 1150,平均响应时间 92ms

WTF?虚拟线程反而更慢了?

排查过程

翻了半天才找到官方文档里这句话:

Virtual threads should not be pooled. A new virtual thread should be created for each task.

翻译成人话就是:虚拟线程设计出来就不是给你池化用的

虚拟线程的核心机制是:阻塞 IO 时自动把底层的平台线程(叫 Carrier Thread)让出来,让它去执行其他虚拟线程。如果你把虚拟线程池化,池里的虚拟线程被固定复用,阻塞时载体线程也被卡住,根本没法动态调度。

简单说就是:你用池化的方式,把虚拟线程"用成了"平台线程,还多了一层调度开销。

正确写法

ini 复制代码
// 错误写法(我就是这么写的)
ExecutorService pool = Executors.newFixedThreadPool(100, 
    Thread.ofVirtual().factory());

// 正确写法:不需要池化
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

每个任务创建一个虚拟线程,用完即弃,JVM 自己调度。


第二个坑:synchronized 导致的线程"钉住"

又一个翻车现场

换了写法之后,QPS 确实上去了,跑到了 1800。但过了一会儿,运维过来说:机器 CPU 使用率异常

我去查线程 dump,发现一堆线程处于 Parking 状态,堆栈里全是 synchronized 关键字。

原因分析

虚拟线程有个"钉住"(Pinning)机制:如果在 synchronized 块里执行阻塞操作,虚拟线程无法从载体线程上卸载,导致载体线程被"钉住"。

来看个典型场景:

typescript 复制代码
public class MyService {

    // 很多老代码里都有这种写法
    private final Object lock = new Object();

    public String callExternalAPI(String request) {
        synchronized (lock) {  // 这里钉住了!
            return httpClient.post()
                .uri("/api")
                .body(request)
                .retrieve()
                .body(String.class);  // 阻塞 IO
        }
    }
}

synchronized 块里调 HTTP 接口,虚拟线程无法卸载,载体线程被占着,CPU 空转。

正确做法

  1. 用 ReentrantLock 替代 synchronized
csharp 复制代码
private final Lock lock = new ReentrantLock();

public String callExternalAPI(String request) {
    lock.lock();
    try {
        return httpClient.post()
            .uri("/api")
            .body(request)
            .retrieve()
            .body(String.class);
    } finally {
        lock.unlock();
    }
}

ReentrantLock 支持可中断获取、超时获取,虚拟线程调用时会正确挂起。

  1. 缩短 synchronized 临界区

如果没法改锁,把 IO 操作移到临界区外面:

scss 复制代码
// 错误
synchronized (lock) {
    result = httpClient.post().uri("/api").retrieve().body();
}

// 正确:临界区只保护共享状态
String response = httpClient.post().uri("/api").retrieve().body();
synchronized (lock) {
    sharedState = parse(response);
}
  1. 开启钉住检测

启动参数加这一行,可以看到哪些代码触发了钉住:

ini 复制代码
java -Djdk.tracePinnedThreads=full -jar app.jar

运行时遇到钉住,控制台会打印具体堆栈。


第三个坑:ThreadLocal 跨请求残留

诡异的事故

换了写法、调了锁之后,线上突然出现了一些诡异的数据错乱。有个接口,A 用户查出来的数据是 B 用户的。

排查了半天,发现是 ThreadLocal 捣的鬼。

原因分析

以前一个用户请求绑定一个平台线程,ThreadLocal 里存的用户上下文不会串。

但虚拟线程是 M:N 调度,同一个载体线程可能被多个虚拟线程共享。虚拟线程用完即弃,如果 ThreadLocal 没清理干净,可能被下一个虚拟线程读到。

更坑的是,虚拟线程数量多、生命周期短,ThreadLocal 的内存占用会被放大。

正确做法

JDK 21 提供了 ScopedValue,专门替代 ThreadLocal:

arduino 复制代码
public class RequestContext {

    private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
    private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

    public static String currentUserId() {
        return USER_ID.get();
    }

    public static void main(String[] args) {
        // 在 ScopedValue的作用域内执行
        ScopedValue.runWhere(USER_ID, "user123", () -> {
            ScopedValue.runWhere(TRACE_ID, "trace-abc", () -> {
                // 这里是同一个作用域
                System.out.println("User: " + RequestContext.currentUserId());
                System.out.println("Trace: " + RequestContext.currentTraceId());
            });
        });
    }
}

ScopedValue 的特点是:

  • 作用域绑定:值只在当前作用域有效,出了作用域自动清理
  • 不可继承:子线程/子作用域拿不到
  • 内存安全:不存在跨请求残留问题

如果暂时不想迁移,InheritableThreadLocal 加上虚拟线程工厂也能用,但有隐患,不推荐。


最终效果

修复完三个坑之后,压测结果:

yaml 复制代码
平台线程池(200线程):QPS 1200,平均响应时间 85ms
虚拟线程(错误用法):QPS 1150,平均响应时间 92ms  
虚拟线程(正确用法):QPS 3200,平均响应时间 28ms

提升还是很明显的,前提是用对。


总结:虚拟线程的正确打开方式

错误做法 正确做法
把虚拟线程塞进线程池 newVirtualThreadPerTaskExecutor(),每个任务一个线程
synchronized 块里做 IO 用 ReentrantLock,或把 IO 移出临界区
继续用 ThreadLocal 迁移到 ScopedValue(JDK 21+)

还有一个原则:虚拟线程只适合 IO 密集型场景 。CPU 密集型任务(加密、计算、压缩),老老实实用 ForkJoinPool,虚拟线程帮不上忙。

相关推荐
沉默王二1 小时前
又一款国产模型诞生,StepPlan性价比杀疯了!
agent·ai编程·claude
勇敢的先登1 小时前
MCP 是什么?为什么 Function Call 之后还需要它
agent·ai编程
卡卡罗特AI1 小时前
Codex复刻小米MiMoCode官网,丝滑融入项目,只需要3步!保姆级教程!
人工智能·ai编程
做一个快乐的小傻瓜1 小时前
ZYNQ DEV套件引脚约束
java·linux·运维
sunneo1 小时前
本周 AI 新动态精选(2026.06.08–06.14)
人工智能·aigc·ai编程·ai写作·ai-native
CoderYanger1 小时前
Java EE:6.网络编程套接字(第二弹)
java·网络·程序人生·面试·职场和发展·java-ee·学习方法
devilnumber1 小时前
Java Lambda 表达式 200 条常见问题、坑点、易错点、规范清单
java·开发语言
极客先躯1 小时前
高级java每日一道面试题-2026年02月12日-实战篇[Docker]-什么是容器的 Seccomp 配置?如何自定义?
java·运维·分布式·docker·容器·自动化·文件