虚拟线程常见问题总结
虚拟线程是 JDK 21 正式落地的轻量级线程:JVM 把海量虚拟线程调度到少量平台线程上,阻塞时挂起并让出载体,让我们用同步阻塞代码的写法拿到高并发 I/O 吞吐。本文讲清它的调度原理、用法边界与 pinning 陷阱。
它解决什么问题
平台线程是对操作系统内核线程的一对一封装:一个平台线程从创建到销毁都占着一个内核线程,栈内存按 MB 计,上下文切换要进出内核态。线程池能摊薄创建成本,却改不了「同时存活的线程数受限」这个事实。算一笔账:接口平均耗时 500ms、目标 2000 QPS,按 Little’s Law 就需要约 1000 个并发请求;每请求独占一个平台线程时,线程数往往先于 CPU 和带宽成为瓶颈。
反应式编程用事件循环加回调把线程从等待中解放出来,代价是把顺序代码拆成回调链:异常处理、调试、上下文传递全面复杂化,火焰图和线程转储也变得难以解读。虚拟线程的目标正是兼得两者:保留同步代码的可读性与完整调用栈,同时让等待 I/O 不再钉住昂贵的内核线程。它提升的是吞吐与并发承载力,不是单个请求的速度。
核心原理
虚拟线程是 java.lang.Thread 的一种实现,由 JVM 调度而非操作系统。调度模型是 M:N:大量虚拟线程被映射到少量平台线程(载体线程,carrier thread)上执行。JEP 444 规定的调度器是一个 FIFO 模式的 work-stealing ForkJoinPool,并行度默认等于可用处理器数,与并行流的 commonPool 不是同一个池。
挂起与恢复依赖 Continuation 机制:虚拟线程的栈帧以栈块对象的形式存放在 Java 堆中,可随执行伸缩。遇到支持挂起的阻塞点(网络 I/O、BlockingQueue.take、Thread.sleep 等,JDK 内部已完成适配)时,JVM 保存当前栈状态,把虚拟线程从载体上卸载(unmount),载体立刻去执行其他虚拟线程;I/O 就绪后,虚拟线程被重新提交调度,挂载(mount)到某个载体上继续运行。操作系统自始至终只看见平台线程,对虚拟线程一无所知。正因为栈在堆里、按块分配,一次创建成千上万个虚拟线程才成为现实。
深入机制
创建方式
// 最直接:启动一个虚拟线程;它是守护线程,main 不 join 可能直接退出 |
newVirtualThreadPerTaskExecutor 不是传统线程池:它不复用线程,而是为每个任务新建一个虚拟线程。正确心智是「每个任务一个线程」,不要池化——虚拟线程足够便宜,池化没有意义。需要限流时应限制资源本身,例如对脆弱的老系统用 Semaphore 控制并发许可,而不是缩减虚拟线程数量。
Pinning:载体被钉住
挂起的前提是阻塞点支持卸载。两类情况会把虚拟线程「钉」在载体上:其一,在 synchronized 块或方法内发生阻塞——JDK 21 至 23 中 monitor 与载体线程绑定,无法中途卸载;其二,执行 native 方法或外部函数(FFM)时阻塞。钉住期间,载体连同其内核线程一起被占住,并发度瞬间回落到平台线程水平。
// JDK 21-23 的高危写法:持有 monitor 做慢 I/O,阻塞时载体被钉住 |
应对策略:缩小 synchronized 临界区、把慢 I/O 挪到锁外,高频阻塞路径改用 ReentrantLock(其锁挂起对虚拟线程友好)。JDK 24 的 JEP 491 消除了 synchronized 引发的 pinning,锁内阻塞也能释放载体;若仍停留在 JDK 21–23,可用 -Djdk.tracePinnedThreads=full 或 JFR 的 jdk.VirtualThreadPinned 事件定位钉住点。短小纯内存操作的 synchronized 无须紧张,真正危险的是持锁查库、调远程接口。
工程实践
- 不池化、不设上限;容量管理的对象应是数据库连接池、HTTP 连接池、下游限流器这些真实资源。虚拟线程让等待变便宜,但不会让共享资源变多。
- 慎用 ThreadLocal 缓存大对象:平台线程池时代一份缓存只有几十上百份,切到每任务一个虚拟线程后副本数随任务量爆炸。传不可变小上下文(traceId 之类)问题不大;重型对象可关注 JDK 25 转正的 Scoped Values(JEP 506)。
- 迁移后的第一道新瓶颈常是连接池:原来 200 线程天然限流,如今上万并发同时打向数据库,需重新压测并调整池容量。
- 观测手段要换代:jstack 面对海量虚拟线程不可用,改用
jcmd <pid> Thread.dump_to_file -format=json导出转储,配合 JFR 的虚拟线程事件分析。 - 虚拟线程不消除线程安全问题:共享可变状态仍需加锁或隔离,非线程安全的客户端与会话对象不能被多个虚拟线程混用。
对比与权衡
| 维度 | 平台线程 | 虚拟线程 | 反应式(WebFlux 等) |
|---|---|---|---|
| 调度者 | 操作系统(1:1 内核线程) | JVM(M:N 挂载载体) | 事件循环(少量线程) |
| 代码风格 | 同步阻塞 | 同步阻塞 | 回调 / 响应式流水线 |
| 可承载并发 | 数千级 | 百万级任务挂起等待 | 极高 |
| 调试与观测 | 成熟 | 成熟(仍是 Thread) | 调用栈被切碎,较困难 |
| 适用场景 | CPU 密集、传统低并发 | 高并发 I/O 等待型服务 | 流式、背压、已全链路异步的系统 |
选型结论:同步阻塞模型、I/O 等待为主的服务,虚拟线程几乎是最低成本的可扩展性升级;CPU 密集任务三种方案都不会变快,瓶颈在核心数;已深度反应式化的系统不必回头重写。
面试视角
虚拟线程与平台线程的本质区别是什么? 平台线程与内核线程 1:1 绑定,由操作系统调度;虚拟线程由 JVM 调度,M:N 映射到载体平台线程上,栈存于堆中可伸缩,阻塞挂起时让出载体,因此数量可远超内核线程。
虚拟线程为什么能提升吞吐?单次请求会变快吗? 它提升的是并发承载能力:等待 I/O 的虚拟线程不再占着内核线程,同样硬件可同时推进更多请求。任何单次 I/O 或计算的耗时都不变,单请求延迟不会因此下降。
什么是 pinning,如何避免? 虚拟线程在 synchronized 内阻塞或进入 native/FFM 调用时无法从载体卸载,载体被连带占住,即 pinning。JDK 21–23 应避免锁内慢 I/O、改用 ReentrantLock 并缩小临界区;JDK 24 起该问题已由 JEP 491 解决。
虚拟线程需要池化吗? 不需要。线程池复用的是昂贵的平台线程,虚拟线程创建廉价,正确模型是每任务一个;要限并发就对具体资源设限——Semaphore、连接池、限流器。
虚拟线程能取代反应式编程吗? 在「同步代码 + 高并发 I/O」场景两者殊途同归,虚拟线程以可读性和调试体验取胜;但流式处理、背压、事件驱动语义是反应式独有领域,虚拟线程并非通吃银弹。
小结
- 虚拟线程是 JVM 调度的轻量 Thread,M:N 挂载到平台线程,阻塞时挂起让出载体,让「一个请求一个线程」模型重获可扩展性。
- 用法牢记三条:每任务一个、不要池化、限流去限真实资源。
- JDK 21–23 警惕 synchronized 内慢 I/O 导致 pinning,升级 JDK 24+ 可基本免除。
- 它不加速 CPU 密集任务,不消除线程安全问题,也不放大共享资源容量——连接池往往是迁移后的第一道新瓶颈。

