Java 21 虚拟线程切上线第 6 天大促被 synchronized + EhCache + JDBC 三连 pinning 卡死 P99 9.4s 的 4 天复盘:carrier thread 失踪定位 + 5 种修法 + 10 条 Loom 工程纪律

Spring Boot 3.2 + Java 21 虚拟线程上线 6 天大促当晚 QPS 从 3800 跌到 420 P99 飙 9.4s,线下 8000 QPS 一切正常。jdk.tracePinnedThreads 抓出每秒数千次 pinning:EhCache 2.x synchronized + 老 JDBC 驱动 native + 业务两段 synchronized 把所有 carrier 钉死。本文讲清 VT 调度模型、carrier pinning 原理、5 种修法、ScopedValue 替代 ThreadLocal、Structured Concurrency 配套、JFR 监控、跨平台同类问题对比。

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.readLockSupport.parkThread.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 下所有的锁(ReentrantReadWriteLockStampedLockSemaphore)在 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 工程纪律

  1. 切 VT 前必须先 grep 全代码所有 synchronized:hot path 上的全部改 ReentrantLock。
  2. 第三方依赖必须过一遍 VT 兼容性验证:用 -Djdk.tracePinnedThreads=short 跑一次集成测试。
  3. 压测必须用真实下游延迟分布:线下 mock 的均匀延迟掩盖 pinning 问题,要复刻生产长尾。
  4. 每个 Pod export pin 事件 metric:阈值 > 1000/min 报警。
  5. 不要在 VT 里跑 CPU 密集任务:Loom 不是 CPU scheduler,长跑任务会饿死其他 VT。
  6. ThreadLocal 在 VT 上要小心:VT 数量可能上万,ThreadLocal 全在堆里会 OOM,改用 ScopedValue(Java 21+)。
  7. 不要手动管理 VT 线程池:VT 是廉价的,直接 Thread.ofVirtual().start() 即可,不要用 ThreadPoolExecutor 包。
  8. 升级到 JDK 21+ 最新小版本:每个版本都在改进 pinning 行为,关注 release notes。
  9. JFR 持续打开,留 7 天 rolling:任何性能问题第一时间能回放 pinning 事件。
  10. 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。这是一个重大改进,但有几个注意:

  1. 仍然有 native frame 的场景会 pin:比如 JNI 调用、某些 Class loader 锁;
  2. 不是所有 JDK 24+ 都默认开启:可能需要 -Djdk.virtualThreadScheduler.parallelism 之类的开关;
  3. 第三方依赖仍要测:即使 JVM 不 pin,组件内部有死锁风险的 synchronized 写法仍然要重构。

所以哪怕等到 JDK 24+,本文的纪律(把 synchronized 换 ReentrantLock)依然是最佳实践——更易读、更可控、性能没差,何乐而不为。

引申二:为什么线下压测没复现

这是事故里最让我们后怕的一点——线下 8000 QPS 压测怎么也没复现 pinning 崩溃。复盘后定位到 3 个原因:

  1. 线下用的下游 mock 延迟均匀(都是 20ms),生产下游 P99 长尾(10ms-2s);均匀延迟下 carrier 切换均匀,长尾下 carrier 频繁卡在长事务上 + pin,叠加雪崩;
  2. 线下用的 EhCache cache hit 率 95%+,因为压测数据集小;生产 cache hit 率 60%,意味着 40% 请求穿透到 DB,DB 查询全程持有 EhCache 的 put synchronized 锁;
  3. 线下数据库连接池配 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 其实是"用户态调度器卡在内核态"这类问题的一个变种。同类的问题在多个系统都存在:

  1. Go 的 syscall pinning:Go runtime 的 goroutine 在 syscall 期间会把对应 M(OS 线程)交给 syscall,无法跑别的 G;高频 syscall(如同步 file I/O)会让 runtime 不停创建新 M,M 数量爆炸时 OOM;
  2. Linux io_uring 的内核线程:io_uring 的 SQPOLL 模式会起一个内核线程帮你提交 IO,但如果内核线程被某个慢 syscall 卡住,所有提交都被阻塞;
  3. Node.js libuv 线程池:Node 默认 4 个 libuv 线程,所有同步文件 IO / DNS / crypto 在这 4 个线程上排队,一个慢操作就堵全部;
  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
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Go time.After 在 for-select 循环里每秒 5 万次的内存泄漏 3 天复盘:18 小时准时 OOM + ticker / NewTimer.Reset / Go 1.23 三套正解

2026-5-26 23:05:18

技术教程

MongoDB 6.0 分片集群 $lookup 跨分片 scatter-gather 拖垮 P99 7.4s 的 3 天复盘:反规范化 + Change Stream + 应用层 $in 四种修法对比 + 13 条工程纪律

2026-5-26 23:20:19

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索