2026 年 3 月,我们一个 Spring Boot 3.2 + Java 21 + 全面切到虚拟线程(virtual threads, Project Loom)的订单查询服务上线第 6 天,在大促当天直接趴了。QPS 从平稳的 3800 跌到 420,P99 从 80ms 飙到 9.4 秒,Pod CPU 一直在 250%/400% 上下抖,但堆内存、GC、连接池都正常。线下压测 8000 QPS 都跑得风生水起,生产 4000 QPS 就崩,这种"线下健康线上塌"的故障是最难查的——传统监控全绿,但用户就是打不开页面。
4 天的排查最终发现根因是:我们的 JDBC 驱动 + 老牌缓存库 EhCache 2.x + 两段 synchronized 业务代码,在虚拟线程上触发了大规模 carrier thread pinning。虚拟线程的核心卖点是"在阻塞操作时让 carrier thread 去跑别的虚拟线程"——但 synchronized 块和某些 native 调用会把虚拟线程钉死在 carrier 上,carrier 不能切走也不能跑别的任务。当生产负载下绝大多数虚拟线程都被 pinned 时,你等效于又回到了固定大小的 carrier 池,只不过这次"线程池"只有 ForkJoinPool.commonPool 默认大小那点 worker,远不够撑高并发。
故障背景:为什么我们急着切虚拟线程
先讲背景,否则后面踩的坑没有上下文。order-query 是个典型的"重 I/O 服务":接到请求后要查 MySQL(订单主表)+ Redis(缓存)+ 调下游 user-service 和 product-service。Tomcat 200 线程的池一直是瓶颈——压测到 4000 QPS 后,Tomcat 就开始拒请求,因为绝大多数线程都阻塞在外部调用上。
| 维度 | 切换前(Java 17 + Tomcat 平台线程) | 切换后(Java 21 + 虚拟线程) |
|---|---|---|
| QPS 上限 | 4200 | 预期 12000+ |
| Tomcat 线程池 | 200 个平台线程 | 无界虚拟线程 |
| P99 延迟 | 180ms | 预期 60ms |
| 线程上下文切换开销 | 显著 | 几乎为 0 |
| 堆栈占用 | 每线程 1MB | 每虚拟线程几 KB |
| Pod 资源 | 4C 8Gi | 4C 8Gi |
切换的方式很简单——Spring Boot 3.2 已经原生支持,只需要在配置里打开:
// application.yml(简化)
spring:
threads:
virtual:
enabled: true
// 等同于注入这个 Bean
@Bean
public TomcatProtocolHandlerCustomizer<?> protocolHandlerVirtualThreadExecutorCustomizer() {
return protocolHandler ->
protocolHandler.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
}
切完上线,线下压测一切完美:8000 QPS 下 P99 = 58ms,CPU 130%/400%,内存平稳。所有人都觉得"Loom 真香",然后生产就翻车了。
事故时间线
| 时间 | 事件 |
|---|---|
| D1 19:42 | 大促开抢,QPS 上 3800,系统正常 |
| D1 19:58 | QPS 触达 4000,响应突然慢,P99 1.2s 起步 |
| D1 20:05 | P99 飙到 9.4s,用户开始大量 502/超时,客服告警 |
| D1 20:15 | 紧急扩容到 60 Pod(原 30),压力没缓解,P99 仍然 8s+ |
| D1 20:42 | 怀疑下游 user-service 慢,查 user-service 监控正常 |
| D1 21:10 | 把 order-query 流量切回旧版本(Tomcat 平台线程),立刻恢复 |
| D2 09:00 | 开始线下复现,8000 QPS 怎么也压不出 P99 飙升 |
| D2 14:00 | 发现关键差异:线下 mock 的下游延迟均匀,生产下游 P99 长尾 |
| D3 10:00 | 用 -Djdk.tracePinnedThreads=full 抓 pinning,日志爆炸,每秒数千次 pin 事件 |
| D3 14:00 | 定位 3 处 pinning 源头:EhCache 内部 synchronized、JDBC 驱动 native call、业务自己的两段 synchronized |
| D4 11:00 | 全部改造:换 Caffeine 替代 EhCache、HikariCP 升级到支持 Loom 的版本、业务 synchronized 改 ReentrantLock |
| D4 18:00 | 灰度 30%,8000 QPS 稳定 P99 = 51ms;次日全量,大促二开抢平稳 |
第一轮排查:被传统线程模型经验带偏
事故当晚的第一反应是"线程不够用"——但虚拟线程是无界的,根本不存在"线程池满"的概念。我们查了堆栈,发现 jstack 里成千上万个 VirtualThread 都在做事,没有"空闲池"可言。这反而让人更困惑:既然线程够,为啥慢?
jstack $PID | grep "VirtualThread" | wc -l
# 输出 11842
# 看看它们在做啥
jstack $PID | grep -A 3 "VirtualThread" | grep "java.lang.Thread.State" | sort | uniq -c
# 5421 java.lang.Thread.State: RUNNABLE
# 4128 java.lang.Thread.State: WAITING
# 2293 java.lang.Thread.State: TIMED_WAITING
看起来很正常——大量虚拟线程在跑或在等。但关键的指标是 carrier thread 在做什么,这一点 jstack 默认不会突出显示。
第二轮排查:发现 carrier thread 100% 跑满
Loom 默认 carrier 池是 ForkJoinPool.commonPool(),大小是 Runtime.availableProcessors()——我们 4C Pod 上就是 4 个 carrier。看 carrier 的 CPU 占用:
# top -H 看线程级 CPU
top -H -p $PID | grep ForkJoinPool
# ForkJoinPool-1-worker-1 25.0 R 几乎 100%
# ForkJoinPool-1-worker-2 24.8 R
# ForkJoinPool-1-worker-3 25.1 R
# ForkJoinPool-1-worker-4 24.9 R
4 个 carrier 每个跑 25% Pod CPU(每个核 100%)——加起来正好 100% CPU。这就有点奇怪:虚拟线程的卖点是"阻塞时切走 carrier 不占 CPU",怎么 carrier 还满载?
第三轮排查:开 jdk.tracePinnedThreads 看 pinning
转折点来自 JVM 启动参数 -Djdk.tracePinnedThreads=full,这会在每次虚拟线程被 pin 到 carrier 时打印调用栈。我们开了 30 秒,日志直接爆炸——每秒数千行 pinning 事件:
java -Djdk.tracePinnedThreads=full -jar order-query.jar
# 日志输出大概长这样
Thread[#127,VirtualThread[#5421]/runnable@ForkJoinPool-1-worker-3,5,VirtualThreads]
java.base/java.lang.VirtualThread$VThreadContinuation.onPinned
...
net.sf.ehcache.store.MemoryStore.get(MemoryStore.java:344) <== monitors:1
...
com.example.order.service.OrderService.getOrder(OrderService.java:88)
# 关键标志:"<== monitors:1" 表示进入了 synchronized 块
有了 trace 一切就清晰了——EhCache 2.x 内部 MemoryStore.get 用了 synchronized,每次缓存读都 pin 一次 carrier。我们的订单查询路径里,EhCache 平均被命中 3-5 次/请求,4000 QPS 就是 1.2-2 万次/秒的 pin 事件,4 个 carrier 全在 pin 状态下卡死。
问题本质:什么是 carrier thread pinning
这里必须把虚拟线程的执行模型讲清楚,否则后面修法看不懂。简化的虚拟线程调度模型:
正常 I/O 阻塞时(Socket.read、LockSupport.park、Thread.sleep),JDK 实现了"unmount"——把 VT 的状态保存,carrier 立刻去跑别的 VT。但 synchronized 和某些 native 操作做不到 unmount,因为 synchronized 的 monitor 锁是 JVM 内置的、必须在持有 monitor 的线程上释放;如果让 carrier 切走去跑别的 VT,VT 持有的 monitor 就被"虚拟线程级"和"OS 线程级"两边脱节,JVM 内部数据结构会崩。所以遇到 synchronized,只能 pin。
修法 1:替换 synchronized 为 ReentrantLock
业务代码里我们有两段 synchronized,场景是"刷新本地配置缓存的双重检查锁":
// 反例:synchronized 在 VT 上会 pin
public class ConfigCache {
private volatile Map<String, String> cache;
public String get(String key) {
if (cache == null) {
synchronized (this) { // PIN 源头
if (cache == null) {
cache = loadFromDB(); // 阻塞 I/O,触发 pinning trace
}
}
}
return cache.get(key);
}
}
// 正解:ReentrantLock 是 LockSupport.park 实现的,不 pin
public class ConfigCache {
private final ReentrantLock lock = new ReentrantLock();
private volatile Map<String, String> cache;
public String get(String key) {
if (cache == null) {
lock.lock();
try {
if (cache == null) {
cache = loadFromDB(); // 不 pin,carrier 可切走
}
} finally {
lock.unlock();
}
}
return cache.get(key);
}
}
ReentrantLock 内部用 LockSupport.park() 实现,这个在 Loom 里被特殊处理,会触发 unmount。同样地 java.util.concurrent 下所有的锁(ReentrantReadWriteLock、StampedLock、Semaphore)在 VT 上都不会 pin——这是迁移 Loom 的第一条铁律:把所有 hot path 上的 synchronized 换成 java.util.concurrent 锁。
修法 2:替换 EhCache 2.x 为 Caffeine
EhCache 2.x 是 2010 年的老库,内部 MemoryStore / Element 大量用 synchronized 做并发控制,无法在 VT 上跑高 QPS。我们直接换成 Caffeine(JCache 兼容):
// EhCache 2.x:已经触发 pin
CacheManager cm = CacheManager.create();
Cache cache = cm.getCache("orders");
// 改成 Caffeine,基于 ReentrantLock 实现内部并发,VT 友好
Cache<String, Order> cache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
Caffeine 内部用 StampedLock + 无锁数据结构,几乎不会 pin。这次切换还顺带去掉了 EhCache 的磁盘溢出能力——对我们订单查询场景来说本来就用不上,反而是包袱。压测验证:同样 8000 QPS,EhCache 版本 1.2 万次/秒 pin 事件,Caffeine 版本 0 次。
修法 3:JDBC 驱动 / 连接池升级
第三个 pinning 源头在 MySQL JDBC 驱动里。MySQL Connector/J 8.0.x 的 StatementImpl.executeQuery 内部一段 synchronized 在等待网络响应时也会 pin。修法:
| 组件 | 问题版本 | VT 友好版本 |
|---|---|---|
| MySQL Connector/J | 8.0.x | 9.0+(社区移除了 hot path synchronized) |
| PostgreSQL JDBC | 42.6.x | 42.7.3+ |
| HikariCP | 5.0.x(部分 pin) | 5.1.0+(已移除 hot path synchronized) |
| Jedis | 4.x(底层用 SocketChannel,有 pin) | Lettuce(Netty + Reactor,完全 unmount) |
| Apache HttpClient 4.x | 有 pin | JDK 内置 java.net.http(完全 VT 友好) |
每个第三方库都需要单独验证。我们的"组件 VT 兼容性矩阵"花了一周才整理完,这之后我们建议团队切 VT 前先把所有第三方依赖过一遍 trace,不能盲目升 Java 21 就以为完事。
修法 4:对无法替换的 synchronized,显式留在平台线程
有些组件没法换(比如自研的核心算子用了 synchronized,改造成本高),那就把那段代码显式扔到平台线程跑,避免污染 carrier:
// 把 legacy synchronized 代码隔离到固定平台线程池
private final ExecutorService legacyPool = Executors.newFixedThreadPool(32);
public CompletableFuture<Result> legacyAsyncCall(Request req) {
return CompletableFuture.supplyAsync(() -> {
return legacyEngine.process(req); // 这里面有 synchronized,在平台线程跑不会污染 carrier
}, legacyPool);
}
核心思路是:把 pin 风险局部化在一个独立的平台线程池里,carrier 不被牵连。这个模式适合"短期内无法重构的 legacy 模块"。
修法 5:监控 pinning 事件,纳入 SLO
修完之后我们加了 pinning 监控,用 JFR(Java Flight Recorder):
// 开启 JFR 持续记录 pinning 事件
// JVM 参数
// -XX:StartFlightRecording=settings=profile,filename=jfr.jfr
// 或者运行时
Configuration config = Configuration.getConfiguration("profile");
Recording recording = new Recording(config);
recording.enable("jdk.VirtualThreadPinned")
.withThreshold(Duration.ofMillis(20));
recording.start();
// 然后用 JMC 打开 jfr 文件,直接看 Pinned 事件 top stack
// 配合 Prometheus,把每分钟 pin 次数 export 出来作为 SLO 指标
Metrics.counter("vt.pinned",
"stack", topStack).increment();
SLO 阈值:任何 Pod 每分钟 pin 事件 > 1000 直接报警。这条规则上线后,后续每次有人引入新的 synchronized 都能被立刻发现。
性能基准:修复前后对比
| 指标 | EhCache + synchronized (BUG) | Caffeine + ReentrantLock (修复) | 切回平台线程(对照) |
|---|---|---|---|
| 4000 QPS P99 | 9.4s | 51ms | 180ms |
| 8000 QPS P99 | 无法支撑 | 72ms | 无法支撑 |
| 12000 QPS P99 | 无法支撑 | 118ms | 无法支撑 |
| Pod CPU(4000QPS) | 100% / 400% | 120% / 400% | 340% / 400% |
| 每分钟 pin 事件 | ~120 万 | ~3 | 不适用 |
| 每 Pod 支撑 QPS | ~400 | ~3000 | ~1000 |
修复后单 Pod 承载 3000 QPS,比平台线程版本(1000 QPS)直接提升 3 倍——这就是 Loom 真正的红利。
决策树:迁移虚拟线程的判断流程
我们立的 10 条 Loom 工程纪律
- 切 VT 前必须先 grep 全代码所有
synchronized:hot path 上的全部改 ReentrantLock。 - 第三方依赖必须过一遍 VT 兼容性验证:用
-Djdk.tracePinnedThreads=short跑一次集成测试。 - 压测必须用真实下游延迟分布:线下 mock 的均匀延迟掩盖 pinning 问题,要复刻生产长尾。
- 每个 Pod export pin 事件 metric:阈值 > 1000/min 报警。
- 不要在 VT 里跑 CPU 密集任务:Loom 不是 CPU scheduler,长跑任务会饿死其他 VT。
- ThreadLocal 在 VT 上要小心:VT 数量可能上万,ThreadLocal 全在堆里会 OOM,改用 ScopedValue(Java 21+)。
- 不要手动管理 VT 线程池:VT 是廉价的,直接
Thread.ofVirtual().start()即可,不要用 ThreadPoolExecutor 包。 - 升级到 JDK 21+ 最新小版本:每个版本都在改进 pinning 行为,关注 release notes。
- JFR 持续打开,留 7 天 rolling:任何性能问题第一时间能回放 pinning 事件。
- CI 加 pin 监控测试:接 byte-buddy 或 ASM 扫 hot path 上的 synchronized,新增直接 fail。
修法补充:carrier 池大小调优
有些场景下,即使做了所有正确的 VT 迁移,如果你 Pod 只有 4 个核,业务里又确实有少量"无法避免的短 pin"(比如 logging framework 内部的 synchronized 写文件),稳态下 carrier 偶尔还是会被占满。Loom 允许通过 JVM 参数把 carrier 池调大:
# 默认 carrier 池大小 = Runtime.availableProcessors()
# 在 4C Pod 上是 4 个 carrier
# 调大到 16 个 carrier,容忍偶尔短 pin
-Djdk.virtualThreadScheduler.parallelism=16
-Djdk.virtualThreadScheduler.maxPoolSize=256
# 同时调整 ForkJoinPool 行为
-Djdk.virtualThreadScheduler.minRunnable=2
这是一个权衡:carrier 多了,pin 容忍度高,但 carrier 间的工作窃取竞争增加,稳态吞吐略降。我们的经验是parallelism = CPU cores × 2-4 是个安全的起点,具体值通过压测 sweep 找最优。但这只是"缓解",不是"修复"——根本上还是要消灭 pin。
引申一:JEP 491 与 synchronized 的未来
JEP 491(JDK 24 中)宣布把 synchronized 也变成 unmount-friendly——届时大部分 synchronized 在 VT 上不再 pin。这是一个重大改进,但有几个注意:
- 仍然有 native frame 的场景会 pin:比如 JNI 调用、某些 Class loader 锁;
- 不是所有 JDK 24+ 都默认开启:可能需要
-Djdk.virtualThreadScheduler.parallelism之类的开关; - 第三方依赖仍要测:即使 JVM 不 pin,组件内部有死锁风险的 synchronized 写法仍然要重构。
所以哪怕等到 JDK 24+,本文的纪律(把 synchronized 换 ReentrantLock)依然是最佳实践——更易读、更可控、性能没差,何乐而不为。
引申二:为什么线下压测没复现
这是事故里最让我们后怕的一点——线下 8000 QPS 压测怎么也没复现 pinning 崩溃。复盘后定位到 3 个原因:
- 线下用的下游 mock 延迟均匀(都是 20ms),生产下游 P99 长尾(10ms-2s);均匀延迟下 carrier 切换均匀,长尾下 carrier 频繁卡在长事务上 + pin,叠加雪崩;
- 线下用的 EhCache cache hit 率 95%+,因为压测数据集小;生产 cache hit 率 60%,意味着 40% 请求穿透到 DB,DB 查询全程持有 EhCache 的 put synchronized 锁;
- 线下数据库连接池配 50,生产 200,池越大 pin 状态下排队效应越剧烈(更多 VT 等同一个 carrier)。
这给我们的压测规范加了三条铁律:下游延迟必须复刻生产分布(用真实 trace replay)、cache hit 率必须复刻生产、连接池规模必须复刻生产。
更广义地说,任何"用户态调度器"的特性(VT、Go runtime、tokio executor)在性能测试时都比传统线程更难复现真实问题——因为这些调度器内部状态(carrier 利用率、工作窃取频率、parking 队列长度)对负载结构非常敏感。均匀负载下调度器表现完美,真实长尾负载下立刻暴露弱点。所以这类系统的压测必须用 trace replay,而不是用 wrk2 之类的"均匀生成器"。我们后来把生产 Nginx access log 做成 replay 数据集,定期回放给压测环境,这是迄今最有效的压测方法,远胜任何"理论上合理"的合成负载。
引申三:虚拟线程 vs 反应式编程(Reactor/RxJava)
| 维度 | 虚拟线程 (Loom) | Reactor/RxJava |
|---|---|---|
| 编码模型 | 同步阻塞代码(直觉) | 异步链式(flatMap, zip, etc.) |
| 学习曲线 | 平缓,会写传统 Java 就行 | 陡峭,需理解 backpressure 等概念 |
| 调试体验 | 正常 stack trace | 调用栈丢失,需要 Reactor checkpoint |
| 性能(纯 I/O) | 接近(注意 pinning) | 稍优,无 pinning 风险 |
| 性能(CPU 密集) | 不推荐 | 不推荐 |
| 生态成熟度 | 2023+ 开始爆发 | 2017+ 已经成熟 |
| 典型适用 | HTTP API、定时任务 | 流处理、复杂编排 |
我们的结论:新项目、纯 HTTP API 服务,直接选 Loom;已有 Reactor 项目,没必要为切 Loom 而重构;复杂事件流处理,继续用 Reactor。这两种模型不是互斥的,Spring 6 已经支持在同一应用里混用。
引申四:ThreadLocal 在 Loom 时代的隐性陷阱
事故修完后我们做安全审查,发现另一个隐性陷阱——ThreadLocal 在虚拟线程上行为正确但开销巨大。一个 ThreadLocal 在传统 200 线程模型下,最多 200 份;Loom 下可能瞬间有 1 万个虚拟线程并发,等于 1 万份 ThreadLocal 实例驻留堆里。我们一个 SecurityContext 的 ThreadLocal 在大促时占了 800MB 堆——线上没爆是因为我们堆调到 6GB,但已经是隐患。JDK 21 引入了 ScopedValue 来替代,通过结构化作用域避免常驻:
// 旧:ThreadLocal,在 Loom 下放大 100 倍
private static final ThreadLocal<SecurityContext> CONTEXT = new ThreadLocal<>();
// 新:ScopedValue,JDK 21+ 预览特性,作用域结束自动清理
private static final ScopedValue<SecurityContext> CONTEXT = ScopedValue.newInstance();
public Response handle(Request req) {
SecurityContext ctx = buildContext(req);
return ScopedValue.where(CONTEXT, ctx).call(() -> {
return business.process(req);
});
}
ScopedValue 在虚拟线程结束时自动释放,不会常驻;同时它是不可变的,避免 ThreadLocal 那种被业务代码偷偷修改导致的并发 bug。
引申五:这次故障对我们 SRE 文化的影响
这次事故让团队对"前沿特性上线"做了重新校准。原本我们的发布流程是"线下压测通过 + 灰度 1 天",这次证明对 Loom 这种"运行时行为发生根本变化"的特性来说远远不够。新的规约是:涉及 JVM/runtime 层切换的特性(虚拟线程、ZGC、Project Panama 等),必须满足"线下 4x 生产负载长跑 24h + 单 Pod 灰度 7 天 + 全量灰度按 5/15/40/100% 分四步"才能视为安全。慢一点没关系,但要彻底,不能再让"线下绿生产红"重演——SRE 真正的能力,是知道已知的安全网在哪些场景下会失灵,而不是迷信通过率。
引申八:Loom 与 GC 的相互作用
排查中我们顺便发现一个反直觉的事:Loom 切换前后 GC 行为发生了变化。平台线程时代,每个线程持有 1MB 栈,GC 扫根集时这些栈帧也是根;Loom 时代,每个 VT 的栈只有几 KB 且是堆上对象(Continuation),意味着GC 根集变小,但堆里多了大量栈片段。具体影响:
| 指标 | 平台线程(200 线程) | 虚拟线程(1 万 VT 并发) |
|---|---|---|
| 栈空间总占用 | 200 × 1MB = 200MB(独立内存) | 1 万 × ~10KB = 100MB(堆内) |
| GC 根集大小 | 200 个线程栈 | 仅 carrier 数(4 个)+ scheduler 引用 |
| GC 标记阶段 | 扫栈是大头 | 变快 |
| 新生代分配压力 | 低 | 高(VT 创建/销毁是堆分配) |
| 建议 GC | G1 / Parallel | ZGC / Shenandoah(低延迟更适合 Loom) |
我们切 Loom 时同步把 GC 从 G1 切到 ZGC,P99 GC pause 从 35ms 降到 0.4ms。这两件事是配套的——Loom 让线程廉价,你的服务就会更倾向于"每请求一个线程",意味着新生代分配速率必然增加,匹配的 GC 必须能够 handle 高分配率。如果你不切 GC,Loom 的红利会被 GC pause 吃掉一半。
引申: jdk.tracePinnedThreads 的两种模式
这个 JVM 参数其实有两个值,生产 / 开发场景用法不同,这里说清楚避免误用:
-Djdk.tracePinnedThreads=full:每次 pin 打印完整 stack,信息全但 I/O 开销巨大,只能短期诊断用。我们诊断时开了 30 秒就关——日志增长了 1.2GB,继续开会撑爆 ES;-Djdk.tracePinnedThreads=short:只打印 pin 发生的位置(顶层 frame),开销小,适合长期开;- JFR + jdk.VirtualThreadPinned 事件:完全没有 stdout 开销,而且能聚合统计,生产环境长期开这个
我们最后在生产用的是 JFR + Prometheus export 组合,既不污染日志,又能让 SRE 直接在 Grafana 上看每分钟 pin 事件趋势。如果你的服务还没接 JFR,这次切 Loom 是个好的契机一并接上——JFR 是 JDK 自带的,启用零成本。
引申九:操作系统层面也有 pinning 的远亲
视野再放大一层,VT pinning 其实是"用户态调度器卡在内核态"这类问题的一个变种。同类的问题在多个系统都存在:
- Go 的 syscall pinning:Go runtime 的 goroutine 在 syscall 期间会把对应 M(OS 线程)交给 syscall,无法跑别的 G;高频 syscall(如同步 file I/O)会让 runtime 不停创建新 M,M 数量爆炸时 OOM;
- Linux io_uring 的内核线程:io_uring 的 SQPOLL 模式会起一个内核线程帮你提交 IO,但如果内核线程被某个慢 syscall 卡住,所有提交都被阻塞;
- Node.js libuv 线程池:Node 默认 4 个 libuv 线程,所有同步文件 IO / DNS / crypto 在这 4 个线程上排队,一个慢操作就堵全部;
- Erlang 的 dirty schedulers:Erlang 早期所有 NIF 在主调度器上跑,慢 NIF 会卡死调度;OTP 17 引入 dirty schedulers 隔离这类调用。
所有这些都告诉我们一件事:"绿色线程 / 协程 / 用户态调度"模型必然需要一套机制处理"无法切走的阻塞"——Loom 选择了显式 pinning + 监控,Go 选择了动态扩 M,Erlang 选择了 dirty schedulers,Node 选择了固定大小线程池。每种方案各有取舍,理解它就能预测它的失败模式。
引申六:Structured Concurrency 是 Loom 完整故事的另一半
Loom 项目其实由两块组成:虚拟线程(VT)和Structured Concurrency(SC)。本文主线是 VT,但 SC 同样重要——它解决了"VT 廉价导致随手起新线程,后续无法 cancel/timeout/error 传播"的工程问题。SC 的核心 API StructuredTaskScope 让你把多个并发任务绑在一个作用域里:
// 用 Structured Concurrency 并发调下游 user + product,任一失败即取消另一个
import jdk.incubator.concurrent.StructuredTaskScope;
public Order assemble(long orderId) throws Exception {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Supplier<User> userTask = scope.fork(() -> userClient.get(orderId));
Supplier<Product> productTask = scope.fork(() -> productClient.get(orderId));
scope.join(); // 等所有子任务
scope.throwIfFailed(); // 任一失败即抛
return new Order(userTask.get(), productTask.get());
}
// try-with-resources 自动调 close,所有未完成子任务被 cancel
}
对比传统 CompletableFuture.allOf:SC 提供了"如果父作用域提前退出,所有子任务必然被取消"的保证,这在 Loom 时代特别重要——没有 SC,你 fork 出去的 VT 可能在父请求超时后还在跑,白白消耗下游资源、产生孤儿调用。Spring 6.2 已经把 SC 整合进 WebRequestHandler,业务代码可以直接用。切 Loom 的同时强烈建议把 SC 一并落地,不然你只解决了"线程廉价",没解决"线程生命周期失控"。
引申七:同类的"看似无界实则有界"故障模式
这次 pinning 事故让我意识到,业内有一类反复出现的故障模式可以总结为:"看似无界资源,实际被某个隐藏的有限通道收敛"。这个模式不仅 VT 上有,在多个领域都有:
| 资源 | 表面无界 | 真实瓶颈(隐藏通道) |
|---|---|---|
| 虚拟线程 | VT 数量无限 | carrier 池大小 |
| K8s Pod 副本数 | 可以拉到几千 | 下游单实例连接数 / DB max_connections |
| asyncio 并发协程 | 百万级 task | 系统文件描述符 / Event loop 单线程 CPU |
| Node.js Promise | 无限 chain | libuv 线程池(4 个默认) |
| Goroutine | 百万级 | 系统线程数 / NetPoller |
| S3 上传并发 | 无限 | 账号级 PUT QPS 限制 |
识别这种模式的方法:遇到性能问题时,问自己"我以为这里是无界的,但下游真正承载的是什么?"。每一层抽象都会有一个最终承载它的物理资源,找到那个物理资源就找到瓶颈。Loom 的 pinning 就是教科书级的例子——VT 在 carrier 上跑,carrier 是物理线程,物理线程是 OS 资源,链条上任何一环被 pin 都会让所谓"无界"立刻变有界。
总结
虚拟线程是 Java 这十年最重要的并发模型升级,它解决了"一个线程一连接"模型的根本扩展性问题。但它不是免费午餐——synchronized 与某些 native 调用会触发 carrier thread pinning,让你的"无界 VT"瞬间退化成几个 carrier 的固定池,在高 QPS 下直接雪崩。
核心避坑三件套:把 synchronized 换 ReentrantLock、把老牌库换 VT 友好版本、上线必开 jdk.tracePinnedThreads 监控。比这更重要的是认知:Loom 改变了线程的执行模型,所有围绕"线程"做并发假设的代码都值得重新审视一次——synchronized、ThreadLocal、ThreadPoolExecutor、平台线程池调优经验、性能压测方法,全部都要按 VT 时代的规则重新校准。这次 4 天的踩坑教会我们,迁移大特性时,真正的功夫不在写新代码,而在认真审一遍旧代码的每一个并发假设。
另一层启示是:"看起来一行配置就能开启"的特性最危险。spring.threads.virtual.enabled=true 这一行的代价是要审计全部依赖、重构 hot path、重新设计压测、改 GC、加 pinning 监控、培训团队——加起来是几人月的工作。下一次面对任何"一键启用"的现代特性(Native Image、Records 替换 DTO、模块系统、CRaC、Project Valhalla 等),都要先问自己:这一行配置打开的,是一条新地铁,还是一个未拆掉脚手架的工地?如果答案是后者,就要做相应的工程准备。这种工程心态远比熟悉任何具体特性的细节更重要——因为下一个 Loom 一定会出现。
—— 别看了 · 2026