《Java 并发编程·线程与内存模型》15.3 从 ExecutorService 迁移的边界

把同一段三下游扇出分别用虚拟线程池和 StructuredTaskScope 实现:代码行数几乎相同、token 反而多 8 个,失败到返回却从 828ms 降到 140ms。本节给出迁移的四条硬边界——不可跨作用域、异常要解包、虚拟线程不是重点、preview 风险,并附真实报错。

承接的问题

15.1 讲作用域的形状,15.2 讲 Joiner 的语义。剩下最现实的问题:线上跑着的 ExecutorService 代码要不要改?怎么改?

先把预期摆正。很多资料把迁移说成「代码更短、更清晰」,本机实测并不支持这个说法:同一段三下游扇出,用 StructuredTaskScope 写出来非空行只少 6 行,token 反而多 8 个,因为泛型签名 Stream<StructuredTaskScope.Subtask<String>> 很占 token。真正的差别在行为:同样的失败,一个 828ms 才返回且副作用已经扩散,另一个 140ms 返回且兄弟任务全部取消。

所以这一节不写「怎么改写得漂亮」,只写三件事:迁移前后到底差在哪、哪些代码根本不能迁、preview 阶段迁移要付什么代价。

同一段扇出,两种写法

场景固定:reserve 成功后向 mail、sms、push 三个下游发通知,全部成功才算成功。push 在第 120ms 返回 503,另外两个各睡 800ms。先看迁移前的写法:

    public void fanout(String memberId, String text) {
        ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
        List<Future<String>> futures = new ArrayList<>();
        try {
            for (String name : CHANNELS) {
                StructuredNotifier.Channel channel = channels.get(name);
                futures.add(pool.submit(() -> {
                    channel.send(memberId, text);
                    return name;
                }));
            }
            for (Future<String> future : futures) {
                future.get();
            }
        } catch (ExecutionException e) {
            futures.forEach(f -> f.cancel(true));
            Throwable cause = e.getCause();
            if (cause instanceof LoanException loan) {
                throw loan;
            }
            throw new LoanException(ErrorCode.CONFLICT, "通知扇出失败: " + cause);
        } catch (InterruptedException e) {
            futures.forEach(f -> f.cancel(true));
            Thread.currentThread().interrupt();
            throw new LoanException(ErrorCode.INTERRUPTED, "通知扇出被中断");
        } finally {
            pool.shutdown();
        }
    }

这段已经算「写得讲究」:有 cancel(true)、有异常解包、有 shutdown。迁移后(StructuredNotifier.fanout,15.2 给过完整类):

    public void fanout(String memberId, String text) {
        try (var scope = StructuredTaskScope.<String, Stream<StructuredTaskScope.Subtask<String>>>open(
                StructuredTaskScope.Joiner.allSuccessfulOrThrow(),
                cfg -> cfg.withThreadFactory(Thread.ofVirtual().name("notify-", 0).factory()))) {

            for (String name : CHANNELS) {
                Channel channel = channels.get(name);
                scope.fork(() -> {
                    channel.send(memberId, text);
                    return name;
                });
            }

            scope.join().toList();        // 全部成功才会走到这里
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new LoanException(ErrorCode.INTERRUPTED, "通知扇出被中断");
        } catch (StructuredTaskScope.FailedException e) {
            Throwable cause = e.getCause();
            if (cause instanceof LoanException loan) {
                throw loan;
            }
            throw new LoanException(ErrorCode.CONFLICT, "通知扇出失败: " + cause);
        }
    }

DemoMigrate 把两个版本跑在同一个失败场景上。本机输出:

  [push] + 145ms 503
  [sms ] + 827ms ok
  [mail] + 827ms ok
executor + 828ms code=CONFLICT msg=push 下游 503
  [push] + 135ms 503
  [mail] + 139ms INTERRUPTED
  [sms ] + 139ms INTERRUPTED
scope    + 140ms code=CONFLICT msg=push 下游 503

度量(token 用 cl100k_base 分词,它不认 Java 语法,只看相对量级):

版本非空行字符token失败到返回兄弟任务
ExecutorNotifier.fanout291166222828ms未被取消,跑满 827ms
StructuredNotifier.fanout231084230140ms139ms 被中断

结论有点反直觉:迁移换来的不是代码量,是失败语义。 行数少 6 行,token 多 8 个;但一次下游故障的代价从 828ms(还发了两次不该发的通知)降到 140ms(另外两个立刻停)。

为什么 executor 版本即使写了 futures.forEach(f -> f.cancel(true)) 也救不回来?因为 get() 是按提交顺序阻塞的:先等 mail 的 800ms,等到了才发现 push 早就失败了,这时 sms/mail 早就跑完,cancel(true) 对已完成的任务毫无作用。要提前发现失败,得改用 CompletionService 或 CompletableFuture.anyOf,那就等于把调度逻辑重写一遍。这是迁移最实在的收益:不是少写代码,是不用自己实现「谁先失败谁触发取消」。

四条硬边界

边界一:作用域不能跨,子任务不能借

Subtask 是作用域的成员,不是通用句柄。三条实测约束:

  • 只有 owner 线程能 fork。 换个线程(哪怕是虚拟线程)往同一个作用域里 fork,抛 java.lang.WrongThreadException: Current thread not owner。想把任务从别的线程塞进来,只能回到 submit。
  • join 之后作用域冻结。 再 fork 抛 IllegalStateException: Already joined or scope is closed(15.1 实测)。
  • 关闭后能读结果,但不能取消。 作用域关闭后再 get() 一个已成功的 Subtask,本机实测仍然拿得到值;而被取消的那个是 UNAVAILABLE,get() 抛 IllegalStateException: Result is unavailable or subtask did not complete successfully。

三条约束跑一遍就能看清边界在哪:

import java.time.Duration;
import java.util.concurrent.StructuredTaskScope;
import java.util.concurrent.StructuredTaskScope.Subtask;

public class ScopeBoundary {

    public static void main(String[] args) throws Exception {
        System.out.println("A. 非 owner 线程 fork");
        try (var scope = StructuredTaskScope.<String>open()) {
            Thread other = Thread.ofVirtual().start(() -> {
                try {
                    scope.fork(() -> "borrowed");
                } catch (RuntimeException e) {
                    System.out.println("  " + e.getClass().getName() + ": " + e.getMessage());
                }
            });
            other.join();
            scope.join();
        }

        System.out.println("B. 作用域关闭后读结果");
        Subtask<String> done;
        Subtask<String> cancelled;
        try (var scope = StructuredTaskScope.<String, String>open(
                StructuredTaskScope.Joiner.anySuccessfulResultOrThrow())) {
            done = scope.fork(() -> "fast");
            cancelled = scope.fork(() -> {
                Thread.sleep(Duration.ofSeconds(5));
                return "slow";
            });
            scope.join();
        }
        System.out.println("  done.state=" + done.state() + " done.get=" + done.get());
        System.out.println("  cancelled.state=" + cancelled.state());
        try {
            cancelled.get();
        } catch (RuntimeException e) {
            System.out.println("  cancelled.get -> " + e.getClass().getSimpleName() + ": " + e.getMessage());
        }
    }
}

本机输出:

A. 非 owner 线程 fork
  java.lang.WrongThreadException: Current thread not owner
B. 作用域关闭后读结果
  done.state=SUCCESS done.get=fast
  cancelled.state=UNAVAILABLE
  cancelled.get -> IllegalStateException: Result is unavailable or subtask did not complete successfully

也就是说,Subtask 可以安全地传给别的组件读结果,但它对生命周期的控制权永远留在原作用域。想把「A 作用域的任务交给 B 作用域等」这种结构表达出来,API 层面没有入口——这是设计,不是缺失。

边界二:异常一定会被包一层

executor 版本里,业务异常被包在 ExecutionException 里;作用域版本里,被包在 FailedException 里。两者都需要 getCause() 解包,但解包的必要性不一样:

  • ExecutionException 只有在你主动 get() 时才出现,很多代码根本不 get,于是异常静默丢失;
  • FailedException 是 join() 抛出来的,你想忽略都难——这既是优点也是迁移成本:所有 catch (LoanException e) 的分支都要改成先接 FailedException 再解包。

还有一个更容易漏的点:Subtask.get() 在 FAILED 状态下抛的是 IllegalStateException,不是原始异常。原来写 catch (LoanException e) 的地方,换成 task.get() 之后会变成 catch (IllegalStateException e),编译不报错、测试可能也过,直到线上才发现错误码全丢了。

FailedException 还只保留一个 cause:三个下游同时失败时,本机实测 getSuppressed().length == 0,另外两个异常不会挂上来。要完整失败列表,只能在每个子任务内部记录。

边界三:虚拟线程不是迁移理由

作用域的子任务默认跑在虚拟线程上(15.1 实测 virtual=true),但这不代表「迁移是为了用虚拟线程」——第 14 章的 newVirtualThreadPerTaskExecutor() 已经给你虚拟线程了。两者的分工是:

关注点虚拟线程作用域
线程成本每个任务一条,栈按需增长每个子任务一条虚拟线程,不改变成本模型
任务边界不管由代码块定义,关闭即取消
失败传播不管由 Joiner 定义
并发上限不管不管(仍需 Semaphore,14.3)

所以迁移的判断标准不是「有没有用虚拟线程」,而是「这段代码是否需要『任务必须在离开前结束』」。已经是虚拟线程池 + 手工取消 + awaitTermination 的代码,迁移收益最大;纯 CPU 计算、本来就用 ForkJoinPool 的代码,迁移只会多一层包装。

边界四:preview 的代价是构建链

StructuredTaskScope 在 JDK 25 仍是 preview,未来版本 API 可能变化,代价不只是加个参数:

# 故意不加 --enable-preview,看编译器怎么说
$JAVA_HOME/bin/javac --release 25 -d out MigrateDemo.java
MigrateDemo.java:4: 错误: StructuredTaskScope 是预览 API,默认情况下处于禁用状态。
import java.util.concurrent.StructuredTaskScope.Subtask;
                           ^
  (请使用 --enable-preview 以启用预览 API)

编译时加了参数还不够,class 文件会带上 preview 标记,换个 JVM 启动就报错:

错误: 加载主类 MigrateDemo 时出现 LinkageError
	java.lang.UnsupportedClassVersionError: Preview features are not enabled for MigrateDemo (class file version 69.65535). Try running with '--enable-preview'

major version: 69 是 JDK 25,minor: 65535 是 preview 标记。这意味着:编译产物只能由同一个大版本的 JDK 加载,且启动命令必须带 --enable-preview;容器镜像、java -jar 启动脚本、CI 的测试命令都要同步改。API 本身也还可能变——本卷用到的 open/Joiner 就是这一轮 preview 的新形状,更早的 preview 版本里「一个类一种策略」的写法已经被替换掉了。在生产环境迁移前,先确认你能接受「每次升级 JDK 都要重新验证这套 API」这件事。

在 libraryd-conc 里落地

迁移顺序建议按「扇出 → 超时 → 上下文」三步走,每一步都能独立回滚:

  1. 先迁纯扇出:StructuredNotifier(15.2)替换 ExecutorNotifier。这一步只改调用结构,不改业务逻辑,用同一个 DemoMigrate 就能对比 828ms 与 140ms。
  2. 再补超时:给 Configuration 加 withTimeout(Duration.ofSeconds(2)),把「下游卡死」从线程池的 awaitTermination 里挪出来。15.1 的 StructuredLoanService 已经是这个形状。
  3. 最后处理上下文:ScopedValue(7.3)在作用域内自动传给子任务,ThreadLocal 不会。第 13.3 节的装饰器包装可以整段删掉——这一步改动最大,放最后。

三步都完成后,conc-core 里同时存在 VirtualLoanService(14 章,ExecutorService)与 StructuredLoanService,让调用方按场景选:

// 需要「任务边界」的请求路径
LoanService service = new StructuredLoanService(books, loans, members);

// 后台批处理:任务要比请求活得久,继续用执行器
try (ExecutorService batch = Executors.newVirtualThreadPerTaskExecutor()) {
    isbns.forEach(isbn -> batch.submit(() -> reindex(isbn)));
}

编译与运行(preview 参数两处都不能少):

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
mvn -q compile -Dmaven.compiler.enablePreview=true
java --enable-preview --source 25 -cp target/classes com.plume.conc.core.DemoMigrate

注意第二步的 withTimeout 不是银弹:它只保证「时间到了 join 返回」,被取消的子任务如果卡在不可中断的调用里,close() 还是会等(15.2 的取消语义一)。超时能给的是「调用方不被拖死」,不是「下游立刻停手」。

什么时候不要用

  • 任务要比请求活得久:审计、埋点、异步索引。迁移过去就得为它们单独开一个作用域,还得保证不随着请求线程结束被取消,等于把「发射后不管」硬塞进「必须结束」的模型里。
  • 需要队列和背压:作用域没有队列、没有最大并发、没有拒绝策略。上游流量突增时,fork 就是无限创建虚拟线程,第 10 章的线程池参数在这里全部失效,只能用 Semaphore 兜底。
  • 任务集合动态增长:join 之后不能 fork,分页拉取、递归展开这类场景用 ForkJoinPool 或 CompletionService 更自然。
  • 团队还在 JDK 21/24:preview API 的形态在版本之间变过(策略类的合并就是例子),跨版本维护的成本高于收益,等转正再迁。
  • 只是想把代码写短:本机实测 token 反而多 8 个。收益在语义,不在篇幅。

常见错误与边界

一、迁移时只替换了 API,没替换错误处理。 把 submit 换成 fork、get 换成 join,但 catch (ExecutionException) 没改成 FailedException,编译直接失败;如果两边都 catch (Exception),则会在运行时把 FailedException 当成业务异常上报,错误码全部变成未知。

二、在子任务里 fork。 实测抛 WrongThreadException,而且这个异常发生在子任务内部,会被 FailedException 包一层,堆栈看起来像「下游调用失败」,很容易误判。

三、以为 close() 是即时的。 close() 返回时子任务确实都结束了,但「结束」可能是被中断后还在跑 finally。有资源释放逻辑的子任务,要把清理放在 finally 里并保证它不阻塞。

四、把 withTimeout 当熔断。 超时只影响这一次 join,不统计失败率、不打开熔断器。熔断是另一层的事(卷三第 12 章展开)。

五、class 文件跨环境复用。 preview 的 class 文件 minor=65535,本机实测换 JVM 启动直接 UnsupportedClassVersionError。构建产物不要跨 JDK 版本分发,Docker 镜像里的 JRE 必须和编译 JDK 同版本。

六、以为 --enable-preview 只影响编译。 运行也要带;mvn test 需要额外配 argLine,否则测试跑起来全是 UnsupportedClassVersionError。

小结

  • 同一段三下游扇出:executor 版本 29 行 / 222 token / 828ms 且未取消兄弟任务;作用域版本 23 行 / 230 token / 140ms 且 139ms 取消兄弟任务。迁移买的是失败语义,不是代码量。
  • 四条硬边界:作用域不可跨(只有 owner 能 fork,实测 WrongThreadException)、异常必包一层(FailedException,且只带一个 cause)、虚拟线程不是迁移理由、preview 让构建链绑定单一 JDK 版本。
  • 异常解包是迁移中最容易漏的一步:Subtask.get() 在失败时抛 IllegalStateException,业务异常在 exception() 里;join() 抛的 FailedException 需要 getCause()。
  • 迁移顺序:扇出 → 超时 → 上下文,每步可独立回滚;「任务要比请求活得久」的场景留在执行器里。
  • preview 风险具体到命令:编译、运行、测试三处都要 --enable-preview,class 文件 69.65535 不能跨 JDK 复用。

第 15 章到此结束。从第 1 章的线程模型到这里的结构化并发,卷一的主线是「让并发代码的边界可被检查」;接下来附录 A 是并发 API 速查,附录 B 是诊断命令与 JFR 事件。

阅读导航:上一节 15.2 Joiner、失败传播与取消 | 下一节 目录与学习路径

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收