Java 25 LTS 深度实战:从 Loom 收官到 Leyden 起步——作用域值、结构化并发、紧凑对象头一次讲透
距离上一个 LTS(Java 21)过去整整两年,Java 25 在 2025 年 9 月 16 日 GA。这不是一次"挤牙膏"式的小版本,而是四条技术主线同时收口的节点:Loom 的并发模型基本定型、Amber 的语言现代化让"写第一行 Java"这件事彻底变样、Lilliput 把对象头从 128 位压到 64 位、Leyden 第一次把 AOT 塞进命令行。作为一个写了很多年 JVM 的人,我想把这一版真正值得动手的东西讲透——不堆特性清单,而是讲清楚每个特性背后"为什么"和"怎么用"。
一、背景:为什么 Java 25 值得你重新认识 JVM
先说一个容易被忽略的事实:大多数生产系统还跑在 Java 8 和 Java 11 上。很多人对 Java 的印象还停留在"啰嗦、样板代码多、并发要靠线程池调参"。但如果你从 Java 8 一步跳到 Java 25,会发现这几乎是两门语言。
Java 采用六个月一个特性版本、每两年一个 LTS 的节奏。LTS 之间的这些"中间版本"不是玩具——它们是特性的预览与孵化通道。等到一个特性在 LTS 里定稿(final),意味着它经历了好几轮预览打磨。这就是为什么"看 LTS"是最省心的策略:LTS 里 final 的东西,就是团队认为可以压上生产的东西。
Java 25 从 Java 21 一路积累下来,一共 18 个 JEP。我把它们按四条主线归类,这张地图比逐条读 JEP 更有用:
| 主线 | 项目 | 本版关键 JEP | 状态 |
|---|---|---|---|
| 并发模型 | Loom | 506 作用域值、505 结构化并发 | 506 final / 505 第五次预览 |
| 语言现代化 | Amber | 511 模块导入、512 紧凑源文件与实例 main、513 灵活构造器体、507 模式中的原始类型 | 511/512/513 final / 507 第三次预览 |
| 性能与内存 | Lilliput / GC | 519 紧凑对象头、521 分代 Shenandoah | 519 生产可用 / 521 final |
| 启动与预热 | Leyden | 514 AOT 命令行、515 AOT 方法剖析 | 均 final |
| 安全与观测 | Security / JFR | 510 KDF API、470 PEM、518/520/509 JFR | 510/511 final / 509 实验性 |
下面我挑最值得程序员动手的几块深入讲。
二、Loom 收官:虚拟线程的"最后一块拼图"
2.1 先把虚拟线程本身讲清楚
虚拟线程(Virtual Threads,JEP 444)在 Java 21 就已 final,但很多人还没真正理解它解决了什么问题。核心矛盾是这样的:
传统的 java.lang.Thread 是平台线程(platform thread),它是操作系统线程的一层薄封装。一个 OS 线程通常要吃掉约 1MB 栈内存,且线程切换要走内核。这导致一个经典的"C10K"难题:想同时处理一万个连接,如果一个连接一个线程,光栈内存就要 10GB,内核调度也扛不住。
于是过去二十年我们发明了各种绕路方案:线程池、NIO、Reactor、异步回调、CompletableFuture、响应式编程(Reactor/RxJava)。这些方案的共同代价是——代码不再"顺序"了。你被迫把一段本该线性的业务逻辑,拆成一堆回调和 flatMap,栈追踪也变得没法读。
虚拟线程的思路是回到"一个请求一个线程"的朴素模型,但让线程变得极其廉价:
// 虚拟线程:几乎零成本,可以开百万个
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // 阻塞在这里完全没问题
return i;
});
});
} // executor.close() 会等待所有任务结束
上面这段代码开了一百万个虚拟线程,如果换成平台线程,机器早就 OOM 了。原理是:虚拟线程运行在少量"载体线程(carrier thread)"之上,当虚拟线程遇到阻塞(比如 IO、sleep),JVM 会把它从载体线程上卸载(unmount),让出载体线程去跑别的虚拟线程;等阻塞结束再**挂载(mount)**回来。这套"挂载/卸载"由 JVM 自动完成,你写的还是同步阻塞代码。
关键心智模型:虚拟线程适合 IO 密集型,不适合 CPU 密集型。它省的是"等待"的成本,不是"计算"的成本。
2.2 JEP 491:干掉 synchronized 的 pinning
虚拟线程在 Java 21 刚 final 时有一个恶心的坑叫 pinning(钉住)。当虚拟线程在 synchronized 块内部发生阻塞时,它无法从载体线程上卸载——相当于"钉死"在载体线程上。如果大量虚拟线程同时钉住,载体线程池被占满,整个应用会假死。
那时候官方的建议是:把 synchronized 换成 ReentrantLock。但这意味着你要改一堆老代码、老依赖库,很多第三方库你根本改不动。
Java 24 的 JEP 491("Synchronize Virtual Threads without Pinning")从底层解决了这个问题:现在虚拟线程在 synchronized 块内阻塞也能正常卸载。到 Java 25,这已经是稳定行为。这意味着:
你现在可以放心地把老代码直接跑在虚拟线程上,不用再为 synchronized 做大规模改造。 这是虚拟线程从"能用"到"敢在生产大规模用"的分水岭。
不过还有一个残留的 pinning 场景要注意:native 方法调用和 Object.wait() 里的某些情况。诊断 pinning 可以用 JFR 事件:
# 用 JFR 抓 pinning 事件
java -XX:StartFlightRecording=filename=app.jfr,settings=profile MyApp
# 或者用系统属性打印 pinning 栈(调试期用)
java -Djdk.tracePinnedThreads=full MyApp
看到 jdk.VirtualThreadPinned 事件就说明还有钉住,顺着栈找到那段 native 调用即可。
2.3 一个真实的坑:连接池要重新算容量
虚拟线程改变了一个隐含假设:过去数据库连接池(如 HikariCP)的大小往往对齐线程池大小。现在虚拟线程可以有几十万个,如果每个都去抢连接,连接池会瞬间被打爆或大量排队。
正确姿势是:连接池继续保持小而固定(比如 CPU 核数 × 2 到几十),用信号量或连接池自身的排队来做背压。虚拟线程负责"廉价地等",连接池负责"限流",两者分工。别指望把连接池也开到几十万——数据库那头扛不住。
// 虚拟线程 + 有界连接池:让虚拟线程排队等连接,而不是无限制建连接
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(32); // 保持小而稳
config.setJdbcUrl("jdbc:postgresql://localhost/app");
DataSource ds = new HikariDataSource(config);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100_000; i++) {
executor.submit(() -> {
// 十万个虚拟线程竞争 32 个连接,HikariCP 自身做排队背压
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT 1")) {
ps.executeQuery();
}
return null;
});
}
}
三、作用域值(JEP 506):ThreadLocal 的接班人正式转正
这是 Java 25 我最想安利的特性。作用域值(Scoped Values)在 Java 25 正式 final 了,它是为虚拟线程时代量身定做的"上下文传递"机制。
3.1 ThreadLocal 的三宗罪
先说为什么要替换 ThreadLocal。ThreadLocal 在"少量长寿命线程"的世界里工作得很好,但在"百万虚拟线程"的世界里问题被放大:
- 可变性无约束:
ThreadLocal随时可以set(),值的生命周期不清晰,你不知道谁在什么时候改了它,容易造成难以追踪的 bug。 - 内存泄漏风险:忘记
remove()就会泄漏,尤其在线程池复用线程时,脏数据会串到下一个任务。 - 继承成本高:
InheritableThreadLocal在创建子线程时要复制整个 map,百万虚拟线程场景下这个成本会爆炸。
3.2 作用域值怎么用
作用域值的核心思想是不可变 + 有明确作用域。你在一个代码块里"绑定"一个值,块内(包括调用链上的所有方法、包括你在块内 fork 出的结构化并发子线程)都能读到,块一结束值自动失效。
public class ServerContext {
// 声明一个作用域值,通常是 static final
public static final ScopedValue<User> CURRENT_USER = ScopedValue.newInstance();
public static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
}
// 在请求入口处绑定(比如一个 Web 过滤器)
void handleRequest(HttpRequest request) {
User user = authenticate(request);
String reqId = UUID.randomUUID().toString();
// where(...).run(...) 建立一个作用域,块内所有代码都能读到这两个值
ScopedValue.where(ServerContext.CURRENT_USER, user)
.where(ServerContext.REQUEST_ID, reqId)
.run(() -> processBusiness(request));
// 块结束后,CURRENT_USER 和 REQUEST_ID 自动失效
}
// 调用链深处,直接读,无需层层传参
void processBusiness(HttpRequest req) {
logWithContext("开始处理业务");
// ...
}
void logWithContext(String msg) {
// 在任意深度读取,不可变,读到的一定是当前作用域绑定的值
String reqId = ServerContext.REQUEST_ID.get();
User user = ServerContext.CURRENT_USER.get();
System.out.printf("[%s][%s] %s%n", reqId, user.name(), msg);
}
对比 ThreadLocal,作用域值有三个硬优势:
- 不可变:一旦绑定,块内不能改。要"改"只能进入一个新的嵌套作用域,用
where重新绑定,出了嵌套块又恢复外层的值。这让数据流向变得可预测。 - 自动清理:作用域结束值就失效,从根上杜绝了忘记
remove()的泄漏。 - 廉价继承:配合结构化并发,子线程能零成本共享父作用域的值,不用复制 map。
需要注意 get() 时如果没绑定会抛 NoSuchElementException,可以用 isBound() 判断,或用 orElse:
String reqId = ServerContext.REQUEST_ID.isBound()
? ServerContext.REQUEST_ID.get()
: "no-request-id";
3.3 嵌套重绑定:作用域值的"局部覆盖"
作用域值的不可变不是死板,它支持嵌套覆盖,出了嵌套块自动恢复。这在需要临时切换上下文时特别好用:
ScopedValue.where(ServerContext.CURRENT_USER, normalUser).run(() -> {
doSomething(); // 这里读到 normalUser
// 临时以管理员身份执行一段(比如系统内部调用)
ScopedValue.where(ServerContext.CURRENT_USER, adminUser).run(() -> {
doAdminTask(); // 这里读到 adminUser
});
doSomethingElse(); // 出了嵌套块,又读回 normalUser
});
这种"结构化的上下文栈"是 ThreadLocal 用 set/remove 手动模拟起来极易出错的,作用域值把它变成了语言级的、自动正确的行为。
四、结构化并发(JEP 505):让并发也有"作用域"
结构化并发在 Java 25 是第五次预览(还没 final,用的时候要加 --enable-preview),但 API 这一版有重要调整,值得提前掌握。
4.1 非结构化并发的问题
看这段"传统"的并发代码:
// 非结构化:两个子任务的生命周期和主逻辑脱钩
Future<User> userFuture = executor.submit(() -> fetchUser(id));
Future<Order> orderFuture = executor.submit(() -> fetchOrder(id));
User user = userFuture.get(); // 如果这里抛异常……
Order order = orderFuture.get(); // orderFuture 还在后台空跑,没人管它
问题很明显:如果 fetchUser 失败了,fetchOrder 还在后台跑,浪费资源且没人取消它;异常处理、超时、取消传播全都要手写,极易出错。这就是"并发逃逸出了代码块的作用域"。
4.2 结构化并发的心智模型
结构化并发借鉴了结构化编程的思想:子任务的生命周期必须严格嵌套在父任务的作用域内。父任务打开一个作用域 fork 出子任务,作用域结束前,所有子任务必须全部完成(或全部被取消)。fork 出去的东西不可能"逃逸"出这个块。
Java 25 这一版的 API 用工厂方法 StructuredTaskScope.open(...) 配合 Joiner 策略:
import java.util.concurrent.StructuredTaskScope;
Response handle(long userId, long orderId) throws Exception {
// Joiner.allSuccessfulOrThrow:任一子任务失败就取消其余任务并抛异常
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {
// fork 返回 Subtask 句柄
var userTask = scope.fork(() -> fetchUser(userId));
var orderTask = scope.fork(() -> fetchOrder(orderId));
scope.join(); // 等两个都成功;任一失败会自动取消另一个并抛出
// 到这里两个都成功了,安全取值
return new Response(userTask.get(), orderTask.get());
} // 作用域关闭时保证没有子任务遗留
}
几个关键点:
- 失败即取消:
allSuccessfulOrThrow语义下,任一子任务抛异常,作用域会立刻中断其余还在跑的子任务。不再有"孤儿任务"空跑。 - 异常聚合:异常会正确地从子任务传播到父作用域,栈追踪能连起来,不像回调地狱那样断片。
- 可读性:整个并发逻辑就是一个
try块,谁 fork、谁 join、什么时候结束一目了然。
4.3 常见 Joiner 策略
| Joiner | 语义 | 典型场景 |
|---|---|---|
allSuccessfulOrThrow() | 全部成功才继续,任一失败即取消并抛出 | 聚合多个必需的下游调用 |
anySuccessfulResultOrThrow() | 任一成功即返回,取消其余 | 多副本竞速取最快的 |
awaitAll() | 等全部结束(不管成败) | 批量任务,逐个收集结果 |
allUntil(Predicate) | 自定义终止条件 | 复杂策略 |
比如"多数据源竞速":
String queryFastest() throws Exception {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> queryFromCache());
scope.fork(() -> queryFromReplicaA());
scope.fork(() -> queryFromReplicaB());
return scope.join(); // 返回第一个成功的结果,其余自动取消
}
}
4.4 和作用域值的化学反应
结构化并发和作用域值是天生一对:在作用域里 fork 出的子任务,会自动继承父作用域绑定的作用域值,且是零成本共享(不复制)。这样你的 REQUEST_ID、CURRENT_USER 能自然地流到并发子任务里:
ScopedValue.where(ServerContext.REQUEST_ID, reqId).run(() -> {
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {
// 这两个子任务里 REQUEST_ID.get() 都能读到同一个 reqId
scope.fork(() -> { log("子任务A"); return fetchA(); });
scope.fork(() -> { log("子任务B"); return fetchB(); });
scope.join();
}
});
这就是 Loom 整体设计的优雅之处:虚拟线程解决"廉价并发",结构化并发解决"并发的生命周期管理",作用域值解决"上下文传递",三者拼在一起,才是完整的现代 Java 并发模型。
五、Amber:把"写第一行 Java"这件事彻底改了
Amber 项目专注语言现代化。Java 25 有三个 final 的语言特性,合起来威力惊人——尤其对新手和写脚本的场景。
5.1 JEP 512:紧凑源文件与实例 main 方法
还记得 Java 的"Hello World"有多啰嗦吗?
// 老写法:一个 Hello World 要写这么多样板
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
Java 25 的 JEP 512 让它变成这样:
// 新写法:紧凑源文件,不需要 class 声明,不需要 public static,不需要 String[] args
void main() {
IO.println("Hello, World!");
}
变化有三层:
- 不需要显式的类声明(编译器自动包一个隐式类)。
- main 可以是实例方法,不必
public static void main(String[]),可以就写void main()。 - 新的
IO类提供IO.println/IO.print/IO.readln,不用再写又长又拗口的System.out.println。而且在紧凑源文件里,java.base的核心类型是自动导入的。
这看起来是小事,但意义很大:它极大降低了 Java 的教学门槛和写小工具的成本。以前教初学者,第一节课就要解释 public、static、void、String[] 一堆他还理解不了的概念;现在可以先写逻辑,等他准备好了再引入类和访问控制。对老手来说,写个一次性脚本也不用再堆样板了。
5.2 JEP 511:模块导入声明
大项目里 import 一大堆是常态。Java 25 允许一行导入整个模块导出的所有包:
// 一行顶过去几十行 import
import module java.base;
void main() {
List<String> names = List.of("三", "哥", "茄", "子");
Map<String, Integer> counts = new HashMap<>();
names.forEach(n -> counts.merge(n, 1, Integer::sum));
IO.println(counts);
}
import module java.base; 会把 java.base 模块导出的所有包(java.util、java.io、java.util.stream 等)一次性引入。这在原型开发、数据脚本、教学场景里很爽。生产代码里是否用它见仁见智——有人觉得显式 import 更利于阅读,这点看团队规范。
5.3 JEP 513:灵活构造器体
这是个"迟到了 20 年"的特性。以前 Java 有一条铁律:构造器的第一条语句必须是 super(...) 或 this(...),之前不能有任何代码。这导致参数校验很别扭:
// 老写法:想在 super() 前校验参数?不行,只能塞进 super 的参数里或用静态方法绕
public class PositiveInt {
PositiveInt(int value) {
super(); // 必须第一句
if (value <= 0) throw new IllegalArgumentException(); // 校验只能放后面,可 super 可能已经用了脏数据
}
}
Java 25 允许在 super(...) / this(...) 之前写代码(只要不访问正在构造的实例):
// 新写法:可以在 super() 之前做校验、准备参数
public class Range {
private final int lo, hi;
Range(int lo, int hi) {
if (lo > hi) throw new IllegalArgumentException("lo 必须 <= hi"); // 提前失败
// 甚至可以在调用 super 前做参数变换
this.lo = lo;
this.hi = hi;
}
}
class SortedRange extends Range {
SortedRange(int a, int b) {
// 在 super 之前把参数排好序,这在老 Java 里做不到
int lo = Math.min(a, b);
int hi = Math.max(a, b);
super(lo, hi);
}
}
这解决了一个长期的痛点:fail-fast 校验和参数预处理终于能在对象"半初始化"之前完成,避免了以前那种"先 super 再校验、但 super 已经拿脏数据初始化了一半"的尴尬。
5.4 JEP 507:模式匹配支持原始类型(第三次预览)
模式匹配一路进化,Java 25 让它支持原始类型(预览阶段):
// switch 里直接对原始类型做模式匹配和范围守卫
String classify(int code) {
return switch (code) {
case 0 -> "零";
case int i when i < 0 -> "负数:" + i;
case int i when i < 100 -> "小正数:" + i;
case int i -> "大数:" + i;
};
}
// instanceof 也支持原始类型模式
Object obj = 42;
if (obj instanceof int i) {
IO.println("是个 int:" + i);
}
这让模式匹配的表达力更完整,配合 record 解构,写数据处理逻辑越来越像函数式语言了。
六、Lilliput:紧凑对象头(JEP 519)——一次"免费"的内存优化
这是个纯收益、几乎零改动的性能特性,我强烈建议关注。
6.1 对象头是什么,为什么值得压
JVM 里每个对象都有一个"对象头(object header)",存放锁状态、GC 分代年龄、identity hashCode、类型指针(klass pointer)等元信息。在传统 64 位 HotSpot 上,对象头通常占 128 位(16 字节)。
问题在于:Java 应用里有海量的小对象。一个只有一两个字段的小对象,光对象头就占了一半甚至更多空间。这些头信息累加起来,在大堆应用里能占到堆的 20% 甚至更多。
Lilliput 项目的目标就是把对象头压缩。JEP 519 把对象头从 128 位压到 64 位(8 字节)。
6.2 收益有多大,怎么开
实测在很多真实工作负载上,堆内存能省 10%~20%,同时因为对象更紧凑、缓存命中率提升,往往还能带来吞吐量的小幅提升和 GC 停顿的降低。这几乎是"白给"的优化。
Java 25 里它已经是生产可用的特性,用一个 flag 打开:
java -XX:+UseCompactObjectHeaders -jar app.jar
需要注意:它目前不是默认开启(在后续版本可能转默认)。开启后建议做一轮压测对比,观察 -Xlog:gc* 的堆占用和停顿变化。绝大多数应用直接受益,极少数强依赖对象头内存布局的场景(如某些堆外/序列化黑魔法库)要回归测试一下。
七、GC:分代 Shenandoah(JEP 521)
Shenandoah 是一款以"低停顿"著称的并发 GC。它过去的短板是不分代——不区分新生代和老年代,导致处理"大量短命对象"的场景效率不如分代 GC。
JEP 521 让 Shenandoah 支持分代模式并在 Java 25 转正。核心收益:利用"弱分代假说"(大多数对象很快死亡),把频繁的年轻代回收和昂贵的老年代回收分开,用更少的 CPU 和内存开销维持同样的低停顿。
# 启用分代 Shenandoah
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar app.jar
选型建议:如果你的应用对停顿时间极度敏感(比如金融交易、实时系统),且堆比较大,可以对比 ZGC(分代 ZGC 在 Java 21 已 final)和分代 Shenandoah,实测选优。对于大多数普通服务,默认的 G1 仍然是稳妥选择。
八、Leyden:AOT 起步,向"快启动"进军
Java 一直被诟病"启动慢、预热慢"——JIT 需要跑一会儿才能把热点代码编译成机器码,这在 Serverless、短命任务、快速扩缩容场景里很吃亏。Leyden 项目就是冲着这个来的。
Java 25 有两个 Leyden 相关 JEP:
- JEP 514:AOT 命令行改进——简化了 AOT 缓存的创建流程。
- JEP 515:AOT 方法剖析——把方法执行的剖析数据也提前缓存,让 JIT 一启动就能拿到"哪些是热点"的信息,加速预热。
它的工作流大致是"先训练、再运行":先跑一轮采集应用的类加载和执行剖析数据,生成 AOT 缓存;正式运行时加载这份缓存,跳过大量冷启动的类加载和剖析工作。
# 1. 训练阶段:记录应用行为,生成 AOT 配置
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -jar app.jar
# 2. 生成 AOT 缓存
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -jar app.jar
# 3. 生产运行:加载缓存,启动更快
java -XX:AOTCache=app.aot -jar app.jar
Leyden 还在演进中,但方向很明确:让 Java 在云原生、Serverless 场景里,启动和预热速度不再是短板。这和 GraalVM Native Image 是两条不同的路——Leyden 保留了 JVM 的动态性(还能 JIT、还能用反射),只是把冷启动的部分成本提前付掉。
九、可观测性:JFR 三连更新
JDK Flight Recorder(JFR)是 JVM 内建的低开销剖析工具。Java 25 有三个 JFR 相关 JEP:
- JEP 509:CPU 时间剖析(实验性,Linux)——基于 CPU 时间而非墙钟时间采样,能更准确地定位真正吃 CPU 的方法。
- JEP 518:JFR 协作式采样——改进采样机制,降低开销、提高准确性。
- JEP 520:JFR 方法计时与追踪——可以对特定方法做计时和追踪,不用改代码加埋点。
配合前面提到的虚拟线程 pinning 诊断,JFR 现在是排查 Loom 相关问题的第一工具:
# 开一个 profile 级别的飞行记录
java -XX:StartFlightRecording=duration=60s,filename=rec.jfr,settings=profile -jar app.jar
# 用 jfr 命令行工具分析(打印虚拟线程钉住事件)
jfr print --events jdk.VirtualThreadPinned rec.jfr
# 打印方法采样热点
jfr print --events jdk.ExecutionSample rec.jfr | head -50
十、综合实战:一个"现代 Java 并发"Web 处理器
把前面的东西串起来,写一个能体现 Java 25 并发威力的完整例子:处理一个请求,需要并发拉取用户信息、订单列表、推荐商品三个下游,任一失败即整体失败,全程带 REQUEST_ID 上下文。
import java.util.concurrent.StructuredTaskScope;
import java.util.UUID;
public class OrderPageService {
static final ScopedValue<String> REQUEST_ID = ScopedValue.newInstance();
record UserInfo(long id, String name) {}
record Order(long id, String item, int amount) {}
record Recommendation(String item, double score) {}
record PageData(UserInfo user, java.util.List<Order> orders,
java.util.List<Recommendation> recs) {}
// 请求入口:绑定作用域值,交给虚拟线程执行
public PageData buildPage(long userId) throws Exception {
String reqId = UUID.randomUUID().toString().substring(0, 8);
return ScopedValue.where(REQUEST_ID, reqId).call(() -> aggregate(userId));
}
// 用结构化并发聚合三个下游
private PageData aggregate(long userId) throws Exception {
log("开始聚合页面数据 userId=" + userId);
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<Object>allSuccessfulOrThrow())) {
var userTask = scope.fork(() -> fetchUser(userId));
var orderTask = scope.fork(() -> fetchOrders(userId));
var recTask = scope.fork(() -> fetchRecommendations(userId));
scope.join(); // 三个都成功才往下走,任一失败自动取消其余并抛出
PageData data = new PageData(
(UserInfo) userTask.get(),
castList(orderTask.get()),
castList(recTask.get()));
log("聚合完成");
return data;
}
}
private UserInfo fetchUser(long id) {
log("拉取用户信息"); // 子任务里能读到父作用域的 REQUEST_ID
sleep(120);
return new UserInfo(id, "茄子" + id);
}
private java.util.List<Order> fetchOrders(long id) {
log("拉取订单列表");
sleep(200);
return java.util.List.of(new Order(1, "机械键盘", 399),
new Order(2, "人体工学椅", 1299));
}
private java.util.List<Recommendation> fetchRecommendations(long id) {
log("拉取推荐商品");
sleep(150);
return java.util.List.of(new Recommendation("显示器", 0.92),
new Recommendation("降噪耳机", 0.88));
}
// 统一日志:自动带上 REQUEST_ID,无需层层传参
static void log(String msg) {
String rid = REQUEST_ID.isBound() ? REQUEST_ID.get() : "-";
System.out.printf("[%s][%s] %s%n",
rid, Thread.currentThread(), msg);
}
@SuppressWarnings("unchecked")
static <T> java.util.List<T> castList(Object o) { return (java.util.List<T>) o; }
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }
public static void main(String[] args) throws Exception {
var service = new OrderPageService();
// 用虚拟线程并发处理一批请求
try (var executor = java.util.concurrent.Executors.newVirtualThreadPerTaskExecutor()) {
for (long uid = 1; uid <= 3; uid++) {
long id = uid;
executor.submit(() -> {
PageData data = service.buildPage(id);
log("最终结果:" + data);
return null;
});
}
}
}
}
这段代码用到了:虚拟线程(newVirtualThreadPerTaskExecutor)承载高并发、结构化并发(StructuredTaskScope)管理三个子任务的生命周期和失败传播、作用域值(REQUEST_ID)在整条调用链和并发子任务里透明传递上下文。整体逻辑还是顺序、同步的写法,可读性远胜回调链或 CompletableFuture.thenCompose 拼装。
注意:结构化并发在 Java 25 还是预览特性,编译运行要加
--enable-preview --release 25。作用域值和虚拟线程都已 final,可直接用。
十一、性能优化与迁移清单
如果你准备把项目升级到 Java 25,我给一份实操清单:
并发相关
- 升级后把 IO 密集的线程池逐步替换成
newVirtualThreadPerTaskExecutor,但先压测。 - 重新审视数据库/HTTP 连接池大小——保持小而有界,靠连接池排队做背压,别跟着虚拟线程放大。
- 用 JFR 抓
jdk.VirtualThreadPinned,确认没有残留 pinning(JEP 491 后synchronized已不再钉住,重点查 native 调用)。 - 逐步用作用域值替换请求级的
ThreadLocal,尤其是那些容易忘记remove()的。
内存与 GC
- 打开
-XX:+UseCompactObjectHeaders,压测对比堆占用,多数应用能省 10%~20%。 - 停顿敏感的大堆应用,实测对比分代 Shenandoah / 分代 ZGC / G1。
语言与工程
- 新项目/脚本可以用紧凑源文件、模块导入降低样板;生产代码是否用看团队规范。
- 用灵活构造器体把参数校验前移,写出更健壮的不可变对象。
启动优化
- Serverless / 快速扩缩容场景,试验 Leyden 的 AOT 缓存流程,观察冷启动收益。
通用
- 从 Java 8/11 直升 25 时,重点检查被移除的特性(比如 32 位 x86 端口在 JEP 503 里被移除)、废弃 API 和第三方库兼容性,用
jdeprscan和jdeps扫一遍。
十二、总结与展望
Java 25 不是一个"多了几个语法糖"的版本,而是四条主线同时收口的里程碑:
- Loom 用"虚拟线程 + 结构化并发 + 作用域值"给出了一套完整、自洽、可读的现代并发模型,宣告"回调地狱"和"响应式样板"在很多场景下可以退休了。
- Amber 让 Java 的入门门槛和小工具成本大幅下降,同时用灵活构造器体补齐了长期的工程短板。
- Lilliput 用紧凑对象头给出一次几乎免费的内存优化。
- Leyden 开始正面解决启动与预热问题,瞄准云原生的未来。
如果你还停留在"Java 很啰嗦、并发很难写"的印象里,Java 25 值得你花一个周末重新认识它。展望后续版本,Valhalla(值类型/原始类)、Babylon(面向 GPU 与外部编程模型的代码反射)、以及 Leyden 的进一步落地,会继续把 Java 往"更快、更省、更好写"的方向推。作为一个长期押注 JVM 的程序员,我觉得现在是这十年里 Java 最值得关注的时刻之一。
动手建议就一句话:装一个 JDK 25,把上面那个综合实战例子跑起来,你会对"现代 Java"有全新的体感。