并发08:虚拟线程
基于 Java 21 稳定特性,系统理解虚拟线程的设计思想、调度模型、适用场景和实践边界,包括 pinning、限流和资源保护。
虚拟线程解决什么问题
虚拟线程在 Java 21 成为稳定特性。它要解决的核心问题不是”让 CPU 算得更快”,而是”让大量阻塞等待的线程更便宜”。
传统平台线程和操作系统线程关系很近,创建和切换成本都不低。Web 服务里很多线程其实不是一直在计算,而是在等数据库、HTTP、Redis、文件 IO。等的时候还占着平台线程,线程池一满,请求就开始排队。
虚拟线程的思路是:业务代码仍然可以写阻塞风格,但线程阻塞时,JDK 可以把虚拟线程从承载它的平台线程上卸下来,让平台线程去跑别的虚拟线程。
虚拟线程 vs 平台线程
| 特性 | 平台线程 | 虚拟线程 |
|---|---|---|
| 与 OS 线程关系 | 一对一映射 | 多对一映射 |
| 创建和切换成本 | 高 | 低 |
| 数量上限 | 几千到几万 | 几十万甚至更多 |
| 阻塞时资源占用 | 占用平台线程 | 可卸载,释放平台线程 |
| 适用场景 | CPU 密集型 | IO 密集型 |
调度模型
虚拟线程不是操作系统直接调度的线程。它由 JDK 调度,运行时会挂载到平台线程上,平台线程也常被叫作 carrier thread。
当虚拟线程执行普通 Java 代码时,它占用 carrier;当它遇到支持卸载的阻塞操作时,JDK 可以把它从 carrier 上卸下来,carrier 去跑其他虚拟线程。等阻塞操作就绪,再把虚拟线程继续调度回来。
这就是”等待变便宜”的来源。以前一个请求等数据库时,占住一个平台线程;虚拟线程下,这个等待有机会不再占住 carrier。
但如果代码一直在做 CPU 计算,它就不会频繁卸载。此时虚拟线程仍然需要 carrier 执行,CPU 核数还是硬上限。创建 10 万个虚拟线程做计算,只会让调度更忙,不会让 CPU 更多。
典型用法
虚拟线程让”每个任务一个线程”重新变得可行:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<User> user = executor.submit(() -> userClient.get(id));
Future<List<Order>> orders = executor.submit(() -> orderClient.list(id));
return new Profile(user.get(), orders.get());
}
这段代码仍然是阻塞式 get(),但每个任务可以放在独立虚拟线程里执行。对大量 IO 等待的聚合接口来说,它比维护复杂回调链更容易理解。
容量上限仍然存在
虚拟线程很轻,但下游资源不轻。数据库连接池、Redis 连接、第三方接口限额、CPU、内存都还是有限的。
如果以前线程池大小是 200,虚拟线程改造后一口气放进 2 万个请求,下游数据库连接池还是只有 100 个。结果可能不是服务更快,而是更多请求同时压到连接池、超时和重试一起放大。
所以虚拟线程通常要和限流、连接池、信号量一起使用:
Semaphore permits = new Semaphore(100);
try {
permits.acquire();
return queryDatabase();
} finally {
permits.release();
}
ThreadLocal 和上下文
虚拟线程数量可以非常大,这会放大 ThreadLocal 滥用的问题。虚拟线程通常是任务级创建,用完结束,污染问题缓解了一些。
但如果每个虚拟线程都塞入很大的上下文对象,内存仍然会被放大。日志 traceId、用户上下文、租户信息这些可以传,但不要把大对象、缓存、连接这类资源放进 ThreadLocal。
引入虚拟线程前,要压测数据库驱动、HTTP 客户端、连接池、监控探针和日志上下文传递,别只看业务代码能不能编译。
pinning 问题
虚拟线程阻塞时,理想情况下可以从平台线程卸载。但有些情况会发生 pinning,也就是虚拟线程被固定在承载它的平台线程上,平台线程不能被释放出来。
| pinning 场景 | 说明 |
|---|---|
synchronized 代码块 | 在 synchronized 块里执行长时间阻塞操作 |
| native / foreign 方法 | 调用本地方法 |
| 某些 IO 操作 | 不支持卸载的阻塞 IO |
工程上要避免把慢 IO 放进大锁里。如果一个虚拟线程在持有 synchronized 锁时调用慢接口,它不仅占着业务锁,也可能让承载线程无法释放。更好的做法是缩小锁范围,把阻塞 IO 移到锁外。
和线程池不是替代关系
虚拟线程让”每个任务一个线程”重新变得可行,但它不意味着所有线程池都没用了:
- CPU 密集任务:仍然受 CPU 核数限制,需要控制并行度
- 资源隔离:订单查询、报表导出、第三方同步应该有各自的容量保护
- 容量限制:仍然要用限流、信号量、连接池、队列和超时表达
常见误区
误区一:虚拟线程能提升所有接口性能
它主要改善高并发阻塞 IO 的资源占用。如果瓶颈是 CPU、数据库慢 SQL 或下游限额,虚拟线程不能直接解决。
误区二:有了虚拟线程就不需要异步编程
虚拟线程让同步风格更有吸引力,但事件流、响应式背压、复杂任务编排仍然有自己的适用场景。
误区三:不再需要监控线程
虚拟线程数量可能非常大,更要看请求耗时、下游连接池等待、超时、错误率和 JDK Flight Recorder 里的虚拟线程事件。
项目里怎么试点
优先在这些地方试:
- 阻塞 IO 多、代码回调复杂的聚合查询
- 下游容量明确,有超时和连接池保护的接口
- 业务逻辑比较直线,不依赖复杂 ThreadLocal 上下文的链路
- 能压测对比平台线程池和虚拟线程表现的独立模块
暂时不建议一上来改造核心支付、强事务链路和大量依赖老旧库的复杂服务。先选一个边界清楚的读接口,把超时、限流、日志上下文和压测指标补齐,再判断收益。
一句话:虚拟线程的价值,是让阻塞等待不再轻易吃光昂贵的平台线程。它让 Java 同步代码在高并发 IO 场景里重新有了空间,但容量控制、状态保护和下游治理仍然要认真做。