第 12 章 虚拟线程与结构化并发:Java 21 的”goroutine 答案”(以及它的三个暗礁)

你盯着一段 Go 代码看了三秒:go fetchUsers(); go fetchOrders() —— 两行,开两个 goroutine,简单到你不觉得它值得想。然后你转头看 Java 的同事:他为同样的事配了一个 200 行的线程池(core=16、max=64、ArrayBlockingQueue(1024)、CallerRunsPolicy、自定义 threadFactory),还写了拒绝策略兜底。你忍不住想:Java 能不能像 Go 一样,直接”开就完了”?Java 21 说:能。于是有了虚拟线程。但先别急着高兴 —— 这一章有一半在讲”虚拟线程能让你翻的车,比线程池还多”。


12.1 虚拟线程是什么:把 goroutine 的戏在 JVM 上重演一遍

先给准确定义。虚拟线程(Virtual Thread)是 java.lang.Thread 的一个轻量级实现,是用户态调度的线程——它不绑定一个 OS 线程,而是由 JVM 自己调度,运行在少数几个 OS 线程(官方叫 carrier thread,载体线程)之上。

换句话说,这就是 Java 版的 goroutine。想想第 10 章讲的那张图:Go 是 M:N(M 个 goroutine 跑在 N 个 OS 线程上),虚拟线程一模一样——N 个虚拟线程跑在 M 个 carrier 线程上。区别只是名字:Go 叫 G/M/P,Java 叫虚拟线程 / 载体线程 / ForkJoinPool 调度器。

创建成本对比一下你就有体感了:

维度平台线程(传统 Thread)虚拟线程
初始栈默认 1MB(-Xss,预留虚拟内存)几百字节,按需增长
创建耗时毫秒级(syscall 建内核线程 + 栈分配)纳秒级(纯 JVM 内分配)
可同时存在数量几千就吃力(OS 线程上限)百万级无压力
阻塞代价占着 OS 线程,内核调度器救不了阻塞时 unmount,carrier 去跑别的

这就解释了为什么第 00 章埋的那个伏笔能兑现:goroutine 阻塞能被 runtime 吞掉,Java 线程阻塞会占死 OS 线程——而虚拟线程干的事,本质就是让 JVM 来扮演 Go runtime,把”阻塞”从 OS 层面降级到 JVM 层面。

历史来之不易,这点你必须知道,否则你不会理解后面那些”暗礁”为什么存在。Project Loom 从 2017 年立项,到 Java 21(2023 年 9 月)才正式把虚拟线程转正(JEP 444)——六年,踩了无数坑。根因特别扎心:JVM 里 synchronized、native 方法、JNI 这些机制,从 1995 年起就”绑定”了 OS 线程。要给虚拟线程解耦这层,等于重写 JVM 的并发心脏。

【思考】为什么 Java 等了 20 多年才等到虚拟线程?Go 从第一行代码起就有 goroutine,Java 为什么不能早点有?

参考答案

直接答案:因为 JVM 的线程模型深处,从设计第一天起就耦合了 OS 线程。要解耦这层,等于重写 JVM 的并发心脏。Go 没有这个包袱,因为 runtime 从第一行代码起就自己控制调度。这是”设计时点”决定的历史债务。

先说 Go 为什么没有这个包袱。 Go 出生在 2009 年,Runtime 是白纸一张。它的设计者(Rob Pike、Russ Cox 那批人)从一开始就决定:调度器是 runtime 自己的事,OS 只提供一堆内核线程(M),goroutine(G)怎么在 M 上跑、P(逻辑处理器)怎么分配、网络 IO 怎么交给 netpoller,全是 runtime 说了算。Go 没有任何”语言特性”假设”一个 goroutine = 一个 OS 线程”——反过来,它假设的就是”goroutine 是廉价、海量的、用户态调度的”。

再看 Java 的历史债务。 Java 1995 年设计 Thread 时,操作系统层面的线程(pthread)刚刚普及,最自然、最正确的选择就是”一个 Java 线程 = 一个 OS 线程”(1:1 模型)。于是一整条技术栈都建立在这个假设上:

  • synchronized 的监视器锁,状态存在对象头的 Mark Word 里,锁的拥有者是”OS 线程 ID”——硬绑定。
  • Object.wait()/notify(),底层是 OS 的条件变量/管程,操作的是 OS 线程。
  • JNI 临界区:GetPrimitiveArrayCritical 这类 JNI 函数会直接禁止 GC、锁定内存,OS 根本不知道”这是虚拟线程”。
  • 信号量、栈帧、甚至 JVM 的 safepoint 机制,很多都假设”当前执行流 = OS 线程”。

要加虚拟线程,就得让上面每一条都能”在虚拟线程上跑,但阻塞时不占 OS 线程”。 这比”从零造一个 goroutine”难一百倍——你不是造新产品,你是给一个跑了 20 年、全球无数生产系统在跑的心脏做开胸手术,而且不能让旧代码崩。这就是为什么 Loom 花了六年:光是 synchronized 的 pinning 问题(12.4 详讲)就拖了多年,因为对象头 Mark Word 的锁结构改起来牵一发动全身。

更深一层:这个差异是”语言设计时点”决定的。Go 赢在”生得晚”——它出生时多核、高并发、云原生已经是既定事实,runtime 可以针对这个事实设计;Java 输在”生得早”,它的线程模型是单核/少核时代的合理选择,只是被二十年后世界的需求放大了代价。虚拟线程不是 Java 突然变聪明了,是 Java 终于有勇气给心脏做一次大手术。你作为后学,享受的是这次手术的结果,但理解手术为什么难,才能理解后面那些”暗礁”为什么不是 bug、而是妥协。

代码锚点(感受一下虚拟线程”廉价到随便开”):

// 开 100 万个虚拟线程,传统平台线程这么开会 OOM,虚拟线程不会
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 1_000_000; i++) {
        int id = i;
        executor.submit(() -> fetchUser(id));   // 每个任务一个虚拟线程
    }
}  // try-with-resources 关闭,等所有任务结束

12.2 怎么用(语法极简)

虚拟线程的 API 刻意做得很矮——因为它要的就是”像开 goroutine 一样顺手”。三种创建方式:

// 方式一:最直白,一行开一个
Thread.startVirtualThread(() -> { handleRequest(); });
 
// 方式二:Thread.Builder,更可读,能起名、能设守护
Thread vt = Thread.ofVirtual().name("order-", 0).start(() -> handleRequest());
 
// 方式三:最常用 —— 配合 ExecutorService 提交任务(注意:这不是线程池!)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Future<User> f = executor.submit(() -> fetchUser(id));

重点说方式三那个 Executors.newVirtualThreadPerTaskExecutor()。这个名字里有 Executor,但它不是线程池。 它每来一个任务就开一个虚拟线程,来一百万个任务就开一百万个虚拟线程——开虚拟线程是纳秒级的、栈是几百字节的,所以根本不需要”池化”来复用。池化的前提永远是”创建贵”,而虚拟线程恰恰不贵。

结构化并发(Structured Concurrency)是虚拟线程的黄金搭档。Java 21 里它是预览特性(JEP 453),到 Java 24 转正(JEP 499)。核心类是 StructuredTaskScope:

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User>  u = scope.fork(() -> fetchUser(id));
    Subtask<Order> o = scope.fork(() -> fetchOrder(id));
    scope.join();                 // 等所有子任务
    scope.throwIfFailed();        // 任一个失败则抛
    return new Result(u.get(), o.get());
}  // 作用域结束:确保所有子任务都 terminated

try-with-resources 在这里是关键语义:作用域结束时,如果还有子任务在跑,会等它们结束或被取消——这保证了”没有孤儿任务泄漏”。ShutdownOnFailure 表示”任意一个子任务失败,就取消其他还在跑的”。

【思考】newVirtualThreadPerTaskExecutor() 名字里有 Executor,但它不是线程池——为什么这么设计?如果还用有限大小的池,会怎样?

参考答案

直接答案:因为虚拟线程廉价到不需要池化,池化的前提是”创建贵”;如果还用有限大小的池(比如只让 100 个虚拟线程并发),你就白白放弃了虚拟线程最大的优势——百万级并发度——这正是虚拟线程要解决的痛点。

先想”为什么线程池存在”。 第 11 章讲透了:平台线程贵(1MB 栈、syscall 建内核线程),所以你必须”复用固定数量的线程、任务排队”,用 ThreadPoolExecutor 当闸门。线程池存在的唯一原因,是”OS 线程是稀缺资源,不能随便建”。

虚拟线程把”稀缺”这件事消灭了。 创建一个虚拟线程是纳秒级、栈几百字节,建一百万个也不 OOM。那”池化”的理由就彻底消失了——你不需要复用,因为新建比复用还便宜;你不需要队列缓冲,因为每个任务立刻有自己的虚拟线程。所以 newVirtualThreadPerTaskExecutor() 的设计就是”每任务一虚拟线程”,由 JVM 调度到 carrier 上,没有队列、没有 core/max、没有拒绝策略。

如果还把它套进有限大小的池,会发生什么? 假设有人写:

// 错误思路:想"限制虚拟线程数量"
ExecutorService pool = new ThreadPoolExecutor(100, 100, 0, SECONDS,
    new ArrayBlockingQueue<>(1000),    // 100 个平台线程 + 虚拟线程排队
    ...);

这么做等于”用 100 个平台线程去承载虚拟线程”——虚拟线程还是在,但承载它们的 carrier 只有 100 个,并发度又被锁死在 100。这跟”50 线程吞吐 250 QPS”的事故是同一类:你绕了一圈,又回到了线程池的天花板。虚拟线程的价值(百万并发)被你亲手阉割了。

更深一层:这其实是心智模型转换的试金石。你带着线程池经验来,本能地想”给虚拟线程也加个池、加个限流”。但虚拟线程的并发度不该被”线程数”限制——该被”下游能承受多少 QPS”限制(12.6 详讲)。把限流从”线程数”挪到”背压/信号量包 IO”,是这一章最重要的一个转身。对比第 11 章:Executors.newFixedThreadPool 的队列/拒绝策略是救命的,因为平台线程贵;newVirtualThreadPerTaskExecutor 没有那些,因为它不需要。

Go 对照,这条老哥会秒懂。 上面那段 StructuredTaskScope 代码,和 Go 的 errgroup 几乎一一对应:

// Go:errgroup + context 并发调两个下游
g, ctx := errgroup.WithContext(ctx)
var user User
var order Order
g.Go(func() error { var err error; user, err = fetchUser(ctx, id); return err })
g.Go(func() error { var err error; order, err = fetchOrder(ctx, id); return err })
if err := g.Wait(); err != nil { return err }
return assemble(user, order)

两者连”失败取消其他”的语义都一样:errgroup.WithContext 在任意一个 goroutine 失败时会 cancel(ctx),其他 goroutine 通过 ctx.Done() 提前退出;StructuredTaskScope.ShutdownOnFailure 在任意一个子任务失败时,调用 scope.join() 会让其他子任务被取消。

但有一个本质差异你要记牢:Go 的 errgroup 是”库级”的——它基于 goroutine + WaitGroup + Context,是标准库 golang.org/x/sync/errgroup 提供的;Java 的 StructuredTaskScope 是”语言级”的——它是 JDK 内置的,而且能正确处理”作用域内的取消与异常传播”、保证作用域结束时没有孤儿任务。一个是从语言习惯里长出来的库,一个是 JVM 亲自下场补的能力。


12.3 原理:虚拟线程的调度(这是理解”暗礁”的前提)

不懂调度,你看不懂后面三个暗礁。 所以这一节必须先啃下来。

虚拟线程的调度模型长这样:JVM 内部有一个调度器,它是一个 ForkJoinPool(commonPool 的一个变体,但不是 ForkJoinPool.commonPool() 本身,它是专门为虚拟线程造的一个默认调度器),采用 work-stealing 算法。这个池里的 worker 线程就是 carrier 线程——它们是实实在在的 OS 平台线程,数量默认等于 Runtime.getRuntime().availableProcessors(),可以通过 -Djdk.virtualThreadScheduler.parallelism=N 调。

两个核心动作,记死这两个词:挂载(mount) 和 卸载(unmount)。

  • mount:虚拟线程被调度到某个 carrier 线程上,开始执行代码。此刻虚拟线程的栈被”借”到了 carrier 的栈上(其实是虚拟线程有自己独立的栈,但执行上下文跑在 carrier 上)。
  • unmount:当虚拟线程遇到阻塞(比如 LockSupport.park、网络 IO、Thread.sleep),它从 carrier 上”摘下来”——carrier 线程被释放,立刻去跑别的虚拟线程。虚拟线程的栈被存到堆里,等它解除阻塞、被重新调度时再 mount 到某个 carrier 上继续。

关键句:虚拟线程的阻塞,不会阻塞 carrier 线程。 这正是百万虚拟线程能跑的根——跟 goroutine “阻塞时让出 P”一模一样。一百万虚拟线程里,可能只有几十个在真正用 CPU(跑在几十个 carrier 上),其余几百万个都在等 IO(unmount 状态,不占任何 OS 线程)。carrier 永远在忙,永远不会”被一个阻塞的虚拟线程占着发呆”。

【思考】虚拟线程遇到 Thread.sleep(1000) 会怎样?它会占着 carrier 吗?对比传统平台线程的 sleep,差别在哪?

参考答案

直接答案:不会占着 carrier。虚拟线程里的 Thread.sleep 被 JVM 重写了——它会 unmount 自己、注册一个一秒后的定时任务,然后 carrier 立刻去跑别的虚拟线程。一秒后定时器触发,虚拟线程被重新调度、重新 mount 继续跑。对比传统平台线程 Thread.sleep:它是真睡,进入内核 TIMED_WAITING,死死占着 OS 线程一秒。

逐层拆开看。 传统平台线程调 Thread.sleep(1000):

  • JVM 调 Thread.sleep0 → 陷入内核 nanosleep → 线程进入 TIMED_WAITING 状态。
  • 这一秒内,这个 OS 线程啥也不干,就挂在内核的等待队列里,内核调度器也不会把它当”可运行”——一个 OS 线程被白白占用一秒。
  • 如果这是一个 50 线程的池,50 个请求都 sleep,那这一秒里池子 0 个工作线程可用,吞吐直接归零。

虚拟线程调 Thread.sleep(1000),路径完全不同:

  • JVM 检测到当前是虚拟线程,不把它交给内核 sleep,而是:记录”这个虚拟线程一秒后该恢复”,然后 unmount 它(从 carrier 摘下,栈存堆,状态变为 TIMED_WAITING)。
  • carrier 线程立刻 freed,去跑调度器队列里下一个可运行的虚拟线程。
  • 一秒后,JVM 的定时器(底层基于某个 Delayed 机制)把这个虚拟线程标记为可运行,调度器在某个 carrier 上重新 mount 它,从 sleep 之后的代码继续。

一个对比表钉死:

平台线程 Thread.sleep(1s)虚拟线程 Thread.sleep(1s)
占用 OS 线程?占一整秒不占(unmount)
内核态?陷入内核 nanosleep不陷入,JVM 层定时
这一秒 carrier 能跑别人吗?不能能
对吞吐的影响一秒少一个 worker零影响

更深一层:这恰好是”把阻塞从 OS 层面降级到 JVM 层面”最直观的例子。Thread.sleep 在虚拟线程里已经不是”OS 级别的阻塞”了,它变成了”JVM 里的一个定时状态切换”。所以第 00 章那个伏笔(goroutine 阻塞被 runtime 吞掉)在 Java 这边是真的兑现了——只不过 Java 用了六年才把这件事做对。注意:网络 IO、文件 IO、LockSupport.park、虚拟线程版的 sleep,都是这条路径;但 12.4 你会看到,有两类阻塞走不了这条路径。

// 感受:虚拟线程里 sleep 不占 carrier
Thread.startVirtualThread(() -> {
    Thread.sleep(1000);   // 这里 unmount,carrier 跑别的去了
    System.out.println("一秒后继续");
});

12.4 暗礁一:pinning(被钉住)

这是第一个、也是最隐蔽的线上事故源。虚拟线程在两种情况下无法 unmount,会被”钉”(pin)在 carrier 上,直到阻塞结束。 carrier 被霸占,其他虚拟线程就排不上这个 carrier——并发优势荡然无存。

两种情况:

  1. synchronized 块/方法内部发生了阻塞(数据库查询、HTTP 调用、Thread.sleep 等)。JVM 的监视器锁(monitor)目前还没为虚拟线程重写成”可卸载”,所以在 synchronized 内部,虚拟线程就是”钉死”在 carrier 上的。
  2. native 方法 / JNI 临界区内部发生了阻塞。OS 根本不知道这是个虚拟线程,它看到的就是一个 OS 线程在跑 C 代码,没法把它 unmount。

后果有多严重,你品一下:假设你的服务配了 8 个 carrier 线程(默认等于 CPU 核数)。如果有 8 个虚拟线程同时卡在”持有 synchronized 锁做慢 SQL”上,那么 8 个 carrier 全被钉死——剩下的百万虚拟线程一个都跑不了。此时吞吐比线程池还糟:线程池好歹有 50 个平台线程轮转,你这儿 8 个 carrier 全废了,其他虚拟线程饿死。

修复极其简单,但极关键:把 synchronized 换成 ReentrantLock。 ReentrantLock 的 lock()/unlock() 底层走的是 LockSupport.park/unpark,这条路 JVM 专门为虚拟线程做了 unmount 支持。所以在 ReentrantLock 保护的临界区里发生阻塞,虚拟线程能正常 unmount,carrier 让出去。

// 错误:synchronized 内做 IO,会 pin 住 carrier
synchronized (lock) {
    User u = db.query(id);   // 慢 SQL → carrier 被钉死
}
 
// 正确:ReentrantLock 内做 IO,能正常 unmount
lock.lock();
try {
    User u = db.query(id);   // 阻塞时虚拟线程卸载,carrier 去跑别人
} finally {
    lock.unlock();
}

【思考】为什么恰恰是 synchronized 会 pin,而 ReentrantLock 不会?底层机制差在哪?

参考答案

直接答案:synchronized 是 JVM 内建的监视器,锁状态存在对象头的 Mark Word,跟 OS 线程的绑定是”硬”的,JVM 在 pin 场景下无法安全地把虚拟线程从 carrier 摘下来;ReentrantLock 基于 AQS,用的是 LockSupport.park/unpark,而这条路 JVM 专门为虚拟线程做了 unmount 支持。这是 Loom 项目的技术债,官方承诺未来会解决 synchronized 的 pin 问题。

为什么 synchronized 难解耦。 回到第 10 章:一个对象加锁,锁状态写在对象头的 Mark Word 里(偏向锁 ID、轻量级锁指针、重量级锁的 monitor 引用)。重量级锁的 monitor 是 OS 层面的互斥量,Object.wait/notify 也绑在 OS 线程上。当虚拟线程进入 synchronized 时,JVM 必须让”这个 OS 线程(carrier)“去真正抢这把 OS 锁——因为 Mark Word 里记的锁拥有者是 OS 线程 ID。一旦虚拟线程在 synchronized 里要阻塞,JVM 面临两难:

  • 如果它把虚拟线程 unmount(从 carrier 摘下),那”谁持有这把 monitor”就断了——monitor 记的是 carrier 的 OS 线程 ID,但真正持有锁的是虚拟线程,carrier 现在去跑别人了,monitor 状态会乱套。
  • 所以 JVM 的选择是:pin——虚拟线程老老实实占着 carrier,直到 synchronized 退出、阻塞结束。这是”为了不破坏 28 年积累的监视器语义”做的妥协。

为什么 ReentrantLock 没事。 ReentrantLock 底层是 AQS(第 10 章讲过),它的 lock() 拿不到锁时,最终调 LockSupport.park()。LockSupport.park 是 JVM 内部原语,Loom 团队专门在它上面加了”虚拟线程感知”:当虚拟线程在 park() 上阻塞,JVM 知道”这是可以 unmount 的”,于是把虚拟线程摘下、存好状态、释放 carrier。唤醒时(unpark)再重新调度 mount。AQS 的整个”CAS 抢 + 抢不到 park”路径对虚拟线程是透明的、可卸载的。

官方态度:synchronized 的 pinning 是已知限制。JEP 444 的文档明确写了”在 synchronized 内或 native 方法内运行的虚拟线程可能被 pin”。Loom 团队多次表示未来会消除 synchronized 的 pin(让监视器也能被虚拟线程卸载),但 Java 21/22 还没做到——因为改 Mark Word 的锁结构牵涉面太大(12.1 讲过这段历史债)。

更深一层:这条差异是”虚拟线程 vs goroutine 最本质区别之一”的缩影。Go 没有 synchronized 这种”锁绑定 OS 线程”的东西——Go 的 sync.Mutex 是 runtime 管理的,阻塞时 goroutine 让出 P,天经地义。Java 因为历史包袱,synchronized 成了暗礁。所以迁移到虚拟线程时,第一件事就是全局搜 synchronized 块里有没有 IO,有的话一律换成 ReentrantLock。这个改动小、收益大,是本章最值钱的 actionable 建议。

// 诊断 pinning:开发期加 JVM 参数,会把 pin 的堆栈打印出来
// -Djdk.tracePinnedThreads=full

检测 pinning:开发期给 JVM 加 -Djdk.tracePinnedThreads=full,一旦虚拟线程在 pinning 状态下阻塞,会打印一段堆栈告诉你”卡在哪个 synchronized/native 里了”。生产环境别开——每次 pin 都打印,性能和日志量都扛不住。

真实事故:一个服务上了虚拟线程,本以为吞吐能飞。但有个 synchronized 块里调了慢 SQL(一个 join 偶尔 2 秒)。流量一上来,8 个 carrier 被 8 个虚拟线程轮流钉死,吞吐不但没涨,反而比原来的 50 线程池还差。排查靠 jstack,看到大量 VirtualThread 实际占着 carrier 在跑 SQL(jstack 在 Java 21+ 能直接看到虚拟线程栈)。把那个 synchronized 改成 ReentrantLock,吞吐立刻翻倍。


12.5 暗礁二:ThreadLocal 的语义变了

第二暗礁,更隐蔽,因为它不报错、只是”行为不对”。

虚拟线程是海量的、短命的(可能一个 HTTP 请求一个虚拟线程,请求结束线程就没了)。如果你用 ThreadLocal 存”每个请求的状态”,在虚拟线程下:这个状态会被几十万个并发虚拟线程同时持有——内存爆炸;而且虚拟线程可能被复用、被 mount 到不同 carrier,ThreadLocal 假设的”线程级隔离”被打乱。

最经典的坑:SimpleDateFormat。 它非线程安全,老 Java 代码常见写法是放进 ThreadLocal 避免每次 new:

// 线程池时代:50 个平台线程,50 个 SimpleDateFormat 副本,OK
private static final ThreadLocal<SimpleDateFormat> FMT =
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
 
// 虚拟线程时代:每个虚拟线程都有自己的副本 → 几十万个 SimpleDateFormat → 内存和创建开销爆炸

虚拟线程下,每个虚拟线程都 withInitial 出一个 SimpleDateFormat,百万虚拟线程 = 百万 SimpleDateFormat 对象。正确的做法是用 java.time.format.DateTimeFormatter——不可变、线程安全,根本不需要 ThreadLocal:

// 全局一个,所有虚拟线程共享,线程安全
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");

更深的坑:InheritableThreadLocal(父子线程传递)。传统的平台线程,父线程建子线程时,子线程继承父的 InheritableThreadLocal。但虚拟线程的父子关系不像 OS 线程那么稳定——虚拟线程可能被 mount 到不同 carrier,继承语义会乱。替换方案是 ScopedValue(Java 21 预览):它做”作用域内值传递”,语义清晰、不可变、不绑定具体线程。

// ScopedValue:在作用域内绑定一个值,作用域内所有虚拟线程都能读,且不污染别的请求
private static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
 
ScopedValue.where(CURRENT_USER, user).run(() -> {
    handleRequest();   // 作用域内任何地方 CURRENT_USER.get() 都能拿到 user
});
// 作用域结束,值自动"消失",不会泄漏到下一个请求

【思考】第 08 章讲的可观测性(MDC + ThreadLocal 传 traceId),在虚拟线程下还成立吗?

参考答案

直接答案:会丢。MDC 基于 ThreadLocal,而虚拟线程不继承 carrier 的 ThreadLocal,且一个虚拟线程可能在不同时刻 mount 到不同 carrier。所以你在入口设置的 traceId,在虚拟线程里跑着跑着就读不到了。解决:用 ScopedValue 做作用域内传递,或换 OpenTelemetry 的 Context(它自己处理了虚拟线程的传播)。这是迁移到虚拟线程时一个隐蔽但关键的改造点。

为什么 MDC 会丢。 SLF4J 的 MDC(Mapped Diagnostic Context)本质是一个 ThreadLocal<Map>——你在入口线程 MDC.put("traceId", id),日志框架从当前线程的 ThreadLocal 里取。问题来了:

  • 虚拟线程启动时,不会从 carrier 那里继承 ThreadLocal(设计上为了避免海量虚拟线程拷贝海量 ThreadLocal 的代价,也为了避免语义混乱)。
  • 虚拟线程执行中,可能被 unmount、再 mount 到另一个 carrier 上。即使 carrier 自己有 MDC,那也不是你设的那份。
  • 于是 MDC.get("traceId") 在虚拟线程里大概率返回 null——日志里 traceId 全空,链路追踪直接废了。

这跟”虚拟线程海量短命”是同一个根。 平台线程时代,一个请求跑在一个平台线程上,ThreadLocal 隔离天然成立;虚拟线程时代,“一个请求一个虚拟线程”但虚拟线程在 carrier 间漂移,ThreadLocal 的”线程=请求”假设破了。

两个修法:

  1. ScopedValue(推荐,Java 21 预览、需 --enable-preview):把 traceId 放进 ScopedValue,在请求作用域入口 ScopedValue.where(TRACE_ID, id).run(...) 绑定,作用域内所有代码(含里面 fork 的子任务)都能读,作用域结束自动清除——语义比 ThreadLocal 干净,不泄漏,不绑定具体线程。

  2. OpenTelemetry 的 Context:OTel 的 Context 传播机制(Context.current() + io.opentelemetry.context 的 StrictContext/Scope)已经考虑了虚拟线程,它在 unmount/mount 时会正确携带 Context。如果你的项目用 OTel 做可观测性,这通常是最省心的路。

更深一层:这条暗礁揭示的是”虚拟线程要求你重新审视一切基于 ThreadLocal 的设计”。不只 MDC,还有 Spring Security 的 SecurityContextHolder(默认 ThreadLocal 策略 MODE_THREADLOCAL)、事务上下文传递、很多框架的”当前用户/当前租户”都建在 ThreadLocal 上。迁移虚拟线程前,必须逐个确认这些——Spring 6 / Spring Boot 3 已经把 SecurityContextHolder 等改成了对虚拟线程友好的传递方式,但老框架、自研的 ThreadLocal 逻辑得你自己改。呼应第 08 章:可观测性不是配个 MDC 就完事,当执行模型从”平台线程”变”虚拟线程”,传递机制必须跟着换。


12.6 暗礁三:池化虚拟线程 + 信号量限流的误区

第三个暗礁,是”你带着线程池经验本能地犯错”。

反模式一:new ThreadPoolExecutor(...) 里跑虚拟线程。错!虚拟线程不该进池——把虚拟线程塞进固定大小的平台线程池,等于用有限 carrier 限制并发度,亲手废掉虚拟线程(12.2 讲过)。

更隐蔽的反模式二:为了”限制并发”,用 Semaphore 包住虚拟线程的创建。

Semaphore sem = new Semaphore(100);
for (var task : tasks) {
    sem.acquire();
    Thread.startVirtualThread(() -> {     // 错误:用信号量把虚拟线程限到 100
        try { task.run(); }
        finally { sem.release(); }
    });
}

你想”限制同时只有 100 个虚拟线程”。但虚拟线程本来就能轻松开百万个,你用信号量把它限到 100,等于主动放弃了虚拟线程的优势,还不如用线程池——线程池也是 100 个并发,还省了百万虚拟线程的开销。

正确的认知:虚拟线程的并发度,不该用池/信号量限制,而应该用”背压”限制。 限制点在”下游能承受多少 QPS”,不在”开多少个虚拟线程”。

// 正确:虚拟线程随便开,限流放在"下游依赖"上
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (var task : tasks) {
        executor.submit(() -> task.run());   // 一百万个虚拟线程,随便开
    }
}
// 真正要限流的是下游:比如数据库连接池只有 20 个连接,
// 那同时能跑的虚拟线程超过 20 个也没用——它们在获取连接时阻塞,
// 但 block 不占 carrier,其他虚拟线程继续跑

【思考】那虚拟线程就完全不需要并发限制了吗?如果不需要,百万请求同时打过来不会把下游打挂?

参考答案

直接答案:虚拟线程本身不需要”限制开多少个”的限制,但”下游依赖”必须限制。需要限流的是”下游能承受多少”,不是”线程数”。虚拟线程随便开,但用信号量包住 IO 调用(而非包住线程创建),或者在连接池层面限流——这才是正确位置。

先理清概念。 “开一百万个虚拟线程”本身几乎零成本(纳秒级、几百字节)。所以”限制虚拟线程数量”是伪需求——你限制它干啥?它又不占资源。真正占资源的是虚拟线程里面做的那些事:调数据库、调下游 HTTP、读写磁盘。这些下游有硬上限。

举个具体例子。 你调一个数据库,连接池(比如 HikariCP)只有 20 个连接。那:

  • 你开一百万个虚拟线程,每个都去 db.query()。
  • 前 20 个拿到连接,真在查;剩下 999980 个在 HikariCP.getConnection() 上阻塞。
  • 关键点:这个阻塞是 LockSupport.park 类的(HikariCP 底层是可卸载的),所以 999980 个虚拟线程 unmount,不占任何 carrier——carrier 线程去干别的了(比如处理新请求、跑 CPU 任务)。
  • 等一个连接释放,调度器把一个阻塞的虚拟线程唤醒、mount 到某个 carrier,继续查。

看明白了吗?你不需要限制”开多少个虚拟线程”,连接池自然成了限流闸门——多出来的虚拟线程在等连接时便宜地挂着,不浪费任何 OS 资源。若你错误地用信号量把虚拟线程限到 100,那反而可能”连接池有 20 个空闲,你却只允许 100 个虚拟线程在跑”,并发度被你自己的信号量卡死,吞吐上不去。

正确的限流位置:

  1. 把信号量包在 IO 调用上,而不是包在虚拟线程创建上:
Semaphore dbSem = new Semaphore(20);   // 信号量 = 连接池大小,包住 IO
Thread.startVirtualThread(() -> {
    dbSem.acquire();
    try { db.query(); }          // 限制的是"同时发几个 DB 请求"
    finally { dbSem.release(); }
});
  1. 更常见的是直接用连接池当限流(HikariCP 的 maxPoolSize 就是闸门),不用自己写信号量。

  2. 上游限流(网关、Sentinel、令牌桶)控制”整体 QPS”,这是业务层的事,跟线程模型无关。

更深一层:这条暗礁的本质是”限流该发生在哪一层”。线程池时代,限流发生在”线程数”(core/max + 队列)——因为这是稀缺资源。虚拟线程时代,线程不稀缺了,限流必须下沉到”下游依赖”(连接池、信号量包 IO、上游 QPS)。你从”管线程”转身到”管依赖”,是这一章最难但最关键的一个心智切换。对照 Go:你在 Go 里也是用 semaphore.Weighted 或带缓冲 channel 限制”并发发几个 DB 请求”,而不是限制”开几个 goroutine”——Go 早就教你这个了,虚拟线程只是让 Java 终于能这么写。


12.7 goroutine vs 虚拟线程:逐条对照(本章的落点)

这是老哥最想要的一张表——把两者摆在一起,哪里像、哪里致命地不一样,一眼看清:

维度goroutine虚拟线程
调度Go runtime(M:N)JVM(M:N,ForkJoinPool 调度器)
栈2KB,按需增长几百字节,按需增长
阻塞 IO让出 P,runtime 接管unmount,释放 carrier
阻塞在锁内占着 P(但 Go 的锁是 runtime 管理的,通常不长期持有)synchronized 内阻塞会 pin(雷)
取消context.Context(显式、约定俗成)StructuredTaskScope 取消(语言级)
局部变量传递context.Context 显式传ScopedValue(替代 ThreadLocal)
数量上限百万级无压力百万级无压力
调试runtime.Stack / pprofjstack 能看到虚拟线程栈
最大差异自诞生就是用户态调度要避开 synchronized pin / ThreadLocal 语义变化

【思考】既然这么像,为什么 Go 程序员上手虚拟线程还会翻车?

参考答案

直接答案:因为心智模型没切换。你还按”线程贵、要池化、限制数量”的老习惯写代码(信号量/线程池),把虚拟线程当传统线程用,反而更糟;或者你没改 synchronized,踩 pinning。虚拟线程要求你”像写同步代码一样写并发,但别在锁里做 IO”。

翻车的三种典型姿势,对号入座:

  1. 把虚拟线程塞进线程池 / 用信号量限线程数(12.6 的暗礁三)。你本能地想”并发不能无限”,于是给虚拟线程加了池、加了信号量——结果并发度被锁死,虚拟线程的百万优势消失,代码还比线程池更复杂。

  2. 没改 synchronized,在锁里做 IO(12.4 的暗礁一)。Go 没有 synchronized 这种”锁绑定 OS 线程”的坑,你从 Go 转来根本想不到”锁里阻塞会 pin 住 carrier”。结果 carrier 被钉死,吞吐比线程池还差,还不知道为什么。

  3. 依赖 ThreadLocal 传状态(12.5 的暗礁二)。Go 没有 ThreadLocal,你用 context.Context 传 traceId/用户,天经地义。Java 老代码用 MDC/ThreadLocal,搬到虚拟线程下全丢。你如果没意识到这点,链路追踪直接废。

根因一句话:虚拟线程是”在旧 JVM 上长出的新范式”,它必须兼容 28 年的旧代码,这个兼容性包袱让它的暗礁比 goroutine 多。 goroutine 没有历史债,runtime 从零设计,所以”阻塞就让出 P”是铁律、无例外;虚拟线程要兼容 synchronized、JNI、ThreadLocal 这些”假设 OS 线程”的老机制,于是有了 pinning、ThreadLocal 语义变化这些妥协。

更深一层:Go 程序员上手虚拟线程,最大的陷阱是”以为它跟 goroutine 一样就安全了”。它像 goroutine 的地方(廉价、海量、阻塞不占 OS 线程)是真的,但不像的地方(三个暗礁)恰恰是你按 Go 经验绝对想不到的。所以这一章不是教你”怎么用虚拟线程”,是教你”哪三个 Go 直觉在这里会害死你”。记住:虚拟线程 = 像写同步代码一样写并发,但(1)锁里别做 IO,用 ReentrantLock;(2)别用 ThreadLocal 传跨请求状态,用 ScopedValue;(3)别用池/信号量限制线程数,用背压限制下游。


12.8 实战:把第 11 章的线程池代码迁移到虚拟线程

给一个真实场景:一个接口要并发调 3 个下游(用户、订单、积分),并对 DB 访问限流。先看第 11 章的线程池写法,再看虚拟线程写法,逐步标注改造点。

第一步:线程池写法(回顾)。

ExecutorService pool = new ThreadPoolExecutor(
    50, 200, 60L, TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(1000),
    namedFactory,
    new ThreadPoolExecutor.CallerRunsPolicy());
 
public Result handle(long id) throws Exception {
    Future<User>  f1 = pool.submit(() -> fetchUser(id));   // 平台线程执行
    Future<Order> f2 = pool.submit(() -> fetchOrder(id));
    Future<Score> f3 = pool.submit(() -> fetchScore(id));
    return assemble(f1.get(), f2.get(), f3.get());         // 阻塞等三个结果
}

痛点:线程数有限(并发度卡在 50~200),下游慢时吞吐被锁死;get() 阻塞占着平台线程;commonPool 那种坑得自己规避。

第二步:虚拟线程写法(迁移后)。

private final ReentrantLock userLock = new ReentrantLock();   // ① 替换 synchronized
private static final ScopedValue<Long> REQ_ID = ScopedValue.newInstance();  // ② 替换 ThreadLocal
 
// ③ 用虚拟线程 Executor,不是池
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // ④ 限流在"下游依赖"上:信号量包住 DB 调用,不包虚拟线程创建
    Semaphore dbSem = new Semaphore(20);
 
    ScopedValue.where(REQ_ID, id).run(() -> {            // ② 作用域内传 traceId
        Future<User>  f1 = executor.submit(() -> fetchUser(id, dbSem));
        Future<Order> f2 = executor.submit(() -> fetchOrder(id, dbSem));
        Future<Score> f3 = executor.submit(() -> fetchScore(id, dbSem));
        Result r = assemble(f1.get(), f2.get(), f3.get());  // 阻塞自动让出 carrier
        return r;
    });
}
 
User fetchUser(long id, Semaphore dbSem) throws Exception {
    dbSem.acquire();                 // ④ 限流在 IO 调用上
    try {
        userLock.lock();             // ① ReentrantLock 不 pin
        try { return db.query(id); } // 阻塞时虚拟线程 unmount,carrier 去跑别的
        finally { userLock.unlock(); }
    } finally { dbSem.release(); }
}

逐步标注四个改造点:

  • ① 换 Executor:new ThreadPoolExecutor(...) → Executors.newVirtualThreadPerTaskExecutor(),删掉所有池参数(core/max/队列/拒绝策略都不需要了)。
  • ② synchronized → ReentrantLock:凡是锁里可能做 IO 的,全换。本例 userLock 在 fetch 里包了 DB 查询,必须换。
  • ③ ThreadLocal → ScopedValue:原来 MDC.put("reqId", ...) 或 ThreadLocal 传请求 ID,换成 ScopedValue.where(REQ_ID, id).run(...),作用域内可读,不泄漏、不绑定线程。
  • ④ 限流从”线程数”移到”连接池/信号量包 IO”:信号量 Semaphore(20) 包在 db.query() 上,表示”同时最多 20 个 DB 请求”,而不是限制”开多少虚拟线程”。

性能对比(量级,给个直观感受):

  • 线程池 50 线程、下游平均 100ms:吞吐上限 50 / 0.1 = 500 QPS(再高就排队/CallerRuns 限流)。
  • 虚拟线程:并发度不再被线程数卡死。假设下游 DB 连接池 20、单请求 100ms,那 DB 层吞吐 20 / 0.1 = 200 QPS 是硬上限——但虚拟线程的开销不再叠加在这上面,瓶颈纯粹是下游。如果下游能扩到 200 连接,吞吐直接到 2000 QPS,虚拟线程这边零改造成本。关键区别:线程池的吞吐被”线程数”和”下游”双重卡死;虚拟线程的吞吐只被”下游”卡死,线程层不再是瓶颈。

12.9 本章核心结论

  1. 虚拟线程是 JVM 版的 goroutine:M:N 用户态调度,N 个虚拟线程跑在 M 个 carrier 上,阻塞时 unmount 释放 carrier。 它兑现了第 00 章的伏笔——Java 终于能”像 Go 一样把阻塞交给 runtime 吞掉”。
  2. 虚拟线程便宜到不需要池化。 Executors.newVirtualThreadPerTaskExecutor() 每任务开一虚拟线程,没有 core/max/队列/拒绝策略;把它套进线程池等于废掉优势。
  3. 暗礁一 pinning:synchronized 块内阻塞、native 方法内阻塞会钉死 carrier。 修复:把 synchronized 换成 ReentrantLock(走 LockSupport.park,可卸载)。用 -Djdk.tracePinnedThreads=full 开发期检测。
  4. 暗礁二 ThreadLocal 语义变了:虚拟线程不继承 carrier 的 ThreadLocal,且短命海量。 用 ScopedValue(Java 21 预览)替代 ThreadLocal 传作用域值;MDC/Spring Security 的 ThreadLocal 机制在虚拟线程下会丢,需改用 ScopedValue 或 OpenTelemetry Context。
  5. 暗礁三别用池/信号量限制虚拟线程数量:限流点在”下游依赖”(连接池、信号量包 IO、上游 QPS),不在”线程数”。 百万虚拟线程随便开,它们在等连接时便宜地挂着,不占任何 OS 资源。
  6. 结构化并发 StructuredTaskScope(Java 21 预览、Java 24 转正)对标 Go 的 errgroup:失败取消其他、作用域结束无孤儿任务。 但 errgroup 是库级、context 驱动;StructuredTaskScope 是语言级、JDK 内置。
  7. 迁移虚拟线程的三个必改点:synchronized→ReentrantLock、ThreadLocal→ScopedValue、限流从线程数→下游依赖。 纯 IO 密集型新模块最该先用,收益最大、改造最小。

12.10 深度思考题

题 1:虚拟线程和 goroutine 最本质的三个差异是什么?

参考答案

直接答案:① synchronized pinning(Go 没有等价物,因为 Go 的锁是 runtime 管理的,不会 pin OS 资源);② ThreadLocal 语义(Go 没有 ThreadLocal,用 context 传递,天然适配海量 goroutine);③ 取消模型(Go 的 context 是显式的、约定俗成的;虚拟线程用 StructuredTaskScope 语言级)。核心:虚拟线程是”在旧 JVM 上长出的新范式”,要兼容无数旧代码,这个兼容性包袱让它的暗礁比 goroutine 多。

逐条展开:

  1. synchronized pinning。 这是 goroutine 完全没有的概念。Go 的 sync.Mutex 是 runtime 的,goroutine 在锁上阻塞时让出 P,天经地义。Java 的 synchronized 绑死了 OS 线程(对象头 Mark Word + 监视器),虚拟线程在它里面阻塞无法 unmount,于是 pin。goroutine 程序员转 Java 根本想不到”锁里阻塞会占死载体线程”,这是第一暗礁。

  2. ThreadLocal 语义。 Go 没有 ThreadLocal 这种东西,goroutine 之间传状态靠 context.Context 显式传递,天然适配”海量短命执行流”。Java 的 ThreadLocal 假设”一个线程 = 一个长期存在的执行流”,虚拟线程海量短命 + 在 carrier 间漂移,这个假设破了,于是 MDC、SecurityContext 全丢,要换 ScopedValue。

  3. 取消模型。 Go 的 context.Context 是社区的、约定俗成的——你不传 ctx、不查 ctx.Done(),没人强制你取消。虚拟线程的 StructuredTaskScope 是 JDK 语言级的,作用域结束保证无孤儿任务、失败自动取消其他,强制性和安全性更强。

更深一层:这三个差异的共同根是”历史包袱”。goroutine 没有 28 年的旧代码要兼容,runtime 从零设计,所以无 pin、无 ThreadLocal 之困、取消是轻量的约定。虚拟线程必须在”不破坏旧 JVM 语义”的前提下长出新范式,于是每一处都做了妥协。理解这点,你就不把暗礁当 bug,而当成”偿还技术债的代价”。这也回答了第 10/11 章反复提的那个问题:为什么 Java 并发生态这么”麻烦”——因为每一层都背着兼容旧世界的重量。

题 2:你的老项目(Java 8 + 传统线程池)值得升级到 Java 21 用虚拟线程吗?

参考答案

直接答案:给决策框架——升级成本(JDK 版本、依赖兼容)vs 收益(吞吐、代码简化);明确哪些代码要改(synchronized→ReentrantLock、ThreadLocal→ScopedValue、删掉无谓的线程池);建议先在”高并发 IO 密集型”的新模块试水,别一上来全量迁移。

升级成本面:

  • JDK 版本:从 Java 8 跳到 21,跨度极大。很多老库(尤其那些依赖内部 API sun.misc.Unsafe、或绑定特定 JDK 版本的)可能不兼容。要过一遍依赖的兼容性(Spring Boot 3 要求 Java 17+,所以框架也得跟着升)。
  • 依赖兼容:字节码层面,Java 8 编译的 jar 在 21 上一般能跑(字节码向后兼容),但如果你用了 --release 之外的内部 API,得排查。
  • 代码改造:光”换 Executor”不够,还要搜出所有”synchronized 内做 IO”和 ThreadLocal 传状态的地方改掉——否则虚拟线程的暗礁一、暗礁二会咬你。

收益面:

  • 吞吐:IO 密集型服务(大量并发调下游、DB),虚拟线程能让吞吐轻松上一个量级,且代码从”线程池 + Future 回调”变回”直白顺序代码”,可读性暴涨。
  • 资源:不再需要为”并发度”配几十上百个平台线程,内存和上下文切换开销下降。

决策框架(实用):

  • 如果你的服务是 IO 密集型、QPS 高、下游有扩展空间 → 升级收益最大,优先在这类服务试水。
  • 如果是 CPU 密集型(加解密、压缩、计算)→ 虚拟线程帮助不大,因为瓶颈在 CPU 不在线程数,不值得为它大动干戈。
  • 如果是 老掉牙、基本不迭代的内部系统 → 升级成本 > 收益,别碰。

更稳妥的路线:不要”全量迁移”,先挑一个高并发 IO 新模块,单独用 Java 21 + 虚拟线程写,跑稳了再考虑辐射其他。老模块的线程池代码继续跑,虚拟线程和平台线程在 JVM 里能共存(虚拟线程最终也跑在 carrier 平台线程上),所以可以渐进式引入。

更深一层:升级到虚拟线程不是”换个 API”,是”换个并发心智”。最贵的不是改代码,是团队要理解三个暗礁。建议把本章的 12.4/12.5/12.6 当迁移前的必读 checklist,逐条过老代码。

题 3:pinning 为什么是”临时问题”而不是”永久问题”?

参考答案

直接答案:官方明确在后续版本解决 synchronized 的 unmount 支持;技术上难,但不是不可能——难在对象头 Mark Word 的锁结构牵一发动全身,改了要保住 28 年积累的监视器语义(wait/notify、偏向/轻量/重量级升级、可重入)。这是”偿还 1995 年设计债”的工程代价,不是死穴。

为什么它能被解决。 pinning 的根因是”synchronized 的锁状态绑定 OS 线程 ID,虚拟线程 unmount 会切断这个绑定”。要解,就得让监视器能”记住虚拟线程而不是 carrier”——即把锁拥有者从”OS 线程”抽象成”当前执行的虚拟线程(或其载体)“。Loom 团队的设计方向是:在虚拟线程进入 synchronized 且要阻塞时,能安全地把它从 carrier 摘下,并让监视器正确记录”持锁的是这个虚拟线程”。

为什么现在还没解决(难度在哪)。 对象头的 Mark Word 是 JVM 最底层、最高频访问的结构之一。synchronized 的偏向锁/轻量级锁/重量级锁三级升级、重入计数、Object.wait/notify 的 WaitSet、甚至 GC 的标脏,全都读 Mark Word。要支持虚拟线程卸载,等于在这些最高频路径上加”虚拟线程感知”——任何一点改动都可能影响所有 Java 程序的性能,必须极度谨慎。这也是为什么 Java 21 宁可先让它”pin”(行为正确、只是不优),也不急着改——正确性优先于性能最优。

对照 native 方法的 pin。 synchronized 的 pin 官方承诺会消除;但 native 方法 / JNI 临界区内的 pin,短期内无解——因为那是 C 代码在 OS 线程上跑,JVM 没法把它 unmount(OS 不知道这是虚拟线程)。所以即便将来 synchronized 不 pin 了,写 JNI 时仍要注意:别在 native 里长时间阻塞,否则照样 pin。

更深一层:pinning 是”虚拟线程要在不破坏旧世界的前提下长出新能力”的缩影。它告诉我们一个事实:任何在旧地基上加盖的新范式,都会先以”妥协”的形式出现,再逐步打磨。goroutine 没有这步,因为没旧地基;虚拟线程有,所以先用 pinning 换”全兼容”,再用版本迭代换”全性能”。作为使用者,你应该假设”未来 synchronized 内阻塞也能 unmount”,但今天写代码仍要按”它会 pin”来避坑——这是工程里”面向未来设计、基于现状编码”的典型。

题 4(开放题,无标准答案):虚拟线程会让 CompletableFuture 消亡吗?

参考答案

直接答案:部分场景会。简单编排(“并发发几个 IO 请求、最后汇总”)用虚拟线程 + 顺序代码更直白,CompletableFuture 的回调链会退居次要;但 CompletableFuture 的细粒度组合(thenCombine/anyOf)、超时(orTimeout)、取消(cancel)、与老平台线程代码交互,仍有不可替代的价值。长期看,虚拟线程是”结构化并发”的底座,CompletableFuture 退居工具位,而非消亡。

为什么虚拟线程会抢走一部分 CompletableFuture 的场景。 第 11 章讲过,CompletableFuture 主要解决”异步结果的组合”。但很多人的真实需求只是”别让阻塞 IO 占着 OS 线程”——把 executor 换成 newVirtualThreadPerTaskExecutor(),原来的同步阻塞代码(user = fetchUser(id); order = fetchOrder(id);)直接顺序写,JVM 在阻塞时自动让出 carrier。代码从”回调链”变回”直白顺序代码”,可读性暴涨,CompletableFuture 在这类场景就被绕过了。

为什么 CompletableFuture 不会消亡。 它有虚拟线程没有的精细控制:

  1. 细粒度组合:thenCombine(两个结果合并)、anyOf(等最快)、thenCompose(拍平嵌套)——虚拟线程没有一等公民语法表达这些,你得自己用 Future 拼,反而啰嗦。
  2. 超时/取消:orTimeout、completeOnTimeout、cancel(true) 是现成的;虚拟线程取消要靠 Thread.interrupt() + 业务检查 isInterrupted(),更手工。
  3. 与旧代码互操作:大量老库/框架跑在平台线程上、返回 CompletableFuture,你喂给它或消费它都比虚拟线程自然。
  4. 纯 CPU 流水线:对内存计算结果做链式转换,CompletableFuture 的 CompletionStage 很顺手,虚拟线程在这没优势。

更深一层:两者不是替代关系,是”不同抽象层”。虚拟线程解决”阻塞不浪费 OS 线程”,CompletableFuture 解决”异步结果的组合”。最理想的组合其实是:用虚拟线程承载并发执行流,用 StructuredTaskScope 做结构化编排——它比 CompletableFuture 更现代、更安全(语言级取消、无孤儿任务)。所以真正的趋势是:虚拟线程 + StructuredTaskScope 一起,把 CompletableFuture 挤到一个”需要极致组合控制”的角落。但 Java 8/11 老项目没虚拟线程,CompletableFuture 仍是主力——所以它至少还会活很多年。你作为 Go 程序员,可以把虚拟线程+StructuredTaskScope 当成”Java 版 errgroup”,把 CompletableFuture 当成”偶尔才需要的精细工具”。


下一章预告

第 13 章《JVM 性能工程》:GC 调优、内存泄漏、火焰图、JMH。本章你享受了虚拟线程带来的”廉价并发”,但并发只是性能的一面——另一面是 JVM 怎么管内存、怎么回收、怎么告诉你”慢在哪”。第 13 章给你一套推理框架:看到 GC 日志能推出该调什么,看到内存涨能定位泄漏,用 Arthas/async-profiler 抓到真凶,用 JMH 写出不被 JIT 忽悠的微基准。虚拟线程让你”敢开百万并发”,第 13 章让你”开完之后知道它到底慢不慢、慢在哪”。