虚拟线程是 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 可能直接退出
Thread vt = Thread.startVirtualThread(() -> doRequest());
vt.join();

// 构建器可命名,便于在线程转储中定位
Thread named = Thread.ofVirtual()
.name("order-query-", 0)
.start(() -> doRequest());
named.join();

// 业务中最常用:每任务一个虚拟线程的执行器
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> future = es.submit(() -> fetchRemote());
System.out.println(future.get());
} // close() 会等待所有已提交任务完成

newVirtualThreadPerTaskExecutor 不是传统线程池:它不复用线程,而是为每个任务新建一个虚拟线程。正确心智是「每个任务一个线程」,不要池化——虚拟线程足够便宜,池化没有意义。需要限流时应限制资源本身,例如对脆弱的老系统用 Semaphore 控制并发许可,而不是缩减虚拟线程数量。

Pinning:载体被钉住

挂起的前提是阻塞点支持卸载。两类情况会把虚拟线程「钉」在载体上:其一,在 synchronized 块或方法内发生阻塞——JDK 21 至 23 中 monitor 与载体线程绑定,无法中途卸载;其二,执行 native 方法或外部函数(FFM)时阻塞。钉住期间,载体连同其内核线程一起被占住,并发度瞬间回落到平台线程水平。

// JDK 21-23 的高危写法:持有 monitor 做慢 I/O,阻塞时载体被钉住
public synchronized String loadConfig() throws IOException {
return httpClient.get("/config");
}

应对策略:缩小 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 密集任务,不消除线程安全问题,也不放大共享资源容量——连接池往往是迁移后的第一道新瓶颈。