虚拟线程 · 载体线程 · Continuation · mount / unmount · ForkJoinPool 调度器 · pinning · jdk.tracePinnedThreads · jdk.VirtualThreadPinned · ReentrantLock · Semaphore · JEP 444 · JEP 491

虚拟线程:等的时候把线程让出来,但在 synchronized 里让不出来

虚拟线程是 JVM 自己调度的轻量线程。它阻塞时,JVM 把它的栈存到堆里、把底下的操作系统线程(载体线程)让给别人,所以可以“一个请求一个线程”地写阻塞代码。它只对等有用,对算没用;在 JDK 21 上,如果在 synchronized 里阻塞,它让不出载体线程,这叫钉住(pinning)。实测 1 万个等 100 ms 的任务:200 线程的池子 5037 ms,虚拟线程 242~265 ms;100 个在 synchronized 里等 100 ms 的虚拟线程用了 5030 ms,换成 ReentrantLock 是 125 ms。

全景:一个下单请求在线程池里 · 第 2 站地图:J02 · 虚拟线程:什么时候用、pinning 的坑(第 9 个知识点,下一圈)上一圈:网络 IO 模型(虚拟线程怎么接住 1 万个连接)实验:JDK 21.0.12,2 核 ARM 实测

①为什么需要:200 个 Tomcat 线程,大部分时间在等

在全景第 2 站,这一步要解决的是:一个下单请求大约 50 ms,其中只有一两毫秒在用 CPU,其余都在等数据库和下游。Tomcat 默认最多 200 个线程,按利特尔法则最多扛每秒 4000 个请求;下游一慢到 500 ms,就只剩每秒 400 个,而 CPU 还闲着。Spring Boot 3.2 起一个开关 spring.threads.virtual.enabled=true 就能换成“每个请求一个虚拟线程”。这一圈要回答:什么时候该开、开了以后哪里会出事。

需求上一圈(网络 IO 模型)讲了虚拟线程能照样写阻塞代码,坑 7 提到“钉住”。这一圈是“懂机制”:说得清挂载和卸载是怎么回事、载体线程有几个、什么时候会被钉住,怎么用工具找到钉住、怎么修;再把它和 J02 前八个知识点逐个对照,这一站就收尾了。

②没有它会怎样:线程太贵,只能限着用

不懂这一点时的典型误用:以为“线程多了、快了”,什么都能解决。实测 全景第 2 站换成虚拟线程后,一次压测开出了 941 个线程,没加锁的扣库存照样超卖(109 件):虚拟线程不改变并发正确性,竞态、锁、可见性的规矩一条都没少。

③相邻方案:怎么让“等”不占线程

做法代码长什么样等的时候代价
加大平台线程池同步操作系统线程睡着内存、切换;上限几千
CompletableFuture + 池回调链不占线程(回调到了才占)难写难调,上下文要手动带
Reactor / WebFlux操作符链事件循环,不能阻塞整条链和所有驱动都要非阻塞
async / await(C#、JS、Kotlin 协程)同步样子,但函数分两种颜色挂起Java 没有这个语法
虚拟线程(JDK 21)同步,就是普通的 Thread卸载,载体线程去跑别人钉住;对计算没帮助

选择规则:并发高、大部分时间在等 IO 的服务(典型的 Spring MVC 业务),JDK 21 以上用虚拟线程;计算密集的活继续用按核数配的平台线程池;已经是响应式的链路不用改。本页学虚拟线程的机制、钉住,以及什么时候不该用。

④原理:挂载、卸载、钉住

先知道这些
  • 线程状态和上下文切换:阻塞的平台线程在内核里睡着,切换由内核做(线程页)。
  • ForkJoinPool:每个工作线程一个任务队列,空了去别人那里偷(上一页 ④-4 的 commonPool 也是它)。
  • 阻塞 socket 在 JDK 13 起换成了 NIO 实现,为虚拟线程铺了路(网络 IO 模型)。

④-1机制:虚拟线程 = 一段可以暂停的栈 + 一个调度器

调度器:专用的 ForkJoinPool 载体线程数 = 核数(本机 2),最多 256 worker-1 ← 挂着 VT#21 worker-2 ← 挂着 VT#57 就绪队列:VT#88、VT#90、VT#12 … (几万个虚拟线程排在这里, 只占堆内存,不占操作系统线程) VT#21 调了阻塞方法 socket 读、sleep、queue.take、 ReentrantLock 排队、Future.get … Continuation.yield:卸载 栈帧拷到堆上的 StackChunk, worker-1 空出来,去跑就绪队列里的下一个 等的东西到了:unpark VT#21 重新进就绪队列, 可能挂到 worker-2 上接着跑(挂载) yield 失败 = 钉住 JDK 21:持有 synchronized 的锁 (MONITOR)、栈上有 native 帧(NATIVE) → 状态变 PINNED,worker-1 陪着一起等, 谁也用不了它
按 JDK 21 源码:VirtualThread 里一个 Continuation 加一个调度器;默认调度器的并行度取 availableProcessors(),最大 256(jdk.virtualThreadScheduler.parallelism / maxPoolSize 可改)。钉住的原因是 Continuation.Pinned 的三个值:MONITOR、NATIVE、CRITICAL_SECTION。

④-2动手看载体线程:卸载和钉住

先猜:8 个虚拟线程,每个“算 10 ms → 等下游 100 ms → 算 10 ms”,2 个载体线程,等下游的那段写在 synchronized 里(JDK 21)。全部做完大约要多久?

等下游的那段
在载体线程上计算钉住:载体线程陪着等空闲(虚拟线程卸载后去等了)

就绪队列按先进先出排,真实的 ForkJoinPool 有工作窃取,顺序不同,但总耗时的量级一样。在 node 里用“100 个虚拟线程、2 个载体、只等 100 ms”跑了一遍:卸载 101 ms、钉住 5001 ms,⑤ 实测是 125 ms 和 5030 ms。

④-3钉住:怎么发现,怎么修

手段怎么用看到什么
启动参数(JDK 21)-Djdk.tracePinnedThreads=short 或 full虚拟线程被钉住时打出栈,标着 <== monitors:1(⑤ 实测)
JFR默认就开着的 jdk.VirtualThreadPinned 事件,钉住超过 20 ms 记一条⑤ 实测 100 条,每条约 100 ms,带栈
线程转储jcmd <pid> Thread.dump_to_file -format=json 文件虚拟线程也在里面(老的 jstack 只看得到载体线程)

④-4什么时候用,什么时候别用

场景用不用为什么
大量并发请求,大部分时间在等数据库、HTTP、MQ用等的时候卸载,载体线程一直有活干(⑤:5037 → 约 250 ms)
计算密集(压缩、加密、大循环)不用算力只有核数那么多,换成虚拟线程不会变快(⑤:后跑的都是 2.1~2.2 秒);还会霸占载体线程
锁里做 IO(synchronized),JDK 21~23先改钉住;先换 ReentrantLock 或升级 JDK
需要限制对下游的并发用,但加 Semaphore线程不再限并发,限流要自己做(坑 3)
每线程一份的缓存(ThreadLocal 放大对象)先改每个虚拟线程一份,十万个就是十万份(ThreadLocal 页第三圈)

④-5和 J02 前八个知识点对照

知识点换成虚拟线程以后
线程与上下文切换虚拟线程在 Java 层面也有 NEW / RUNNABLE / WAITING 等状态;卸载和挂载由 JVM 做,不进内核,切换比平台线程便宜
Java 内存模型完全照旧:可见性、有序性、happens-before 规则一条不变
synchronized 与 CASJDK 21~23:synchronized 里阻塞会钉住;CAS、原子类不阻塞,没影响
AQS 与 ReentrantLock首选:排队走 park,能卸载
ConcurrentHashMap 与阻塞队列照用;阻塞队列的 take / put 会卸载
线程池不要池化虚拟线程:每个任务一个,用完就扔;“限并发”改用 Semaphore;计算型的池子保留
ThreadLocal能用,每个虚拟线程一份;请求结束线程就没了,串号少了,但别放大对象;以后可换 ScopedValue
CompletableFuture“并行调几个下游”可以写回同步代码:开几个虚拟线程再 join,或用 StructuredTaskScope(预览)

④-6为什么这样设计:不给函数分颜色

Java:线程还是 Thread,阻塞点由 JDK 改造虚拟线程(JEP 444)

  • 几十年的阻塞式代码、JDBC、调试器、异常栈、ThreadLocal 都能直接用
  • 不需要新语法,同一个方法在平台线程和虚拟线程上都能跑
  • JDK 要一个个改造阻塞点,改不到的地方(synchronized、native)就会钉住

被放弃的:async / awaitC#、JavaScript、Kotlin 的挂起函数

  • 编译器明确知道哪里会挂起,不存在“钉住”
  • 函数分成两种颜色:异步函数只能被异步函数调用,整个调用链都要改
  • Java 生态里海量的同步库都得出一套异步版

决定胜负的约束:Java 已经有海量的同步代码和库。让它们原封不动地享受“等的时候不占线程”,比让所有人重写成异步划算。代价是 JDK 自己要把阻塞点都改造好,JDK 21 时还没改完 synchronized,到 JDK 24 才补上。

API最少要会的几行
Thread.ofVirtual().name("order-", 0).start(() -> placeOrder());   // 起一个

try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {   // 每个任务一个,不是池
  for (Order o : orders) es.submit(() -> notify(o));
}                                                                   // close() 等全部做完

Semaphore smsLimit = new Semaphore(20);                               // 限并发靠它,不靠线程数
smsLimit.acquire(); try { sms.send(o); } finally { smsLimit.release(); }

# Spring Boot 3.2+:Tomcat 请求、@Async 等都换成虚拟线程
spring.threads.virtual.enabled=true

Spring 打开开关后具体换了哪些执行器、JDK 24/25 的变化、怎么在线程转储里看虚拟线程,放到第⑥步。

⑤亲眼看到:等的时候快 20 倍,算的时候一样快,钉住慢 40 倍

两个程序,--release 21 编译,JDK 21.0.12 运行(虚拟线程只能在 21 上跑)。完整代码:study-labs/java/juc/virtual-threads-pinning →

实验 1:1 万个“等下游 100 ms”的任务,和 200 个计算任务

$ java -cp … Throughput io
CPU 核数 2,任务 10000 个,类型 io
  平台线程池 200        5037 ms,真正干活的操作系统线程 200 个
  虚拟线程 每任务一个    265 ms,真正干活的操作系统线程 2 个
(第二次:5034 ms / 242 ms)

$ java -cp … Throughput cpu [vt-first]    # 200 个任务,每个单独跑约 20 ms 的计算
平台先跑:平台 3733 ms,虚拟线程 2067 ms   (两次:3733/2067、3901/2221)
虚拟先跑:虚拟线程 3197 ms,平台 2129 ms   (两次:3197/2129、3373/2160)

实验 2:100 个虚拟线程,各自拿一把锁,在锁里等 100 ms(2 个载体线程)

$ java -Djdk.virtualThreadScheduler.parallelism=2 -cp … Pinning sync
模式 sync :100 个虚拟线程、各等 100 ms,用了 5030 ms(载体线程 2 个)
$ java … Pinning lock
模式 lock :100 个虚拟线程、各等 100 ms,用了 125 ms(载体线程 2 个)

$ java -Djdk.tracePinnedThreads=short … Pinning sync
VirtualThread[#20]/runnable@ForkJoinPool-1-worker-2 reason:MONITOR
    labs.juc.virtual_threads_pinning.Pinning.lambda$main$0(Pinning.java:23) <== monitors:1

$ java -XX:StartFlightRecording=filename=pin.jfr … Pinning sync && jfr summary pin.jfr
 jdk.VirtualThreadPinned                   100          1400
$ jfr print --events jdk.VirtualThreadPinned --stack-depth 3 pin.jfr
jdk.VirtualThreadPinned { duration = 98.9 ms … java.lang.VirtualThread.parkOnCarrierThread … }
到这里已经能交差了:你能说清虚拟线程等的时候卸载、算的时候不加速;会用 trace 参数和 JFR 找钉住,会用 ReentrantLock 修;知道它不是池子,限并发要用 Semaphore。
已知还没处理的细节:synchronized 钉住(⑥ 坑 1)、把虚拟线程放进池子(⑥ 坑 2)、压垮下游(⑥ 坑 3)、ThreadLocal 大对象(⑥ 坑 4)、长计算霸占载体(⑥ 坑 5)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1synchronized 里做 IO:钉住(JDK 21~23)← ④-3 钉住
现象
打开虚拟线程后压测,吞吐不升反降,严重时所有请求卡住:载体线程只有核数那么多,几个被钉住就没了。⑤ 实验 2:5030 ms vs 125 ms
根源
持有监视器时 Continuation.yield 失败,虚拟线程在载体线程上停着;而 synchronized 不像 Object.wait 那样有补偿。
做法
上线前带 -Djdk.tracePinnedThreads=short 压一遍,或者看 JFR 的 jdk.VirtualThreadPinned;锁里有 IO 的换 ReentrantLock;第三方库升到不用 synchronized 的版本,或者直接升 JDK 24+。
坑 2把虚拟线程放进固定大小的池子← ④-5 线程池那一行
现象
用 newFixedThreadPool(200, Thread.ofVirtual().factory()),结果和 200 个平台线程一样慢。
根源
池子的意义是复用昂贵的线程、顺便限数量;虚拟线程不贵,池子只剩下“限数量”这一个作用,把并发又卡回了 200。
做法
每个任务一个:newVirtualThreadPerTaskExecutor();要限的是某个下游的并发,就在调用那个下游的地方加 Semaphore。
坑 3线程不再限并发,下游被压垮← ④-4 限并发要自己做
现象
开了虚拟线程后,数据库连接池等待超时、短信网关被限流。以前 200 个 Tomcat 线程顺带限住了并发,现在没了。
根源
请求进来就有线程,瓶颈从线程挪到了连接池和下游(网络 IO 页坑 7、自测 5)。
做法
连接池大小按数据库能承受的算,不跟着线程数涨;每个下游一个 Semaphore 做舱壁;入口加限流(J12)。
坑 4ThreadLocal 里放缓存← ThreadLocal 页第三圈
现象
开虚拟线程后堆占用暴涨:以前 200 个线程各一份 SimpleDateFormat、缓冲区,现在十万个虚拟线程各一份。
根源
每个虚拟线程都有自己的 ThreadLocalMap,“每线程一份”的复用在“每请求一个线程”下就失效了。
做法
换成线程安全的对象(DateTimeFormatter)或真正的对象池;用 -Djdk.traceVirtualThreadLocals=true 找出在虚拟线程里 set ThreadLocal 的代码。
坑 5长计算放进虚拟线程← ④-1 没有时间片
现象
一个导出报表的请求在算,同时间其他请求的响应时间整体上涨。
根源
调度器只在阻塞时换人,一直算的虚拟线程不让出载体线程;载体线程只有核数那么多。
做法
计算型任务交给单独的、按核数配的平台线程池,虚拟线程提交过去再等结果(等的时候会卸载)。
第二圈

使用规范

开之前先查钉住

带 trace 参数或 JFR 压测一遍,清掉锁里有 IO 的 synchronized。

坑 1。

不池化,限并发用 Semaphore

每个下游一个信号量,许可数按下游能承受的算。

坑 2、坑 3。

计算和等待分开

等 IO 的用虚拟线程,算的交给平台线程池。

坑 5。

ThreadLocal 只放请求上下文

小的、请求级的值;缓存另想办法。

坑 4。

第三圈

深处:版本、Spring、排查

JDK 21 → 24 → 25

21:虚拟线程正式(JEP 444);StructuredTaskScope 预览(JEP 453)、ScopedValue 预览(JEP 446)。24:JEP 491,synchronized 不再钉住,jdk.tracePinnedThreads 随之去掉,JFR 事件保留(只剩 native 帧等情况)。25:ScopedValue 正式(JEP 506),StructuredTaskScope 还在预览。8 和 17 没有虚拟线程。生产上用 JDK 21 的,钉住这一节一定要过一遍。

Spring Boot 打开开关以后

spring.threads.virtual.enabled=true(Boot 3.2+,要求 JDK 21):Tomcat 处理请求改用虚拟线程执行器,Acceptor、Poller 不变(网络 IO 页);自动配置的 @Async 执行器换成每任务一个虚拟线程的 SimpleAsyncTaskExecutor,spring.task.execution.pool.* 就不再起作用了(按 Spring Boot 文档)。所以“线程池下一圈”算出来的那些参数,开了以后要换成 Semaphore 和连接池上限。

线上怎么看虚拟线程

jstack 只列平台线程,看不到卸载着的虚拟线程。JDK 21 用 jcmd <pid> Thread.dump_to_file -format=json dump.json,按线程容器(比如某个 ExecutorService)分组列出所有虚拟线程和它们的栈。JFR 里还有 jdk.VirtualThreadStart / End(默认关)和 jdk.VirtualThreadSubmitFailed。

面试官会怎么问

虚拟线程和平台线程有什么区别?为什么便宜?

平台线程一对一绑操作系统线程;虚拟线程由 JVM 调度,挂在少数载体线程上跑,阻塞时把栈存到堆上、让出载体线程。创建和切换都不进内核,栈按需增长,所以能开几十万个(④-1)。

什么是 pinning?怎么发现、怎么解决?

虚拟线程阻塞时卸不下来、占着载体线程。JDK 21 主要是持有 synchronized 锁时阻塞、或栈上有 native 帧。用 -Djdk.tracePinnedThreads 或 JFR 的 jdk.VirtualThreadPinned 找;换 ReentrantLock、把阻塞挪出锁,或升级到 JDK 24(④-3)。

虚拟线程适合什么场景?CPU 密集适合吗?

适合高并发、大部分时间在等 IO 的服务;CPU 密集不适合,算力只有核数,而且没有时间片,长计算会霸占载体线程(④-4、坑 5)。

用了虚拟线程,还要线程池吗?

虚拟线程不要池化;“限并发”改用 Semaphore;计算型任务仍然用按核数配的平台线程池(坑 2)。

下一圈

↺回到⑤:改造你的案例

1. 给 Throughput 加一个“虚拟线程放进池子”

加第三种:Executors.newFixedThreadPool(200, Thread.ofVirtual().factory())。先猜它是 5 秒还是 0.25 秒,再跑。

用到:坑 2

2. 用 Semaphore 限住下游

在 io 模式的任务里加 Semaphore(50),模拟“短信网关最多 50 并发”。预测 1 万个任务要多久(提示:利特尔法则),再跑。

用到:④-4、坑 3

3. 让 Object.wait 来对照

把 Pinning 的 sleep 换成 lock.wait(100)(仍在 synchronized 里),看总耗时和 sync 版差多少,再用 JFR 看载体线程数有没有变多。

用到:④-1 第 2 条(补偿)

自测

1. 用一句话说出这一页的精华。

虚拟线程在阻塞时把栈存到堆上、让出载体线程,所以对“等”有用、对“算”没用;JDK 21 上在 synchronized 里阻塞会钉住载体线程;它不是池子,限并发要用 Semaphore。

2. 用 200 线程的池子跑 1 万个 IO 任务,疼在哪?

等的线程也占着名额,只能 200 个一批,5 秒;CPU 几乎闲着(②、⑤)。

3. ⑤ 实验 2 的 sync 版慢在哪?根源是 ④ 的哪一条?

④-1 和 ④-3:持有监视器时 yield 失败,虚拟线程钉在载体线程上睡;2 个载体线程一次只能睡 2 个,100 个要 50 轮 × 100 ms = 5 秒。没有任何锁竞争。

4. 预测:实验 2 的 sync 版,把载体线程数从 2 调到 10(parallelism=10),大约多久?换成 JDK 24 呢?

JDK 21:一次能钉住 10 个,100 ÷ 10 × 100 ms ≈ 1 秒。JDK 24:synchronized 不再钉住,和 lock 版一样,约 100 ms 出头(④-3、JEP 491)。

5. 预测:Tomcat 开了虚拟线程,HikariCP 还是 10 个连接,每个请求查库 100 ms,来了每秒 200 个请求,会怎样?

需要 200 × 0.1 = 20 个连接同时在用,只有 10 个;一半请求的虚拟线程排在 Hikari 上等(卸载着,不占载体),响应时间越来越长,最后连接获取超时。瓶颈从 Tomcat 线程挪到了连接池(坑 3)。

6. 放回全景:第 2 站换成虚拟线程,第 6 站用的是 synchronized 扣库存,里面查了一次数据库。高峰时会怎样?换成第 8 站的数据库条件更新呢?

JDK 21 上,每个进了 synchronized 的虚拟线程查库时都钉住载体线程,载体线程只有核数那么多,加上本来就在排队抢这把锁,整个服务几乎串行,所有请求变慢。换成数据库条件更新:JVM 里没有锁,查库时虚拟线程正常卸载,并发控制交给数据库的行锁(全景第 6、8 站)。