Java25 作为 Java 语言的最新LTS版本,带来了多项令人振奋的改进。这些改进主要集中在三个方面:代码简洁性、运行效率提升和现代化语言特性支持。从实际使用体验来看,这可能是近年来最具突破性的 Java 版本更新。
一、语言语法特性
1. 紧凑源文件与实例 main 方法
改进一:紧凑源文件与隐式类
当 .java 文件在显式类声明之外包含方法或字段时,编译器会自动将这些内容视为属于一个隐式声明的顶层类(implicitly declared class)。这个类不能被其他代码引用,因此不用于定义 API,只作为程序的起点。
typescript
// JDK 25之前的传统写法:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
// 紧凑源文件写法:
void main() {
IO.println("Hello, World!");
}
改进二:实例 main 方法
Java 启动协议现在可以识别实例 main方法。如果启动的类没有合适的静态 main方法,启动器会搜索实例方法,并在调用之前自动创建该类实例。
arduino
class App {
private final String name = "App";
// 1、无参数实例入口
void main() {
IO.println("启动: " + name);
}
}
改进三:java.lang.IO 与控制台 I/O
传统控制台交互需要 System.out.println 和复杂的 BufferedReader 读取代码。java.lang.IO 类,IO 类位于 java.lang 包中,无需显式导入即可使用。提供五个静态方法:
- IO.print(Object obj)
- IO.println(Object obj)
- IO.println()
- IO.readln(String prompt)
- IO.readln()
ini
void main() {
String name = IO.readln("请输入姓名: ");
IO.print("你好, ");
IO.println(name);
}
改进四:java.base 模块自动导入
每个紧凑源文件会被视为在开头自动添加了 import module java.base;。这意味着 java.base 模块导出的所有公共顶层类和接口(如 List、Map、Stream、Collectors 等)无需显式导入即可直接使用。
csharp
// 无需 import java.util.List;
void main() {
var authors = List.of("James", "Bill", "Guy");
for (var name : authors) {
IO.println(name + ": " + name.length());
}
}
注意事项
IO方法不再被隐式静态导入:在 JDK 25 的最终版本中,IO类的静态方法不再被隐式导入到紧凑源文件中。你必须显式写出IO.println(...),除非手动添加静态导入。这是与早期预览版本的一个重要区别,目的是让紧凑源文件向常规类的过渡更平滑。IO的实现基于System.out/System.in:JDK 25 中IO类的底层实现已从java.io.Console改为基于System.out和System.in,使其在更多环境中可用。- 隐式类名:编译器会为隐式声明的类生成一个类名(与文件名相同),但该名称不能在 Java 源代码中直接使用。
2. 灵活构造函数体
允许在构造函数的显式构造函数调用,即 super(...) 或 this(...) 之前,放置不引用正在创建的实例的语句。
在 Java 25 之前,任何构造函数的第一条语句必须是 super(...) 或 this(...) 调用,这迫使开发者必须等到父类构造完成后,才能进行参数校验或字段初始化。
scss
super(...);
// 或
this(...);
JDK 25 允许在 super(...) 或 this(...) 调用之前执行参数校验、计算和本类字段初始化,从而让构造逻辑更自然、更安全。
scala
class Employee extends Person {
// 序言:在调用 super() 之前进行校验
Employee(String name, int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("age 不合法");
}
super(name, age);
}
}
(1)在调用 super() 前初始化字段:
scala
class SmallCoffee extends Coffee {
private final String topping;
SmallCoffee(int water, int milk, String topping) {
this.topping = topping; // 序言阶段初始化本类字段
super(water, milk); // 再调用父类构造函数
}
}
(2)参数预处理:
scala
class Rectangle extends Shape {
Rectangle(int width, int height) {
if (width <= 0 || height <= 0) {
throw new IllegalArgumentException("宽高必须为正数");
}
int area = width * height;
super(area);
}
}
(3)条件分支后调用 super:
scala
class ConfiguredService extends Service {
ConfiguredService(String mode) {
String normalized = mode == null ? "default" : mode.toLowerCase();
if (normalized.equals("dev")) {
super("dev-config");
} else {
super("prod-config");
}
}
}
注意事项:
- 记录类的约束依然存在:记录类(Record)的规范构造函数(canonical constructor)不得包含显式构造函数调用,而非规范构造函数必须包含
this(...)调用,且不能是super(...); - return 语句:在序言中禁止出现 return 语句(包括
return e;),但允许在super(...)之后的尾声(epilogue)中出现无表达式的return;; - 与 Project Valhalla 的关联:早期字段初始化能力,是未来值类(Value Classes)严格初始化要求的重要前置条件。
3. 模块导入声明
核心是引入 import module M; 语法,允许用一条语句导入模块导出的所有包,从而替代多条包级通配符导入。该特性旨在增强 Java 编程语言,简化模块化库的复用,并且不要求导入代码本身位于一个模块中。
arduino
import module M;
它会按需(on-demand)导入以下所有公共顶层类和接口:
- 模块
M导出给当前模块的包中的类型 - 由于读取模块
M而间接读取的模块所导出的类型(即传递依赖的导出)
(1)替代多个按需导入
传统写法需要多个通配符导入:
java
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
使用模块导入后,可以合并为:
arduino
import module java.base;
// 因为 java.base 模块导出了 java.util、java.util.function、java.util.stream 等包。
(2)合并多个按需导入
多个相关的按需导入可以合并为一个模块导入
arduino
// 旧写法
import javax.xml.*;
import javax.xml.parsers.*;
import javax.xml.stream.*;
// JDK 25 新写法
import module java.xml;
(3)名称冲突与消歧
模块导入会导入多个包,因此可能出现同名类的冲突。例如,同时导入 java.base 和 java.sql 时,Date 类会产生歧义:
javascript
import module java.base; // 导出 java.util.Date
import module java.sql; // 导出 java.sql.Date
Date d; // 编译错误:reference to Date is ambiguous
解决方案是添加一个单类型导入声明来"遮蔽"(shadow)冲突的名称:
java
import module java.base;
import module java.sql;
import java.sql.Date; // 显式指定使用 java.sql.Date
Date d = Date.valueOf("2025-06-15"); // 现在正确解析为 java.sql.Date
与紧凑源文件的关系 :JDK 25 中,每个隐式声明的类都会自动按需导入 java.base 模块导出的所有公共顶层类和接口,相当于隐式添加:
arduino
import module java.base;
所以紧凑源文件可以直接使用:
javascript
void main() {
String[] fruits = {"apple", "berry", "citrus"};
Map<String, String> m = Stream.of(fruits)
.collect(Collectors.toMap(
s -> s.toUpperCase().substring(0, 1),
Function.identity()));
m.forEach((k, v) -> IO.println(k + " " + v));
}
这里 Stream、Map、Collectors、Function 都来自 java.base 模块导出的包,无需显式导入。
二、新增API/包
1. 作用域值
核心是用 ScopedValue<T> 替代 ThreadLocal,在线程内和子线程间安全、高效地共享不可变数据,作用域结束自动清理,天然适配虚拟线程与结构化并发。
作用域值的主要使用场景是在方法参数之外,将数据安全地传递给远端方法(通常用于回调)。官方明确指出,在仅需"单向传递数据"且不使用方法参数的目标下,应优先使用 ScopedValue 而非 ThreadLocal。主要原因在于 ThreadLocal 存在三个固有缺陷:
- 可变性不受控:远端被调用的代码可以随意修改
ThreadLocal的值。 - 生命周期无界:方法执行完毕后,
ThreadLocal中的值仍然保留,除非显式移除,容易造成内存泄漏。 - 继承开销大:创建子线程时,需要复制父线程的整个
ThreadLocal映射表。
相比之下,作用域值的绑定严格限定在代码块的执行期间,方法结束后自动解绑,无需手动清理,且子线程的继承是零拷贝的(仅传递指针)。
ScopedValue 的设计基于两个原则:
- 不可变性:绑定后值不可修改,杜绝状态污染;
- 作用域绑定:值只在 run() / call() 代码块内有效,退出自动解除绑定,无需手动清理。
与 ThreadLocal 对比
| 特性 | ThreadLocal | ScopedValue |
|---|---|---|
| 可变性 | 可读写,值可被篡改 | 只读,绑定后不可变 |
| 生命周期 | 绑定到线程,需手动 remove() |
绑定到作用域,自动清理 |
| 内存泄漏 | 线程池中高风险 | 零风险 |
| 子线程继承 | 需 InheritableThreadLocal,且每个线程独立复制 |
自动继承,所有子线程共享同一份数据 |
| 虚拟线程适配 | 海量线程复制开销大 | 轻量共享,内存开销极低 |
(1)基本用法:
csharp
public class ScopedValueDemo {
// 1. 声明(通常 static final)
private static final ScopedValue<String> USER = ScopedValue.newInstance();
public static void main(String[] args) {
// 2. 绑定值并执行作用域内代码
ScopedValue.where(USER, "Alice").run(() -> {
handleRequest(); // 深层调用也能访问
});
// 3. 作用域外访问会抛异常
try {
System.out.println(USER.get());
} catch (IllegalStateException e) {
System.out.println("作用域外无值");
}
}
static void handleRequest() {
System.out.println("当前用户: " + USER.get()); // Alice
}
}
(2)带返回值的 call()
kotlin
String result = ScopedValue.where(USER, "Bob").call(() -> {
return "处理用户: " + USER.get();
});
System.out.println(result); // 处理用户: Bob
(3)多值绑定(链式调用)
arduino
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();
private static final ScopedValue<String> USER_ID = ScopedValue.newInstance();
ScopedValue.where(TRACE_ID, "trace-001")
.where(USER_ID, "user-42")
.run(() -> processRequest());
(4)嵌套作用域
csharp
ScopedValue.where(USER, "Alice").run(() -> {
System.out.println(USER.get()); // Alice
ScopedValue.where(USER, "Bob").run(() -> {
System.out.println(USER.get()); // Bob
});
System.out.println(USER.get()); // Alice(恢复)
});
(5)子线程自动继承
csharp
ScopedValue.where(USER, "Alice").run(() -> {
Thread.ofVirtual().start(() -> {
System.out.println("子线程: " + USER.get()); // Alice
}).join();
});
(6)与结构化并发配合
scss
ScopedValue.where(TRACE_ID, "trace-123").run(() -> {
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
scope.fork(() -> fetchUser());
scope.fork(() -> fetchOrder());
scope.join().throwIfFailed();
}
});
static String fetchUser() {
System.out.println("traceId: " + TRACE_ID.get()); // 自动继承
return "user";
}
适用场景:
- 适用:请求上下文(用户ID、traceId、租户ID)、权限令牌、只读配置参数;
- 不适用:需要中途修改的可变状态(如累加器、事务状态),这类场景仍应使用
ThreadLocal。
与 ThreadLocal 的定位差异:ScopedValue 是 "单向传输" 机制------调用者可靠地将值传递给被调用者,被调用者无法修改这个绑定。它不适用于需要双向读写共享状态的场景。
2. 密钥派生函数 API
密钥派生函数(KDF)是一种从秘密密钥和其他数据派生额外密钥的密码学算法。它的核心目标包括:
- 支持 HKDF(RFC 5869)和Argon2(RFC 9106)等 KDF 算法;
- 支持在 KEM(密钥封装机制)实现(如 ML-KEM)中使用 KDF;
- 支持 TLS 1.3 混合密钥交换等高层协议;
- 为 JDK 实现 HPKE(混合公钥加密,RFC 9180)提供基础。
注意:PBKDF1/PBKDF2 仍走原有 SecretKeyFactory,不通过 KDF API 重构。
新增javax.crypto 和 javax.crypto.spec 包中,主要类型包括:
| 类型 | 作用 |
|---|---|
KDF |
KDF 的主入口类,通过 getInstance() 获取实例 |
KDFParameters |
KDF 参数的标记接口 |
HKDFParameterSpec |
HKDF 专用的参数规格构建器 |
新增类 javax.crypto.KDF,提供两类操作:
| 方法 | 作用 |
|---|---|
KDF.getInstance(algorithm) |
实例化并初始化 KDF |
deriveKey(String alg, AlgorithmParameterSpec spec) |
派生为 SecretKey |
deriveData(AlgorithmParameterSpec spec) |
派生为原始字节数组 |
(1)基础用法:派生 AES 密钥
ini
import javax.crypto.KDF;
import javax.crypto.SecretKey;
import javax.crypto.spec.HKDFParameterSpec;
public class KDFExample {
public static void main(String[] args) throws Exception {
// 1. 获取 HKDF-SHA256 的 KDF 实例
KDF hkdf = KDF.getInstance("HKDF-SHA256");
// 2. 准备输入数据
byte[] initialKeyMaterial = "my-secret-material".getBytes();
byte[] salt = "random-salt-value".getBytes();
byte[] info = "my-app-context".getBytes();
// 3. 构建 HKDF 参数:先 Extract 再 Expand
HKDFParameterSpec params = HKDFParameterSpec.ofExtract()
.addIKM(initialKeyMaterial)
.addSalt(salt)
.thenExpand(info, 32); // 派生 32 字节密钥
// 4. 派生 AES 密钥
SecretKey aesKey = hkdf.deriveKey("AES", params);
System.out.println("算法: " + aesKey.getAlgorithm());
System.out.println("密钥长度: " + aesKey.getEncoded().length + " 字节");
}
}
(2)使用不同上下文派生多个独立密钥。HKDF 的一个重要用途是从同一份初始密钥材料派生出多个密码学上独立的密钥。关键在于为每个密钥使用不同的 info 上下文:
less
// 派生 AES 密钥,使用 AES 专用的上下文
SecretKey aesKey = hkdf.deriveKey("AES",
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret)
.addSalt(salt)
.thenExpand("MyProtocol v1.0 | AES-key".getBytes(), 32));
// 派生 HMAC 密钥,使用 HMAC 专用的上下文
SecretKey hmacKey = hkdf.deriveKey("HmacSHA256",
HKDFParameterSpec.ofExtract()
.addIKM(sharedSecret)
.addSalt(salt)
.thenExpand("MyProtocol v1.0 | HMAC-key".getBytes(), 32));
// 两个密钥是独立派生的,不能互相推导
(3)只派生原始字节:
ini
byte[] ikm = "shared-secret-material".getBytes(StandardCharsets.UTF_8);
byte[] salt = "random-salt-value".getBytes(StandardCharsets.UTF_8);
KDF hkdf = KDF.getInstance("HKDF-SHA256");
HKDFParameterSpec spec = HKDFParameterSpec.ofExtract()
.addIKM(ikm)
.addSalt(salt)
.thenExpand("context".getBytes(StandardCharsets.UTF_8), 64);
byte[] derived = hkdf.deriveData(spec); // 直接拿到 64 字节
适用场景:
- 适用:协议密钥派生、KEM/HPKE 密钥安排、TLS 1.3 混合密钥交换、从共享密钥派生多把子密钥;
- 不适用:用户密码哈希存储应继续使用 SecretKeyFactory + PBKDF2,或第三方 Argon2 库;
- 扩展性:第三方安全提供者可通过扩展 javax.crypto.KDFSpi 实现自定义 KDF 算法。
三、工具与虚拟机更新
1. AOT 命令行易用性
通过简化常用场景所需的命令,使创建 AOT 缓存更加容易。其目标是简化创建 AOT 缓存的过程,同时不损失表达能力,也不引入全新的 AOT 工作流。
Java 24 中创建 AOT 缓存需要两步:先运行一次应用来"录制"配置(-XX:AOTMode=record),再运行一次来"创建"缓存(-XX:AOTMode=create)
ruby
# Java 24 旧流程
java -XX:AOTMode=record -XX:AOTConfiguration=app.aotconf -cp app.jar com.example.App
java -XX:AOTMode=create -XX:AOTConfiguration=app.aotconf -XX:AOTCache=app.aot -cp app.jar
Java 25 新增了 -XX:AOTCacheOutput 选项,将这两步合并为一步。
ini
# Java 25一步完成
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App
# 后续生产运行直接使用缓存
java -XX:AOTCache=app.aot -cp app.jar com.example.App
2. AOT 方法分析
通过让 HotSpot JVM 在启动时立即可用上一次运行的方法执行剖面数据,来改善预热时间。这使 JIT 编译器能够在应用启动时立即生成原生代码,而无需等待剖面数据被收集。
传统上,JVM 需要先运行一段时间来收集"哪些方法是热点"的剖面数据,然后 JIT 才能编译优化。
Java 25 将剖面收集工作转移到训练运行阶段,并通过 AOT 缓存传递到生产运行,从而缩短生产环境的预热期。
ini
# 训练运行:同时收集类信息和 profiling 数据
java -XX:AOTCacheOutput=app.aot -cp app.jar com.example.App
# 生产运行:JIT 直接使用缓存中的 profiling 数据
java -XX:AOTCache=app.aot -cp app.jar com.example.App
3. JFR 协作采样
改进 JDK Flight Recorder 在异步采样 Java 线程栈时的稳定性。实现方式是仅在安全点(safepoint)遍历调用栈,同时最小化安全点偏差问题。
JFR 为了做性能分析,需要定期抓取线程栈。旧方案在非安全点异步抓取,依赖有风险的启发式规则来解析栈帧,可能导致 JVM 崩溃。
新方案改为"协作式":采样线程记录目标线程的程序计数器和栈指针,目标线程继续运行,然后让目标线程在下一个安全点自行完成栈遍历,从而彻底避免不安全的栈解析。
这不需要额外命令行开关,Java 25 中使用 JFR 采样时会自动生效。
ini
# 正常开启 JFR 采样即可
java -XX:StartFlightRecording:duration=60s,filename=app.jfr -jar app.jar
4. JFR 方法计时与追踪
通过字节码插桩扩展 JFR 的方法计时和追踪能力。你可以指定要监控的方法、类或注解,JFR 会记录这些方法的调用次数和执行耗时(MethodTiming),或记录每次调用的堆栈(MethodTrace)。支持按类名、方法名、注解进行过滤,也支持通过 XML 配置文件进行批量配置。
- jdk.MethodTrace:追踪方法调用、调用栈和执行时间;
- jdk.MethodTiming:统计调用次数、最小/平均/最大耗时。
ruby
# 监控所有标注了 @GET 的方法(如 Jakarta REST 端点)
jcmd <pid> JFR.start method-timing=@jakarta.ws.rs.GET
# 追踪 FileDescriptor 构造器的调用,用于排查文件描述符泄漏
java -XX:StartFlightRecording:filename=fd.jfr,method-trace=java.io.FileDescriptor::<init> -jar app.jar
5. 紧凑对象头
核心目标是将 64 位架构上 HotSpot JVM 的对象头大小,从传统的 96 位(12 字节)压缩到 64 位(8 字节),从而减少堆内存占用、提升部署密度并改善数据局部性。
在传统的 HotSpot JVM 中,每个 Java 对象在内存开头都有一个强制性的对象头,由两部分组成:
- 标记字(Mark Word):64 位,存储对象的身份哈希码、GC 年龄、锁状态等元数据;
- 类指针(Class Pointer):32 位(压缩引用),指向元空间中该对象的类元数据。
因此,一个普通对象在 64 位 JVM 上(启用压缩引用时)通常需要 12 字节的对象头。对于实际应用中常见的 32-64 字节的小对象,这个对象头会消耗超过 20% 的堆空间。
紧凑对象头把标记字和类指针合并成一个 64 位结构,其关键改进是将类指针从 32 位压缩到 22,同时保留了哈希码、GC 年龄和锁状态等信息,并为 Project Valhalla 预留了 4 个位。22 位类指针意味着 JVM 最多可支持约400 万个唯一的类,这远远超出了任何实际应用的需求。
压缩后的 64 位布局大致包含:
- 压缩类指针:22 位,可支持约 400 万类;
- 哈希码:31 位,保留完整哈希码范围;
- GC 年龄:4 位;
- 其他状态位:Tag、Valhalla 相关位、保留位等。
6. 分代 Shenandoah GC
为 Shenandoah 引入年轻代/老年代分离回收,在保持极低暂停时间的同时提升吞吐量、降低 CPU 和内存开销。
Shenandoah 原本采用全堆并发标记和整理,暂停时间与堆大小无关,但每次回收都要扫描整个堆。根据弱代际假说,大多数对象"朝生夕死",年轻对象回收成本低、收益高,老年对象回收成本高、收益低。分代化后,Shenandoah 可以:
- 更频繁地回收年轻代;
- 减少全堆扫描次数;
- 降低并发标记和并发疏散的 CPU 开销;
- 提升分配吞吐;
- 降低内存占用。
在 Java 25 中,启用分代 Shenandoah 的命令变得简洁直接:
ruby
# Java 25 中的启用方式(不再需要 UnlockExperimentalVMOptions)
java -XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational -jar your-app.jar
关键参数:
- -XX:+UseShenandoahGC:启用 Shenandoah
- -XX:ShenandoahGCMode=generational:启用分代模式
- -XX:ShenandoahGCHeuristics=adaptive:自适应触发策略,通常保持默认
- -XX:ShenandoahTargetPercentage=95:GC 预留空间比例,调低会节省内存但增加退化风险
- -XX:ShenandoahAllocSpikeFactor=5:分配尖峰因子,控制 GC 提前量
- -Xlog:gc*:输出 GC 日志,便于观察分代行为
四. 移除与弃用
- 移除 32 位 x86 端口