我把 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,虚拟线程帮不上忙。

相关推荐
带刺的坐椅6 分钟前
Agent Harness 实战指南:构建生产级 AI Agent 的"马具"框架
java·ai·llm·agent·solon-ai
c238569 分钟前
互斥锁高频面试题全解:从基础概念到底层实现,一文通关
java·c++·面试·职场和发展
一次旅行16 分钟前
DeepSeek-V4 原厂直供模型即将登陆腾讯云!峰谷定价详解 + 实战调用指南
人工智能·腾讯云·ai编程
error:(1 小时前
【系统与实战双精通】VS Code 调试 ROS2 Python 节点与 Launch 系统指南
android·java·python
ServBay1 小时前
MCP (Model Context Protocol) 深度指南,协议原理、Python 实现与生产级安全实践
后端·ai编程·mcp
Tian_Hang1 小时前
Eclipse Ditto 的权限策略
java·服务器·前端·网络·ide·ubuntu·eclipse
小蓝波2 小时前
docker 的命令
java·docker·容器
阿拉斯攀登2 小时前
Embedding 模型选择与领域适配
人工智能·chatgpt·embedding·agent·ai编程·loop·rag
饼干哥哥2 小时前
用爱马仕多Agent组建跨境电商公司,出海赚美金
openai·agent·ai编程
AI人工智能+电脑小能手2 小时前
【大白话说Java面试题 第155题】【06_Spring篇】第15题:Spring 如何解决循环依赖问题?
java·spring·循环依赖·三级缓存·aop代理