Project Loom 不是一次升级:三块内容分属 JDK 21 与 JDK 25 两条时间线
Project Loom 常被当成 JDK 里的一次并发模型升级整体端出来,但它从来不是一个单一 feature。虚拟线程、结构化并发、作用域值(Scoped Values)走的是不同的 JDK 时间线,把它们塞进同一次升级里理解,真正变化的部分反而被盖住了。
三块内容的状态
- 虚拟线程(JEP 444):JDK 21 定稿。
- 作用域值 Scoped Values(JEP 506):JDK 25 定稿。
- 结构化并发(JEP 505,第五个 preview):JDK 25 仍是 preview;OpenJDK 已继续到 JEP 525(第六个预览)。
- JEP 491:修复虚拟线程在
synchronized块里被 pinning 的问题。
参考链接:
虚拟线程(JEP 444,JDK 21 定稿)
虚拟线程也是 java.lang.Thread 的实例,但不绑定 OS 线程。运行时调度把虚拟线程 mount 到平台线程(carrier)上,遇到阻塞 I/O 时虚拟线程 unmount,carrier 空出来去跑别的虚拟线程。
常见用法:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> System.out.println("hello"));
}
newVirtualThreadPerTaskExecutor 不做线程池,每个提交的任务新建一个虚拟线程。注意 ThreadLocal 在虚拟线程里要慎用,因为数量可能到百万级。
结构化并发:API 形状在 JDK 25 变了
StructuredTaskScope 在 java.util.concurrent 包,把一个任务拆成若干并发子任务(subtask),要求它们在任务继续前完成。核心动作是 fork 和 join。fork 默认起一个虚拟线程。
JDK 21 的写法(旧子类模型):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask user = scope.fork(() -> fetchUser());
Subtask order = scope.fork(() -> fetchOrder());
scope.join();
scope.throwIfFailed();
return new Result(user.get(), order.get());
}
JDK 25 的写法(改用静态工厂 + Joiner):
try (var scope = StructuredTaskScope.open(Joiner.awaitAllSuccessfulOrThrow())) {
Subtask user = scope.fork(() -> fetchUser());
Subtask order = scope.fork(() -> fetchOrder());
return new Result(user.get(), order.get());
}
变化点:
- 用静态工厂
open()取代公开构造器和子类继承。 - 引入
Joiner接口,并发策略在open时就绑定;join()遵循该策略,直接返回结果或抛异常,不再是先join()再throwIfFailed()两步。 - 内置
Joiner:awaitAllSuccessfulOrThrow()(默认,全成功否则抛FailedException)、anySuccessfulResultOrThrow()(第一个成功即返回,其余取消,对应旧ShutdownOnSuccess)、awaitAll()(等全部完成,不抛异常,返回null)、allUntil()(自定义谓词,收集到满足条件即停并取消剩余)。 - 也可以自己实现
Joiner,比如「第一个完成(无论成败)即返回、其余取消」,JDK 没有内置这个策略。
一个真实迁移仓库的数字:Java 21 分支有 56 处 ShutdownOnFailure、7 处 ShutdownOnSuccess、48 处 throwIfFailed;迁到 Java 25 后变成 63 处 StructuredTaskScope.open 和 63 处 Joiner。代码没离开结构化并发,是从内置子类换成了显式 joiner 策略。
其他约束:
fork/join/close只能由 owner 线程(打开 scope 的线程)调用。- scope 用 try-with-resources,
close会取消未完成子任务。 - 可设超时,超时
join抛StructuredTaskScope.TimeoutException。 - 子任务可以自己再开
StructuredTaskScope,形成 scope 层级;jcmd生成的 thread dump 里能看到这棵树。 - 结构化并发的错误会以
FailedException传播。
作用域值(JEP 506,JDK 25 定稿)
Scoped Values 让方法在同一线程及其子线程间共享不可变数据,比 ThreadLocal 更容易推理,空间和时间开销也更低,尤其搭配虚拟线程与结构化并发。
一个实测细节:在结构化 scope 里 fork 的子任务能拿到上下文;而普通嵌套虚拟线程(非结构化)里,child 和 grandchild 打印出来是 unknown。
迁移时注意
pom.xml里source/target从 21 抬到 25,--enable-preview不能去掉,因为结构化并发仍是 preview。- 契约更明确:Java 21 代码读起来像「开一个特殊 scope,fork,join,再补一步策略调用」;Java 25 读起来像「用这个策略开 scope,fork,join 时按策略判定」。
虚拟线程让阻塞代码等待更便宜、作用域值给请求上下文一个词法形状、结构化并发还在改,因为 JDK 自己都还没敲定 ownership 模型。