ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

虚拟线程最坑的坑:synchronized 里睡 300ms,为什么慢了一倍?

虚拟线程最坑的坑:synchronized 里睡 300ms,为什么慢了一倍?

虚拟线程最坑的坑: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 的人数"。

挑战题(答案都在课程源码里)

  1. ⭐改信号量:把Semaphore(50)改成20重跑,看"最大并发"变成多少、总耗时为什么约变成 2.5 倍。
  2. ⭐⭐换锁对象:A 组改成synchronized (new Object())写在块外、锁内只留 sleep——想想结论变不变(提示:锁对象是谁不重要,重要的是"持有监视器时阻塞")。
  3. ⭐⭐加长睡眠:把300改成1000,A 组应该更接近两批的 2000ms 量级,B 组还是 1000ms——验证直觉。
  4. ⭐⭐⭐挑战:给信号量实验加一个"不限流"的对照版(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 不是 21java -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)讲透原理、证据和避坑姿势,附完整可运行代码、挑战题与面试回答模板。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进