Java · 虚拟线程 (Loom)

线程 60 年来最大重写 — 栈在堆上按需生长, 阻塞在 IO 点 unmount, 少量 carrier 撑起百万虚拟线程

平台线程: 阻塞 = 烧内核线程 (1:1) 请求队列 (红色堆积) 200 并发之外的全在这等 等不到 = 前端转圈到超时 Tomcat 线程池 max=200 T1 read 阻塞 T2 read 阻塞 T3 read 阻塞 T4 read 阻塞 T5 等新请求 … T200 每个 ⇄ 一根内核线程 (~1MB 栈) 阻塞期间啥也不干但占着资源: 200 线程 = 200 并发上限 提线程数? 内存与上下文切换先把你拖垮 虚拟线程: 阻塞 = 换人 (M:N) 海量 VT (百万级) VT-1 … VT-1,000,000 栈在堆上, KB 级起步按需长 不池化: 一任务一线程 mount unmount carrier = ForkJoinPool C1 C2 C3 …C8 并行度 = CPU 核数, 固定少量 carrier 上"轮流坐"海量 VT socket.read() 阻塞点 → VT 让出 carrier IO 由内核/调度器看管, 等待期间零线程占用; 数据就绪 → VT 重新入队被任意 carrier mount 少量 carrier 撑起百万并发 — 内存省三个数量级 同步代码的写法, 事件循环的吞吐 (与 Netty/反应式对比见下文) mount / unmount 时序 — 栈是可搬运的箱子 ① VT-1 运行 carrier-2 执行 栈 KB 级在堆 ② socket.read 阻塞点到达 要等内核数据 ③ unmount 栈整箱搬进堆队列 carrier-2 空出 ④ mount VT-2 同一根 carrier 立刻跑下一个 ⑤ 数据就绪 VT-1 重新入队 任意 carrier 恢复 特写: unmount ≠ 销毁 — 栈原样搬家, 恢复时原样装回 carrier-2 VT-1 的栈 (箱子) run() handler() read() ← 卡在这 unmount 堆: 挂起 VT 队列 run() handler() read() mount carrier-5 代码视角"从没停过" — 局部变量/调用链完好, 这就是同步写法拿到异步吞吐的秘密 mount/unmount 只发生在阻塞点; CPU 密集代码段从头跑到尾不换人 暗礁: pinning 钉死 carrier synchronized 块内阻塞 VT 无法 unmount → 连人带栈钉死整根 carrier 同罪: native/JNI 调用 · 旧文件 IO · 身份哈希(旧版) carrier 被钉满 → 其他 VT 全体饿死, 吞吐骤降 排查与修复 -Djdk.tracePinnedThreads=full 打印钉住栈 synchronized 换 ReentrantLock: 阻塞可 unmount 历史注脚 JDK 24 (JEP 491) 起 synchronized 不再引发 pinning 按你所在 LTS 的实测行为写代码, 别背旧结论 观察指标: jcmd Thread.dump_to_file 里的 pinned 计数 Legend 虚拟线程/栈 carrier/推荐 阻塞点/挂起 排队/pinning 就绪/工具

阻塞不再烧内核线程

  • • VT 的栈在堆上, KB 级起步按需生长
  • • IO 阻塞点 unmount, carrier 立刻换人
  • • 少量 carrier(=核数) 撑起百万并发

一任务一线程, 别池化

  • • VT 创建成本 ≈ 普通对象, 不需要池
  • • 限并发用 Semaphore, 不是缩小线程数
  • • ThreadLocal 语义保留但按 VT 数放大

pinning 是头号暗礁

  • • synchronized 内做 IO 会钉死 carrier
  • • tracePinnedThreads=full 一抓一个准
  • • IO 密集吃红利, CPU 密集零收益

💡 一句话理解

虚拟线程把"线程"从内核资源降级成了堆上的普通对象: 栈按需生长、百万个也才几个 GB, 由 JVM 里的少量 carrier(默认 = CPU 核数的 ForkJoinPool) 轮流承载。魔法全在阻塞点: socket.read() 挂起时, VT 连人带栈 unmount 搬进堆里排队, carrier 立刻 mount 下一个 VT — IO 等待第一次变成"零线程占用"。于是你能用最朴素的同步写法(阻塞读/顺序调), 拿到 Netty/反应式才有的事件级吞吐。但记住边界: CPU 密集没红利, synchronized 里做 IO 会把 carrier 钉死, 而"不池化、用信号量限流"是它与平台线程池最反直觉的用法差异。

🧠 必知必会 必考 & 必会

虚拟线程是什么
JVM 调度的用户态线程 (JDK 21 转正): 栈存在堆上按需生长复制, 不对应内核线程 — 创建/销毁成本接近普通对象, 百万级并发成为常态。API 与平台线程几乎一致。
Thread vt = Thread.startVirtualThread(() -> {    // JDK 21+ 转正
    System.out.println(Thread.currentThread());
});                       // 输出形如 VirtualThread[#21,module-main]
vt.isVirtual();           // → true  栈在堆上, KB 级
carrier 是什么
真正跑在 CPU 上的平台线程, 默认一个 ForkJoinPool, 并行度 = 核数。多根 VT 复用一根 carrier; VT 不是"跑在哪个核"而是"此刻挂在哪根 carrier 上"。
Runtime.getRuntime().availableProcessors();
// → 8  即 carrier 默认并行度 (ForkJoinPool)
// 挂载中的 VT 形如 VirtualThread[#21]@ForkJoinPool-1-worker-3
// worker-3 = carrier; 海量 VT 在 8 根 carrier 上轮流坐
mount / unmount
只发生在阻塞点: 阻塞 IO(网络/JNI 之外的库调用)/sleep/lock 等待时 unmount(栈搬堆, carrier 空出); 就绪后重新入队被任意 carrier mount 恢复。CPU 密集段从不换人 — 所以 CPU 任务无收益。
var in = socket.getInputStream();
in.read();      // 阻塞点 → unmount: 栈搬堆, carrier 空出跑别的 VT
// 数据就绪 → 重新入队, 被任意 carrier mount 恢复
// 关键: 局部变量/调用链完好, 代码视角"从没停过"
创建三姿势
Thread.ofVirtual().start(r)、Thread.startVirtualThread(r)、批量任务用 Executors.newVirtualThreadPerTaskExecutor()(一任务一线程的 executor)。前两者拿裸线程, 第三种配 try-with-resources 自动 shutdown。
Thread.startVirtualThread(task);               // 1 静态方法
Thread.ofVirtual().name("vt-1").start(task);  // 2 builder
try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
    vt.submit(task);
}                                              // 3 自动 shutdown
不池化原则
VT 池化是反模式: 池的意义是复用昂贵的内核线程, 而 VT 本身廉价。并发上限用 Semaphore 控"同时在飞的数量", 用下游容量反推许可数, 而不是缩线程池。
// 错思路: 池化 VT — 廉价对象当昂贵资源复用
Semaphore rate = new Semaphore(200);  // 对: 限"同时在飞"
rate.acquire();
try { call(); } finally { rate.release(); }
pinning 场景
① synchronized 块内阻塞 (JDK 24 前) ② native/JNI 调用 ③ 旧 File IO。钉死期间 carrier 无法换人, 钉满 = 全体 VT 饿死。排查: -Djdk.tracePinnedThreads=full; 修复: 换 ReentrantLock。
synchronized (lock) { jdbc.query(); }   // JDK 21-23: 钉死 carrier
private final ReentrantLock lock = new ReentrantLock();
// 排查: java -Djdk.tracePinnedThreads=full Main  打印钉住栈
ThreadLocal 语义
VT 完整支持 ThreadLocal, 但"每 VT 一份"在百万线程下内存放大千倍。小对象无妨; 大上下文显式传参或换 ScopedValue (作用域绑定, 用完即走, 不可变)。
static final ThreadLocal<User> CTX = new ThreadLocal<>();  // 每 VT 一份
// 判定: 对象大小 × 最大 VT 数! 512KB × 1M = 500GB
// 大上下文: 显式传参, 或 ScopedValue 用完即走
传统 API 的边界
setPriority 无意义(调度不管优先级), stop/suspend 依旧弃用且对 VT 行为一致抛异常; Thread.getAllStackTraces 对百万 VT 极慢 — 用 Thread.dump_to_file 替代。
vt.setPriority(Thread.MAX_PRIORITY);  // 无意义: 调度不认优先级
vt.stop();                             // → UnsupportedOperationException
// 百万 VT 别 getAllStackTraces: jcmd <pid> Thread.dump_to_file
结构化并发
StructuredTaskScope 把"一批子任务的生命周期"绑成一个作用域: join/取消/异常传播自动成套 (ShutdownOnFailure 全成全败, ShutdownOnSuccess 取先成功)。多轮预览后逐步稳定, 引入前锁版本核对包名。
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    var a = scope.fork(() -> taskA());      // 预览 API, 锁版本核对包名
    var b = scope.fork(() -> taskB());
    scope.join().throwIfFailed();        // 一个失败 → 全取消
}
适用边界
IO 密集(网关/爬虫/扇出调用/阻塞 JDBC)吃满红利; CPU 密集零收益(VT 不加核); 需要 extreme 低延迟内核级调度的场景仍看平台线程/Netty。
Future<Info> f = vt.submit(() -> httpClient.get(url));
// IO 密集 (网关/扇出/阻塞 JDBC): 红利满格
// CPU 密集 (压缩/加密): 零收益, 走核数大小的平台池
vs 反应式
Netty/Reactor 用回调/事件循环榨吞吐, 代价是调试地狱与"异常在流里传递"的心智模型; VT 用同步写法拿同级吞吐, 栈是完整的, try/catch/log 直接可用 — 新项目默认 VT, 老反应式不必推倒。
// 反应式: get().async(onOk, onErr)  回调地狱, 栈断了
Info info = infoClient.get(id);   // VT: 同步写法, 阻塞不烧线程
try { use(info); } catch (IOException e) { log.warn("io", e); } // 栈完整
Spring 一键接入
Boot 3.2+ 配 spring.threads.virtual.enabled=true: Tomcat 走一请求一 VT。注意它只改"入口线程模型", @Async/自定义 Executor/JDBC 池要自己评估 (见场景 1 与坑 13)。
// application.properties (Boot 3.2+, JDK 21+)
// spring.threads.virtual.enabled=true  → Tomcat 一请求一 VT
// 只改入口线程模型; @Async/自定义 Executor 要单独换
JDK 24 注脚
JEP 491 让 synchronized 阻塞不再 pinning (旧版本必须绕)。技术文档要写"按 LTS 实测行为": JDK 21 LTS 上 synchronized+IO 仍是雷, JDK 25 LTS 已大幅缓解 — 结论带版本号才靠谱。
synchronized (lock) { jdbc.query(); }
// JDK 21-23: 阻塞 → pinning (LTS 雷区, 必须绕)
// JDK 24 (JEP 491) 起: 不再 pinning, 可正常 unmount
// 结论必须带版本号, 按所在 LTS 的实测行为写

🏭 生产实战 real world

场景 1 · 阻塞式 Spring Boot 一行配置切 VT (与前提清单)

IO 密集的老服务, 200 线程池常年打满排队; JDK 21 + Boot 3.2 起一行配置把"每请求一线程"换成 VT:

// application.properties (JDK 21+, Boot 3.2+)
// spring.threads.virtual.enabled=true
// Tomcat: 每个请求一个虚拟线程, 替代 maxThreads=200 的平台池

// 开开关前的三件前提, 缺一个都白改:
// 1) @Async / 自定义 Executor 的地方不受该开关影响, 要单独换 VT executor
// 2) ThreadLocal 缓存大对象的代码先清理 (内存放大千倍)
// 3) synchronized 内做 IO 的老代码 → 先压测看 pinned, 见场景 5

只读接口灰度一周: P99 从 1.9s 降到 410ms, 机器没加一台。

场景 2 · 详情页三下游扇出: 每请求一个 VT, 告别 thenCombine 套娃

CompletableFuture 编排三层 thenCombine, 异常要在流里接; VT 化后就是三行同步代码:

try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
    Future<Info>  a = vt.submit(() -> infoClient.get(id));    // 阻塞调用放心写
    Future<Price> b = vt.submit(() -> priceClient.get(id));
    Future<Stock> c = vt.submit(() -> stockClient.get(id));
    return render(a.get(), b.get(), c.get());   // get() 阻塞的是 VT, 不烧内核线程
}
// 代码量 -40%, 异常回归普通 try/catch, 栈完整可调试
// executor 用完即关 (try-with-resources), 别做成全局静态常驻 — 见坑 7

场景 3 · 百万补偿任务: VT 不池化, Semaphore 限并发

大促后跑百万条补偿, 平台线程池 64 根跑三天; VT 一任务一线程, 但下游承不住无限并发 — 信号量是唯一闸门:

try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    Semaphore rate = new Semaphore(200);          // 全局同时在飞 ≤200, 按下游容量定
    List<Future<?>> fs = new ArrayList<>();
    for (long id : ids) {
        fs.add(pool.submit(() -> {
            rate.acquire();                    // 排队等许可: 阻塞在 VT 上, 零内核占用
            try { compensate(id); }
            finally { rate.release(); }
        }));
    }
    for (Future<?> f : fs) f.get();          // 别忘 get: 不 get 异常照样静默 (老坑不变)
}

同样 8C 容器: 3 天 → 5 小时, 下游错误率零变化 (许可数对齐下游压测上限)。

场景 4 · JDBC 驱动在 VT 下的吞吐实验: 连接池才是新瓶颈

VT 化后 QPS 没涨? 阻塞 JDBC 在 VT 上没问题, 但连接池只有 10 — 排队从线程池挪到了池子里:

HikariConfig cfg = new HikariConfig();
cfg.setMaximumPoolSize(50);      // 池上限 = 新瓶颈: VT 百万也过不去这 50 个连接
// 压测数据示意 (8C 容器, 下游 SQL P99 50ms):
//   平台线程 200 + 池 10 →  8.2k QPS · P99 1.8s   (线程池排队)
//   VT + 池 10           →  9.0k QPS · P99 1.6s   (排队挪到连接池)
//   VT + 池 50           → 21.0k QPS · P99 420ms  (真瓶颈消除)
// 再往上是 DB 自己的极限 — 限流与熔断永远要配着加, VT 不豁免容量规划

场景 5 · pinned 排查: tracePinnedThreads 定位 synchronized, 换 ReentrantLock

灰度 VT 后吞吐反而降: 一段老代码在 synchronized 里查库, 把 carrier 全钉死了:

// 灰度 JVM 参数: -Djdk.tracePinnedThreads=full
// 日志: Thread[#67,ForkJonPool-1-worker-3] pinned due to:
//   com.x.OrderService.warmCache → synchronized (lockCache) { jdbc query... }

private final ReentrantLock lock = new ReentrantLock();  // 修复: 换锁
void warmCache() {
    lock.lock();                          // ReentrantLock 阻塞可 unmount
    try { warmWithDb(); } finally { lock.unlock(); }
}
// 修完 P99 410ms; 顺带把"锁内查库"这个老毛病也治了 — 锁内 IO 本就该移出去

场景 6 · 双活机房"谁先成功用谁": StructuredTaskScope 竞速

读多写少的配置查询, 主备机房同时发, 用先回的那份; 手写 Future 竞速要管取消, 结构化并发自动收尾:

try (var scope = new StructuredTaskScope.ShutdownOnSuccess<String>()) {
    scope.fork(() -> primary.get(url));    // 主机房
    scope.fork(() -> standby.get(url));    // 备机房, 两个 VT 同步竞速
    scope.join();                           // 阻塞的是"当前 VT", 不占内核线程
    return scope.result();               // 第一个成功的结果; 其余自动取消
}
// 注意: 结构化并发长期预览 (JEP 505 等), 包名/类名随版本核对, 生产锁死 JDK 版本再上

场景 7 · ThreadLocal 在百万 VT 下内存炸 → 换 ScopedValue 思路

链路上下文 512KB 缓存进 ThreadLocal, VT 一百万个就是 500GB; 平台线程池时代 200 份没问题, 现在量纲变了:

// 坏: 大对象 × 最大 VT 数 = 灾难
static final ThreadLocal<UserCtx> CTX = new ThreadLocal<>();   // 512KB × 1M

// 好: 上下文显式传参; 必须线程绑定时用有界作用域
private static final ScopedValue<UserCtx> CTX = ScopedValue.newInstance();
ScopedValue.where(CTX, userCtx).run(() -> handler.process(req));  // 用完即走

// 判定标准一句话: 这个对象 × 最大 VT 数, 还小吗?
// 小对象 (locale/traceId 几十字节) 照用 ThreadLocal, 不用过度设计

场景 8 · 爬虫网关万级长连接 VT 化: 一连接一线程, 事件循环全删

老网关用 Netty 写了三千行 pipeline, 新人接不住; 换 VT 后回到教科书写法:

try (ServerSocket server = new ServerSocket(8080);
     ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    while (running) {
        Socket s = server.accept();
        pool.submit(() -> handle(s));       // 一连接一 VT, 万级也轻
    }
}
static void handle(Socket s) throws IOException {
    try (s; var in = new BufferedReader(new InputStreamReader(s.getInputStream()))) {
        for (String line; (line = in.readLine()) != null; )  // 阻塞 = unmount
            dispatch(line);                                // 闲连接零线程占用
    }
}

场景 9 · 压测对比: 平台线程池 vs VT (8C 网关, 数据示意)

同容器同下游 (P99 45ms), 只切线程模型, wrk -t8 -c2000 -d10m, 变量只有一个:

// 平台线程池 (max 200):  6.5k QPS · P99 2.1s · CPU 40%
//   → 大量上下文切换, 200 根线程在 8 核上来回踩
// 虚拟线程 (一请求一 VT): 18.7k QPS · P99 380ms · CPU 55%
//   → 切换消失 (mount/unmount 是用户态搬栈), IO 等待不占线程

// 边界验证 (重要): 同服务的 CPU 密集路由 (压缩+加密) 单独压 —
//   平台池 5.1k QPS vs VT 5.2k QPS: 原地踏步, CPU 密集零红利
// 结论模板: 收益只来自"阻塞等待占比", 压测报告必须分路由看

场景 10 · 灰度迁移: 双执行器并存, 配置中心一键回滚

直接全量切 VT 风险大 (pinning/内存放大都是压测才现形); 开关化灰度, 出事改一行配置回滚:

@Bean
ExecutorService jobExecutor(@Value("${jobs.virtual:false}") boolean virtual) {
    return virtual
        ? Executors.newVirtualThreadPerTaskExecutor()   // VT 通道
        : Executors.newFixedThreadPool(64);                 // 原通道兜底
}
// 灰度顺序: 只读接口 → 混部 5% 流量 → 全量; 每步观察:
//   QPS / P99 / pinned 计数 (jcmd Thread.dump_to_file) / 下游错误率 / 堆增长
// 别跳步: 场景 5 与 7 的两个坑, 都是 5% 流量跑了两天才现形的

⚠️ 编码注意与常见坑 pitfalls

坑 1 · synchronized 内做 IO, carrier 被钉死 — VT 化后吞吐不升反降, tracePinnedThreads 显示锁内 jdbc 查询把 carrier 全钉住, 其他 VT 饿死. 正解: 锁内 IO 移出去; 确要阻塞用 ReentrantLock; JDK 24+ 才有 JEP 491 缓解。
synchronized (cacheLock) { dao.warm(); }   // 错: IO 钉死 carrier (JDK<24)
private final ReentrantLock lock = new ReentrantLock();
lock.lock(); try { dao.warm(); } finally { lock.unlock(); }  // 对
坑 2 · ThreadLocal 塞大对象, 内存放大千倍 — 平台池 200 份没感的 512KB 上下文, 百万 VT 下变成几百 GB, OOM 在夜里爆发. 正解: "对象 × 最大 VT 数"先算一遍; 大上下文显式传参或 ScopedValue。
static final ThreadLocal<UserCtx> CTX = new ThreadLocal<>();  // 错: 512KB×1M
process(req, ctx);                          // 对: 显式传参
ScopedValue.where(CTX2, ctx).run(() -> handler(req));      // 对: 作用域
坑 3 · CPU 密集任务跑在 VT 上 — 压缩/加密/排序的 VT 版 QPS 原地踏步, 还和别的 VT 抢少量 carrier. 正解: CPU 任务走固定大小的平台线程池 (核数个), VT 只给 IO 密集路径。
vt.submit(() -> gzip(bytes));    // 错: CPU 密集跑 VT, 零收益还抢 carrier
ExecutorService cpu = Executors.newFixedThreadPool(
    Runtime.getRuntime().availableProcessors());  // 对: 核数个平台线程
坑 4 · 池化虚拟线程 — newFixedThreadPool 包装 VT 后并发被池大小卡死, 还把"廉价对象"当昂贵资源复用, 完全反模式. 正解: newVirtualThreadPerTaskExecutor 一任务一线程, 限并发用 Semaphore。
// 错: newFixedThreadPool(200, Thread.ofVirtual().factory())
//     并发被 200 卡死, 廉价对象当昂贵资源复用
Executors.newVirtualThreadPerTaskExecutor();  // 对: 一任务一线程
坑 5 · 对 VT 调 setPriority 无效 — VT 调度不认优先级, 调了静默无效; stop/suspend 这类弃用 API 行为是抛异常. 正解: 优先级需求改为队列/信号量结构设计; 取消用 interrupt 协作式处理。
vt.setPriority(Thread.MAX_PRIORITY);  // 错: 静默无效
vt.stop();                             // 错: 抛 UnsupportedOperationException
vt.interrupt();                        // 对: 取消走 interrupt 协作
坑 6 · carrier 被钉满, 全站 VT 饿死 — 8 核 8 根 carrier, 两个 synchronized+IO 的热点把 8 根全占, 健康接口的 VT 也在队列里干等, 故障面远超事发代码. 正解: 监控 pinned 计数告警; 修法同坑 1, 别让"局部 pinning"拖垮全局。
// 错: 两个 synchronized+IO 热点把 8 根 carrier 全钉死
jcmd <pid> Thread.dump_to_file -format=json  // 对: 看 pinned 计数
// 告警阈值挂在 pinned 计数, 别等健康接口一起倒
坑 7 · 全局静态 VT executor 常驻不 shutdown — static final 的 newVirtualThreadPerTaskExecutor 从不 close, 应用反复重部署时线程与内存泄漏. 正解: 按批次 try-with-resources 用完即关; 确要常驻就纳入生命周期管理 (停机钩子里 close)。
// 错: static final ExecutorService VT = ...; 从不 close, 重部署泄漏
try (ExecutorService vt = Executors.newVirtualThreadPerTaskExecutor()) {
    runBatch(vt);
}                                        // 对: 用完即关
坑 8 · 背 JDK 21 的结论跑到新 LTS 上 — "synchronized 必 pinning"在 JDK 24+ 已被 JEP 491 解决, 老文章的修复建议成了无用功甚至误导. 正解: 文档/规约标注 JDK 版本区间; 升级 LTS 后重跑 pinning 压测再定策略。
// 错: 背 "synchronized 必 pinning" 跑到 JDK 25 还在绕锁
// 对: 结论带版本区间 — JDK 21-23 绕, 24+ (JEP 491) 已解决
//     升级 LTS 后重跑 pinning 压测再定策略
坑 9 · JDBC 连接池上限挡住 VT 吞吐 — VT 百万并发撞上 Hikari 池 10 个连接, QPS 纹丝不动, P99 全在等连接. 正解: 池上限按下游容量调大; 池等待在 VT 上零内核成本, 但下游容量才是天花板 — 配限流。
HikariConfig cfg = new HikariConfig();
cfg.setMaximumPoolSize(10);   // 错: VT 百万也过不去 10 连接
cfg.setMaximumPoolSize(50);   // 对: 按下游容量调, 配限流熔断
坑 10 · 身份哈希触发 pinning (历史版本) — JDK 21-23 上对锁对象调用默认 Object.hashCode() (身份哈希) 会让 VT 在 synchronized 处无法 unmount. 正解: 关键锁类覆写 hashCode 或版本升级; 老版本用 tracePinnedThreads 验证。
class CacheKey { }               // 错: 默认身份哈希 (JDK 21-23) → pinning
class CacheKey {
    @Override public int hashCode() { return id; }  // 对: 覆写
}
坑 11 · CompletableFuture 默认 commonPool 混跑 — VT 里 thenApply 回调可能落在 commonPool 平台线程上, ThreadLocal 上下文突然读不到, 时有时无的灵异 bug. 正解: 显式传 executor 参数把回调钉回 VT executor; 或全链路 VT 化别混搭。
future.thenApply(this::render);           // 错: 回调落 commonPool, 上下文丢
future.thenApplyAsync(this::render, vtExecutor);  // 对: 钉回 VT
// 或全链路 VT 化, 别混搭
坑 12 · 裸 VT 没设 uncaught handler, 异常静默 — Thread.startVirtualThread 起的线程抛了异常只打到 stderr, 无告警无指标. 正解: 提交前设 setUncaughtExceptionHandler 上报; 或统一走 executor+Future.get (与异常页坑 12 同源)。
Thread.startVirtualThread(task);   // 错: 异常只进 stderr, 无告警
Thread.ofVirtual().uncaughtExceptionHandler(
    (t, e) -> reporter.error(t.getName(), e)).start(task);  // 对
坑 13 · 只开 spring 开关以为全改完了 — spring.threads.virtual.enabled=true 只换 Tomcat 入口线程; @Async 的自定义池、MQ 消费线程、定时任务还是平台池, 压测收益砍半. 正解: 逐个线程来源盘点 (入口/异步/调度/消费), 配置或 Bean 分别替换。
// spring.threads.virtual.enabled=true  只换 Tomcat 入口线程!
// 错: 以为 @Async/MQ 消费/定时任务也自动 VT 化了
// 对: 逐个线程来源盘点, 配置或 Bean 分别替换
坑 14 · VT 数量无上限, 打爆下游 — 一请求 fork 几十个 VT 调下游, 大促流量下并发数乘出天文数字, 下游限流批量拒绝引发雪崩. 正解: 全局/每路由 Semaphore 定并发上限; 下游容量反推许可数 (见场景 3)。
// 错: 一请求 fork 几十个 VT 调下游, 大促并发数雪崩
Semaphore gate = new Semaphore(500);   // 对: 下游容量反推
gate.acquire(); try { callDownstream(); } finally { gate.release(); }
坑 15 · ScopedValue API 随版本漂移 — 预览期包名/方法在 JDK 21→25 间多次调整, 抄旧博客编译不过或运行 NoSuchMethodError. 正解: 锁死团队 JDK 版本再引入; 升级时把 ScopedValue 列入 API 兼容性检查清单。
// 错: 抄 JDK 21 博客的 ScopedValue 写法到 25 → 编译不过
// 对: 锁死团队 JDK 版本再引入; 升级时把 ScopedValue
//     列入 API 兼容性检查清单
坑 16 · 线程绑定的库语义失效 — Netty FastThreadLocal/RxJava 的线程亲和优化建立在"少量常驻线程"上, VT 一任务一线程让缓存永远 miss, 性能不升反降. 正解: 这类库按官方 VT 适配版本走; 过渡期该部分保留平台线程模型。
// 错: FastThreadLocal/RxJava 线程亲和库直接跑 VT, 缓存全 miss
// 对: 按官方 VT 适配版本走; 过渡期该部分保留平台线程模型
坑 17 · jstack 看到百万线程无从下手 — VT 化后 jstack/jstack 式输出爆炸, 老运维脚本直接卡死. 正解: 用 jcmd <pid> Thread.dump_to_file -format=json 看结构化 VT dump; 监控按 carrier 数+VT 总数聚合, 别逐线程报警。
jstack <pid> > dump.txt          // 错: 输出爆炸, 运维脚本卡死
jcmd <pid> Thread.dump_to_file -format=json dump.json  // 对
// 监控按 carrier 数 + VT 总数聚合, 别逐线程报警
坑 18 · 反应式改 VT 后错误处理"回退"没接住 — 老代码异常走 onError 回调流, VT 化后变回同步 throw, 边界处没人 catch, 兜底日志突然安静. 正解: 迁移清单里加一条"错误路径重审": 入口 filter/全局异常兜底必须覆盖同步抛出的路径 (参考异常页场景 1)。
// 反应式: obs.onError(err -> fallback(err))  错误走回调流
Info i = client.get(id);   // VT 化后变回同步 throw
// 对: 入口 filter/全局异常兜底覆盖同步抛出路径
坑 19 · 忽略 VT 与锁页的交互细节 — synchronized 在 VT 语境有了新维度 (pinning), 但 ReentrantLock 竞争激烈时大量 VT 挂起排队, 内存里堆的是"挂起的栈". 正解: 锁竞争本身要治理 (粒度/无锁结构), VT 不豁免并发设计 — 两页要对照着读。
// ReentrantLock 不 pinning, 但竞争激烈时大量 VT 挂起排队
// → 内存里堆的是"挂起的栈"
// 对: 缩小临界区/细粒度锁; 锁竞争治理 VT 不豁免
坑 20 · 把 VT 当免票的超卖通道 — "我们能扛百万并发了"于是放宽下游限流与重试, 结果下游先死: VT 放大的是你自己的等待容量, 不是整条链路的容量. 正解: 容量规划按最弱下游做, VT 省下的资源换成更严的背压与熔断, 而不是更野的放大。
// 错: "能扛百万并发" → 放宽下游限流与重试 → 下游先死
// 对: 容量按最弱下游规划; VT 省下的资源换成更严的
//     背压与熔断, 而不是更野的放大