虚拟线程 · 载体线程 · 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 就能换成“每个请求一个虚拟线程”。这一圈要回答:什么时候该开、开了以后哪里会出事。
②没有它会怎样:线程太贵,只能限着用
- 平台线程一对一占一个操作系统线程:要预留一段栈,创建一个要几十微秒(线程页实测 82~94 µs),切换一次几微秒。几千个就开始吃不消,所以只能放进池子、限着数量用。
- 池子满了,等的线程也占着名额:实测 1 万个“等下游 100 ms”的任务,200 线程的池子要 5037 ms,CPU 几乎全程闲着(⑤)。
- 老办法一:加线程,内存和切换跟着涨;老办法二:改成异步(CompletableFuture、Reactor),线程不再傻等,但代码被拆成一节一节的回调,异常栈、调试、ThreadLocal 全变难(上一页)。
- 缺的那一块:线程便宜到“一个请求一个”,阻塞时自动把底下的操作系统线程让出来。这就是虚拟线程(JDK 21 正式,JEP 444)。
不懂这一点时的典型误用:以为“线程多了、快了”,什么都能解决。实测 全景第 2 站换成虚拟线程后,一次压测开出了 941 个线程,没加锁的扣库存照样超卖(109 件):虚拟线程不改变并发正确性,竞态、锁、可见性的规矩一条都没少。
③相邻方案:怎么让“等”不占线程
| 做法 | 代码长什么样 | 等的时候 | 代价 |
|---|---|---|---|
| 加大平台线程池 | 同步 | 操作系统线程睡着 | 内存、切换;上限几千 |
| CompletableFuture + 池 | 回调链 | 不占线程(回调到了才占) | 难写难调,上下文要手动带 |
| Reactor / WebFlux | 操作符链 | 事件循环,不能阻塞 | 整条链和所有驱动都要非阻塞 |
| async / await(C#、JS、Kotlin 协程) | 同步样子,但函数分两种颜色 | 挂起 | Java 没有这个语法 |
| 虚拟线程(JDK 21) | 同步,就是普通的 Thread | 卸载,载体线程去跑别人 | 钉住;对计算没帮助 |
选择规则:并发高、大部分时间在等 IO 的服务(典型的 Spring MVC 业务),JDK 21 以上用虚拟线程;计算密集的活继续用按核数配的平台线程池;已经是响应式的链路不用改。本页学虚拟线程的机制、钉住,以及什么时候不该用。
④原理:挂载、卸载、钉住
④-1机制:虚拟线程 = 一段可以暂停的栈 + 一个调度器
VirtualThread 里一个 Continuation 加一个调度器;默认调度器的并行度取 availableProcessors(),最大 256(jdk.virtualThreadScheduler.parallelism / maxPoolSize 可改)。钉住的原因是 Continuation.Pinned 的三个值:MONITOR、NATIVE、CRITICAL_SECTION。- 1阻塞点都被 JDK 改造过:socket、
Thread.sleep、LockSupport.park(所以 AQS 系的锁、阻塞队列、CompletableFuture.get 都行)在虚拟线程里会卸载,而不是让操作系统线程睡着。 - 2有些阻塞卸不下来,但会补偿:很多文件 IO 和
Object.wait()会占住载体线程,JDK 21 用临时多开一个载体线程来补偿(按 JEP 444)。synchronized 里的 sleep / IO 不补偿,这就是 ⑤ 里 5030 ms 的由来。 - 3没有时间片:调度器不会把一个一直在算的虚拟线程强行换下来(按 JEP 444),它只在阻塞时让出。所以长计算会霸占载体线程。
④-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 只看得到载体线程) |
- 1修法一:把 synchronized 换成 ReentrantLock。它排队和等待走
LockSupport.park,能卸载。实测 5030 ms → 125 ms。只需要改“锁里面会阻塞”的那些,锁里只做内存操作的 synchronized 不用动。 - 2修法二:把阻塞挪到锁外:先查好、算好,锁里只改内存。
- 3修法三:升级 JDK。JDK 24 的 JEP 491 让 synchronized 不再钉住(它同时去掉了
jdk.tracePinnedThreads);native 帧仍会钉住。第三方库(老版本 JDBC 驱动、连接池)里的 synchronized 你改不了,这条最省事。
④-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 与 CAS | JDK 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 才补上。
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)
- 1等 IO:200 线程的池子要 1 万 ÷ 200 = 50 批,每批 100 ms,正好 5 秒;虚拟线程 1 万个同时睡,只用 2 个载体线程,约 0.25 秒。
- 2算:谁先跑谁慢(JIT 还没热身),后跑的那一组平台线程和虚拟线程都在 2.1~2.2 秒,正好是“4 秒计算量 ÷ 2 核”。虚拟线程不会多变出 CPU。
实验 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 … }
- 1每把锁只有一个线程用,没有任何锁竞争,慢全来自钉住:100 个 × 100 ms ÷ 2 个载体 = 5 秒。换成 ReentrantLock 后 100 个同时睡,约 0.1 秒。
- 2两个工具都直接指到了代码行:trace 指出
Pinning.java:23持有 1 个监视器;JFR 里 100 条事件、每条约 100 ms,栈顶是parkOnCarrierThread(在载体线程上停着,而不是卸载)。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 打开虚拟线程后压测,吞吐不升反降,严重时所有请求卡住:载体线程只有核数那么多,几个被钉住就没了。⑤ 实验 2:5030 ms vs 125 ms
- 根源
- 持有监视器时
Continuation.yield失败,虚拟线程在载体线程上停着;而 synchronized 不像 Object.wait 那样有补偿。 - 做法
- 上线前带
-Djdk.tracePinnedThreads=short压一遍,或者看 JFR 的jdk.VirtualThreadPinned;锁里有 IO 的换 ReentrantLock;第三方库升到不用 synchronized 的版本,或者直接升 JDK 24+。
- 现象
- 用
newFixedThreadPool(200, Thread.ofVirtual().factory()),结果和 200 个平台线程一样慢。 - 根源
- 池子的意义是复用昂贵的线程、顺便限数量;虚拟线程不贵,池子只剩下“限数量”这一个作用,把并发又卡回了 200。
- 做法
- 每个任务一个:
newVirtualThreadPerTaskExecutor();要限的是某个下游的并发,就在调用那个下游的地方加 Semaphore。
- 现象
- 开了虚拟线程后,数据库连接池等待超时、短信网关被限流。以前 200 个 Tomcat 线程顺带限住了并发,现在没了。
- 根源
- 请求进来就有线程,瓶颈从线程挪到了连接池和下游(网络 IO 页坑 7、自测 5)。
- 做法
- 连接池大小按数据库能承受的算,不跟着线程数涨;每个下游一个 Semaphore 做舱壁;入口加限流(J12)。
- 现象
- 开虚拟线程后堆占用暴涨:以前 200 个线程各一份 SimpleDateFormat、缓冲区,现在十万个虚拟线程各一份。
- 根源
- 每个虚拟线程都有自己的 ThreadLocalMap,“每线程一份”的复用在“每请求一个线程”下就失效了。
- 做法
- 换成线程安全的对象(DateTimeFormatter)或真正的对象池;用
-Djdk.traceVirtualThreadLocals=true找出在虚拟线程里 set ThreadLocal 的代码。
- 现象
- 一个导出报表的请求在算,同时间其他请求的响应时间整体上涨。
- 根源
- 调度器只在阻塞时换人,一直算的虚拟线程不让出载体线程;载体线程只有核数那么多。
- 做法
- 计算型任务交给单独的、按核数配的平台线程池,虚拟线程提交过去再等结果(等的时候会卸载)。
使用规范
带 trace 参数或 JFR 压测一遍,清掉锁里有 IO 的 synchronized。
坑 1。
每个下游一个信号量,许可数按下游能承受的算。
坑 2、坑 3。
等 IO 的用虚拟线程,算的交给平台线程池。
坑 5。
小的、请求级的值;缓存另想办法。
坑 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)。
- Continuation 源码:yield 时栈帧怎么拷进 StackChunk、挂载时怎么懒拷回来
- StructuredTaskScope + ScopedValue:把全景第 10 站的三条通知改写成结构化并发,一个失败其余真正取消
- 在 JDK 24/25 上重跑 ⑤ 实验 2,看 synchronized 版是不是也变成 100 ms 左右(JEP 491)
- HikariCP、MySQL 驱动在虚拟线程下的表现:哪些版本还有 synchronized 钉住
- 交易靶场整体切到虚拟线程后压一遍:瓶颈挪到了哪里(J12 限流、J13 监控)
↺回到⑤:改造你的案例
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 站)。