虚拟线程最坑的坑:synchronized 里睡 300ms,为什么慢了一倍?
系列:Java 后端实战精通营 · 作者:鱼宵
配套课程仓库:https://gitee.com/j67mk2/java21-journey (第 3 课)
先讲一个我朋友的"性能翻车"
他把老项目的线程池改成了虚拟线程,信心满满上线,结果一压测:同样的活,比改之前还慢了一倍。
查了半天,问题出在一段最不起眼的代码——一个synchronized块里的Thread.sleep()。
这不是玄学。Java 21 的虚拟线程有一个官方承认的坑,叫pinning(钉住)。今天这篇用一组能复现的实验,把这个坑的现场、原理和避坑姿势一次讲透。看完你面试遇到"虚拟线程有什么坑",直接甩结论。
一句话结论:pinning = 虚拟线程被钉死在载体线程上
Pinning(钉住):虚拟线程本来遇到阻塞就该"卸货下机",把载体线程让给别人;但如果它正拿着synchronized的监视器锁时发生阻塞(sleep、等 IO),JVM 不敢卸载它,它就被钉在载体线程上——阻塞多久,就白占一个真实的 OS 线程多久。
类比:拿着打卡机钥匙睡觉的临时工。正常情况,人睡觉就把工位让出来,大家轮流用。但这个人手里攥着公司打卡机(synchronized 锁),他一挪窝打卡机就乱了,于是只能钉在工位上睡——工位被白白占 300ms,后面排队的人只能干等。
注意:换ReentrantLock就不会。为什么?下面用实验说话。
关键代码:8 个虚拟线程 × 锁内睡 300ms,synchronized vs ReentrantLock(完整可运行)
本课就这一个核心实验文件(省略包名和 import 的完整版在仓库,复制下方主类即可跑):
importjava.util.concurrent.ReentrantLock;importjava.util.concurrent.locks.Lock;/** * pinning 对比实验:同样的"锁内睡 300ms", * synchronized 组 vs ReentrantLock 组,看耗时和载体线程名。 * 运行:java PinningDemo.java (JDK 21 直接跑) */publicclassPinningDemo{publicstaticvoidmain(String[]args)throwsException{intcores=Runtime.getRuntime().availableProcessors();// 4 核 = 4 个载体工位inttasks=cores*2;// 开 8 个虚拟线程System.out.println("CPU 核数 = "+cores+",开 "+tasks+" 个虚拟线程,每个在锁内睡 300 ms\n");// A 组:synchronized 块内 sleep(传说中被钉住的版本)System.out.println("--- A. synchronized 块内 sleep ---");longaStart=System.currentTimeMillis();Thread[]aThreads=newThread[tasks];for(inti=0;i<tasks;i++){finalintindex=i;aThreads[i]=Thread.ofVirtual().unstarted(()->{Objectmonitor=newObject();// 每个任务自己的锁,互不排队synchronized(monitor){System.out.println(" 任务"+index+" 进入块:"+Thread.currentThread());sleep(300);System.out.println(" 任务"+index+" 离开块:"+Thread.currentThread());}});aThreads[i].start();}for(Threadt:aThreads)t.join();System.out.println("A 组(synchronized)总耗时:"+(System.currentTimeMillis()-aStart)+" ms\n");// B 组:ReentrantLock 块内 sleep(不被钉住的版本)System.out.println("--- B. ReentrantLock 块内 sleep ---");Locklock=newReentrantLock();longbStart=System.currentTimeMillis();Thread[]bThreads=newThread[tasks];for(inti=0;i<tasks;i++){finalintindex=i;bThreads[i]=Thread.ofVirtual().unstarted(()->{lock.lock();// 显式拿锁try{System.out.println(" 任务"+index+" 进入块:"+Thread.currentThread());sleep(300);System.out.println(" 任务"+index+" 离开块:"+Thread.currentThread());}finally{lock.unlock();// 必须放锁,异常也得放}});bThreads[i].start();}for(Threadt:bThreads)t.join();System.out.println("B 组(ReentrantLock)总耗时:"+(System.currentTimeMillis()-bStart)+" ms");}staticvoidsleep(intms){try{Thread.sleep(ms);}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}为什么开2×核数个任务:本机 4 核 = 4 个载体工位。8 个虚拟线程抢 4 个工位——如果都不钉住,睡觉时全部卸载,8 个人分 4 个工位轮流睡,300ms 一批搞定;如果钉住了,4 个人钉在工位上睡 300ms,另外 4 个干等,得两批约 600ms。数字设计得刚刚好,一眼看出差距。
载体线程名怎么看:输出形如VirtualThread[#70030]/runnable@ForkJoinPool-1-worker-4,@后面那截就是当前挂载的载体线程名。进入和离开各打印一次,名字一样 = 被钉住;可能不一样 = 中途卸载过。
实测输出:synchronized 慢一倍,铁证
我本机(JDK 21.0.12.1、4 核 VM)的真实输出:
CPU 核数 = 4,开 8 个虚拟线程,每个在锁内睡 300 ms --- A. synchronized 块内 sleep --- 任务3 进入块:VirtualThread[#70030]/runnable@ForkJoinPool-1-worker-4 任务1 进入块:VirtualThread[#70028]/runnable@ForkJoinPool-1-worker-3 任务2 进入块:VirtualThread[#70029]/runnable@ForkJoinPool-1-worker-1 任务0 进入块:VirtualThread[#70027]/runnable@ForkJoinPool-1-worker-2 任务1 离开块:VirtualThread[#70028]/runnable@ForkJoinPool-1-worker-3 任务3 离开块:VirtualThread[#70030]/runnable@ForkJoinPool-1-worker-4 任务0 离开块:VirtualThread[#70027]/runnable@ForkJoinPool-1-worker-2 ... A 组(synchronized)总耗时:611 ms --- B. ReentrantLock 块内 sleep --- 任务0 进入块:VirtualThread[#70035]/runnable@ForkJoinPool-1-worker-4 任务5 进入块:VirtualThread[#70040]/runnable@ForkJoinPool-1-worker-4 ... 任务5 离开块:VirtualThread[#70040]/runnable@ForkJoinPool-1-worker-2 任务0 离开块:VirtualThread[#70035]/runnable@ForkJoinPool-1-worker-1 ... B 组(ReentrantLock)总耗时:302 ms,其中睡觉时换过载体的任务数 = 4输出怎么读(这是面试要说清的细节):
- A 组 611 ms ≈ 2 × 300ms:4 个工位,第一批 4 个任务钉在工位上睡 300ms,第二批 4 个干等 300ms——两批。看任务 0:进入
worker-2、离开还是worker-2,睡一觉工位没变,钉死了。 - B 组 302 ms ≈ 1 × 300ms:8 个任务同时卸载去睡,工位轮流用,一批搞定。看任务 0:进入
worker-4、离开变成worker-1,睡一觉换了工位,说明中途卸载过。8 个里有 4 个换了载体。 - 同样"锁内睡 300ms",synchronized 版本多花一倍时间——这就是 pinning 的代价。数字随机器波动,看相对关系(A ≈ 2×B)就对了。
为什么 synchronized 会被钉住,ReentrantLock 不会?
一句话:synchronized 的监视器状态和历史实现绑在"线程"上,JVM 卸载虚拟线程时没法安全地把"持有的监视器"搬走,21 里干脆选择不搬(钉住)。
synchronized的监视器(monitor)记录着"哪个线程持有锁",这个状态历史上和线程栈强绑定。虚拟线程要卸载,得先把锁状态搬走——JVM 做不到(或不敢),只能让虚拟线程先别走。ReentrantLock的等待队列是纯 Java 对象,锁状态记在锁对象自己身上,跟线程无关——虚拟线程随便卸载,醒来再换工位,锁照常有效。
类比再来一个:synchronized 是攥在手里的打卡机,一挪窝就乱;ReentrantLock 是带编号的储物柜钥匙,人走了钥匙照常能用,系统不乱。
顺带把另一个坑也交代了:虚拟线程虽然能开 5 万个不排队,但你的数据库连接池只有 50 个连接。虚拟线程没有"池大小"可依赖,所以要显式限流——用Semaphore信号量,许可证数量对齐下游连接池:
Semaphoresemaphore=newSemaphore(50);// 50 张许可证,对齐连接池semaphore.acquire();// 拿不到就在原地等(等待时照样卸载,免费等待)try{// 在这里做 IO}finally{semaphore.release();// 必须还!漏了 = 信号量泄漏,系统慢慢堵死}类比:健身房门禁。更衣室柜子只有 50 个,进去占一个,出来才能让下一个进。虚拟线程开得起 2 万个,但下游扛不住,就得靠信号量掐住"同时在做 IO 的人数"。
挑战题(答案都在课程源码里)
- ⭐改信号量:把
Semaphore(50)改成20重跑,看"最大并发"变成多少、总耗时为什么约变成 2.5 倍。 - ⭐⭐换锁对象:A 组改成
synchronized (new Object())写在块外、锁内只留 sleep——想想结论变不变(提示:锁对象是谁不重要,重要的是"持有监视器时阻塞")。 - ⭐⭐加长睡眠:把
300改成1000,A 组应该更接近两批的 2000ms 量级,B 组还是 1000ms——验证直觉。 - ⭐⭐⭐挑战:给信号量实验加一个"不限流"的对照版(2 万个虚拟线程各 sleep 5ms 直接冲),对比总耗时;再想想什么情况下"不限流"反而是对的(提示:下游资源是否稀缺)。
面试回答模板
问:虚拟线程有什么坑?你遇到过吗?
- 一句话结论:最大的坑是pinning——虚拟线程在 synchronized 块内阻塞时会被钉在载体线程上,阻塞多久就占多久真实 OS 线程,直接拖垮吞吐。
- 展开 1~2 句原理/例子:我做过对比实验,8 个虚拟线程各在锁内睡 300ms,synchronized 版本因为 4 个载体被钉住要分两批跑完约 611ms,进出块打印的载体线程名一模一样;换成 ReentrantLock 后虚拟线程睡觉时卸载,一批跑完约 302ms,还有一半任务醒来换了载体线程。
- 收一句实践:实际工程里我的做法是临界区只放纯计算(短临界区内不阻塞,pinning 无感);锁内必须做 IO 就换
ReentrantLock;上线前用-Djdk.tracePinnedThreads=short跑一遍,把被钉住的代码找出来。
预判追问:“虚拟线程要不要池化?”——“不用,虚拟线程本身廉价到不值得池化;要限并发就显式Semaphore,许可证对齐下游连接池”。追问"ThreadLocal 呢?"——“几百万虚拟线程每人一个 ThreadLocal 内存直接炸,官方建议换 ScopedValue(第 9 课讲)”。
总结表:坑 → 原因 → 解法
| 坑 | 原因 | 解法 |
|---|---|---|
| synchronized 块内 sleep,吞吐掉一半 | pinning:持监视器锁时阻塞,虚拟线程被钉在载体上 | 短临界区纯计算;锁内要做 IO 就换ReentrantLock |
| 虚拟线程开了没人限流,下游被打挂 | 虚拟线程没有池大小概念 | Semaphore显式限流,许可证数对齐下游连接池 |
Semaphore忘了release() | 许可证只发不收 | 放finally里,异常也得还 |
| 任务没跑完 JVM 就退 | 虚拟线程是守护线程 | 用CountDownLatch/join收口 |
编译报newVirtualThreadPerTaskExecutor不存在 | JDK 不是 21 | java -version确认 21 |
| 上线不知道有没有 pinning | 靠猜 | -Djdk.tracePinnedThreads=short直接打印被钉住的栈 |
关于这个系列
本文属于Java 后端实战精通营系列(Java 21 实战 10 课)。完整代码在java21-journey 课程第 3 课(lesson-03/src/main/java/com/java21/lesson03/,含Main.java三组实验 +PinningDemo.java):
gitclone https://gitee.com/j67mk2/java21-journey.gitcdjava21-journey/lesson-03 mvn-qcleaninstall-DskipTestsjava-cptarget/classes com.java21.lesson03.Main(仓库地址是小写 j 的 j67mk2,复制别丢字母。跑完整 4 秒,包含 5 万任务执行器、信号量限流、pinning 对比三组实验。)
下一篇预告:Record 与密封类——数据类一行搞定、继承边界自己说了算(第 4 篇在路上)。
掏心窝一句:pinning 这个坑,光看文档记不住,跑一遍这组实验就刻进肌肉记忆了——面试官问起来,你张口就是"611 对 302"。跑出来的数字和我不一样?那是正常的,看相对关系(A 约等于两倍 B)就对了。评论区一起聊聊你线上踩过哪些虚拟线程的坑。
【发布信息】
- 主标题:虚拟线程最坑的坑:synchronized 里睡 300ms,为什么慢了一倍?
- 备选标题 2:Java 21 虚拟线程的 pinning:你的 synchronized 正在钉死载体线程
- 备选标题 3:从 611ms 到 302ms:虚拟线程下为什么该把 synchronized 换成 ReentrantLock?
- 标签建议:java / 虚拟线程 / Java21
- 摘要建议:虚拟线程最大的坑是 pinning:在 synchronized 块内 sleep 会被钉死在载体线程上。本文用 8 个虚拟线程的对比实验(synchronized 611ms vs ReentrantLock 302ms)讲透原理、证据和避坑姿势,附完整可运行代码、挑战题与面试回答模板。