JDK 21 虚拟线程:等待、限流与 pinning 边界
一次请求只计算几毫秒,却要等待远端响应几十毫秒。大量此类请求同时进入时,先耗尽的可能是工作线程,而非 CPU。虚拟线程降低了保存这些等待任务的成本,但远端服务的容量和每次计算的成本依然存在。
平台线程与虚拟线程:谁承载执行
平台线程通常与操作系统线程一一对应,由操作系统调度。虚拟线程也是 Thread,由 JVM 调度到称为载体(carrier)的平台线程上;真正执行指令时仍然需要操作系统线程。虚拟线程从载体卸载后,载体才能执行其他任务,恢复时也不保证使用原来的载体。
| 观察点 | 平台线程 | 虚拟线程 |
|---|---|---|
| 调度关系 | 操作系统调度线程 | JVM 将任务挂载到载体 |
| 等待成本 | 阻塞期间占用该线程资源 | 可卸载的等待释放载体 |
| 复用方式 | 常用有界线程池 | 通常每个任务创建一个 |
| CPU 计算 | 受核心数约束 | 同样受核心数约束 |
JDK 21 官方指南强调其适用场景是大量包含等待的并发任务。把四个计算线程换成四万个虚拟线程,不会增加核心数;持续压缩、排序等任务仍需控制计算并行度,额外调度反而可能增加开销。
I/O 等待时,载体去了哪里
以可卸载的阻塞网络读取为例,业务代码仍按顺序写,JVM 在内部保存执行状态。远端完成后,任务才进入可运行状态,等待调度,不是响应一到就立即获得 CPU。
卸载并不代表释放业务持有的连接或锁。也不能推导出所有阻塞操作都能卸载:本地方法、文件操作以及具体驱动的实现需要分别检查。在稳定负载下,并发中的请求数可近似理解为吞吐率乘响应时间;等待越长,同样吞吐量需要容纳的任务越多。这解释了降低等待成本为何有用,也解释了慢下游为什么容易积压。
并发上限应跟着下游容量走
newVirtualThreadPerTaskExecutor() 每提交一个任务便创建一个虚拟线程,不提供固定线程池那样的并发上限。下游只承受四个同时调用时,可以共享一个 Semaphore(4),而不是为每次请求各建一个信号量。
// 摘自配套示例的核心结构;gate 为所有任务共享的信号量
if (limited) gate.acquire();
try {
// 执行业务;示例用 Thread.sleep(50) 模拟等待
} finally {
if (limited) gate.release();
}获取许可在 try 之前:获取被中断时没有拿到许可,不能执行释放,否则会扩大容量。Semaphore 的官方契约还提供带超时的 tryAcquire,返回 false 时应走拒绝或降级分支。
信号量限制的是同时执行数,不是每秒请求数;QPS 限流需另外设计。等待许可的请求也会占内存,入口还应有容量边界和等待期限。数据库连接池通常已限制连接并发,但连接获取超时、查询超时仍需设置;不要把虚拟线程数量当成连接数配置依据。
JDK 21 的 pinning 与后续版本
JDK 21 中,在 synchronized 内执行阻塞操作可能无法卸载,载体跟着等待;执行 native 方法或外部函数也存在 pinning。真正影响扩展性的常是频繁、长时间的阻塞,短暂内存临界区无需一律替换。
优先考虑缩小锁范围,把远端调用移出临界区,但必须重新分析状态一致性;确实要持锁等待时,可评估 ReentrantLock,并用 finally 解锁。它解决相应的载体占用问题,不会让受互斥保护的业务自动并行。
版本边界是 JDK 24:JEP 491 消除了普通 synchronized 相关的 pinning。 JDK 24 官方指南在 pinning 条件中保留 native 与外部函数。不能把 JDK 21 的现象套到所有版本,也不能把升级理解为所有阻塞均可卸载。本文只在 JDK 21 实测,未执行 JDK 24 对照。
JDK 21 可用 -Djdk.tracePinnedThreads=full 输出阻塞栈,或用 JFR 的 jdk.VirtualThreadPinned 事件定位,默认事件阈值为 20ms。诊断时同时看 CPU、连接等待和超时分布;没有 pinning 栈不能证明系统不存在瓶颈。
配套实验能说明什么
源码位于 examples/java-advanced/virtual-threads-jdk21/,命令、版本和原始输出保存在 docs/upgrade-100/checks/virtual-threads-jdk21/run.txt。无需预览开关,用 JDK 21 的 javac --release 21 编译。
VirtualThreadsDemo 提交四十个睡眠任务,并逐一 Future.get() 传播异常。本机平台线程池四线程耗时 573ms,无限制虚拟线程 64ms,加四许可后 577ms,对应峰值为 4、40、4。这里只展示等待重叠,睡眠不是实际网络 I/O,也不是性能基准;耗时会随调度变化,不代表生产加速比。
预期失败路径用零许可加超时触发,输出 expected rejection;程序验证拒绝发生后正常退出,不是编译失败样例。PinningDemo 为每个任务分配独立锁,排除同一把锁的竞争,实测出现 reason:MONITOR 和持锁栈。载体并行度参数只用于辅助诊断,不据总耗时推断严格串行。
数据库、Redis 与真实网络压测均未执行。本例没有以它们的运行结果支撑结论。继续阅读:AQS 与 ReentrantLock、线程池过载和Selector 等待机制,可把锁、入口容量与 I/O 就绪通知联系起来。
