前言

关注 JDK 新特性中的什么内容

学习 JDK 新特性,最容易遇到的问题就是:版本很多,特性更多,但是看完一长串名称之后,还是不知道它们到底解决了什么问题。

对企业开发来说,真正值得关注的变化,通常会影响下面几件事:

  • 业务代码怎么写:Lambda、Stream、Record、模式匹配。
  • 并发任务怎么组织:CompletableFuture、虚拟线程、ScopedValue。
  • 应用怎么运行:容器资源识别、垃圾收集器、启动与预热优化。
  • 老项目怎么升级:模块封装、默认字符集、API 移除、Agent 与本地访问限制。

所以,这篇文章只介绍重要的新特性和对企业开发影响较大的调整。某些版本只是在继续预览同一项特性,就放到该特性正式发布的版本中说明,不重复列一遍。

资料核对日期:2026 年 10 月 5 日。本文覆盖 JDK 8 至 JDK 27;JDK 27 已于 2026 年 9 月 15 日正式发布。 发布状态以 Oracle JDK 27 发布说明为依据。

如果没有特殊说明,JVM 实现相关内容针对 HotSpot;示例均为 Java SE / JDK API

常见面试题和工程问题:

  • Lambda、Stream 和普通循环有什么不同?
  • CompletableFuture 的 Async 方法一定使用新线程吗?
  • var 会让 Java 变成动态类型语言吗?
  • List.of() 的不可修改,是否代表元素也不可变?
  • Record 能直接替代所有 JavaBean 和 ORM 实体吗?
  • 虚拟线程为什么适合大量阻塞 I/O?为什么不能让 CPU 计算无限加速?
  • 虚拟线程的 synchronized 固定问题在哪个版本发生变化?
  • JDK 8 升级到 17、21、25,为什么旧代码可能编译成功却运行失败?
  • JDK 21、23、24 的 ZGC 启动参数有什么区别?
  • 正式特性、预览特性和孵化 API,有什么不同?

理解版本和特性状态

LTS 是支持策略,不是一种额外的语言能力

我们经常听到 JDK 8、11、17、21、25 是 LTS 版本。

LTS(Long-Term Support,长期支持)主要描述发行商的维护政策。 Oracle 将这些版本列为 LTS,但不同发行商的支持年限、更新渠道和条款可能不同,不能把某一家发行商的政策当作整个 OpenJDK 项目的统一承诺。具体策略参考 Oracle Java SE 支持路线图。

比如,Record 在 JDK 16 正式发布,即使 JDK 16 不是 Oracle 的 LTS 版本,它仍然是正式语言特性;升级到 JDK 17 后也可以使用。

而且“出现过” 和 “正式可用” 是两件事

状态 含义 使用时需要注意什么
正式特性 / 正式 API 已进入该版本的正式规范或受支持实现 通常不需要启用预览;某些 JVM 功能仍需显式开启
Preview,预览 设计和实现供开发者评估,尚未永久定型 一般需要 --enable-preview,版本之间可能修改或撤回
Incubator,孵化 用孵化模块提供尚未稳定的 API 通常需 --add-modules jdk.incubator.xxx,不能当作稳定 API
Experimental,实验性 JVM 功能 用于验证 JVM 实现方案 常需 -XX:+UnlockExperimentalVMOptions,不等于正式功能

例如,虚拟线程在 JDK 19、20 中预览,在 JDK 21 正式发布;Record 在 14、15 中预览,在 JDK 16 正式发布。版本归属需要区分第一次出现和正式发布。Oracle 语言变化汇总、JEP 444:Virtual Threads记录了这些阶段。

使用预览特性时,要用对应版本的 JDK 编译和运行,升级后重新核对 API。下面正文的完整示例只使用正式特性,均不需要 --enable-preview。

重要版本速览

JDK 本文关注的主要变化 对企业开发的主要影响
8 Lambda、Stream、Optional、java.time、CompletableFuture、元空间 业务写法、异步编排、内存配置
9 模块系统、集合工厂、紧凑字符串、G1 默认策略、统一日志 依赖边界、部署、内存与运维
10 var、容器资源识别、Application CDS 可读性、容器容量规划与启动
11 标准 HttpClient、OpenJDK JFR、TLS 1.3、移除部分内置模块 HTTP 调用、诊断与老项目兼容
14 switch 表达式、移除 CMS 业务分支、GC 配置迁移
15 文本块、ZGC / Shenandoah 转为生产功能 SQL 等多行文本、低延迟 GC
16 Record、instanceof 模式匹配 数据传输对象、类型判断
17 sealed、进一步强封装 JDK 内部实现 领域建模、反射兼容性
18 默认 UTF-8、finalization 标记为待移除 文件编码、资源生命周期
21 虚拟线程、模式匹配 switch、Record 模式、有序集合接口、分代 ZGC 并发、业务模型、集合与 GC
22 FFM API 正式发布 本地函数调用、堆外内存管理
23 分代 ZGC 成为 ZGC 默认模式 JVM 参数与 GC 策略
24 减少虚拟线程固定、Stream Gatherers、Class-File API、AOT 缓存、本地访问告警 并发扩展、批处理、工具链与启动
25 ScopedValue 正式发布、紧凑对象头转正、AOT 演进、灵活构造器 请求上下文、内存、预热与建模
26 HTTP/3、反射修改 final 告警、AOT 对象缓存支持所有 GC 网络、框架兼容与启动
27 G1 默认范围扩大、紧凑对象头默认开启、TLS 混合密钥交换、JFR 脱敏 容量规划、安全与诊断

JDK 12、13、19、20 不单独展开,本文选中的 switch、文本块、虚拟线程等变化,在正式发布版本中统一讲解。这并不表示这些版本没有改进。

JDK 8:现代 Java 编程方式的基础

Lambda 与函数式接口

在 JDK 8 之前,如果我们想把“一个操作”传给另一个方法,通常需要匿名内部类。

Lambda 让这件事变得更直接,它能把一段行为当作参数传递。 比如,把“如何筛选用户”传给集合处理流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// JDK 8,代码片段:函数式接口只有一个抽象方法
@FunctionalInterface
interface DiscountPolicy {
int discount(int amount);

// 默认方法可以拥有实现,不计入“一个抽象方法”
default String description() {
return "优惠策略";
}
}

// 在方法内部使用
DiscountPolicy policy = amount -> amount >= 100 ? 10 : 0;
int discount = policy.discount(200); // 结果为 10

amount -> ... 就是 discount 方法的具体行为。Lambda 的目标类型必须是函数式接口;它也可以引用外部局部变量,但该变量必须是 final 或“事实上不再被赋值”的 effectively final。

同时,接口可以定义 default 方法。这样为接口添加某些新能力时,旧实现类不必全部补上实现。不过多个接口默认方法发生冲突时,仍需要实现类明确解决。参考 Oracle:Java SE 8 语言增强。

Stream:把集合处理写成流水线

我们看一下“筛选姓张的用户,再生成展示文本”怎么写。

下面示例最低版本:JDK 8。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

public class StreamDemo {
public static void main(String[] args) {
List<String> names = Arrays.asList("张三", "李四", "张小明");
List<String> result = names.stream()
.filter(name -> name.startsWith("张")) // 过滤:保留姓张的名字
.map(name -> "用户:" + name) // 转换:生成展示文本
.collect(Collectors.toList()); // 终止操作:真正执行流水线
System.out.println(result);
}
}

运行结果:

1
[用户:张三, 用户:张小明]

这个处理过程可以画成一条流水线:

flowchart LR
    A[原始名字列表] --> B[filter:过滤]
    B --> C[map:转换]
    C --> D[collect:收集结果]

Stream 不是保存数据的容器,它描述的是数据的处理过程。 filter、map 是中间操作,通常具有惰性;终止操作触发遍历,一个 Stream 使用终止操作后不能再重复消费。Java SE 8 Stream 包说明明确了这些语义。

注意两个容易误解的地方:

  • stream() 默认是顺序流。写成流水线不代表自动多线程。
  • parallelStream() 也不保证一定更快。数据量、拆分成本、排序、共享状态都会影响收益,不能把数据库调用随意塞进去就期待吞吐量提升。

所以,Stream 更适合表达清楚的过滤、转换、聚合。复杂分支和大量副作用仍然可以使用普通循环。

Optional:表达结果可能不存在

Optional 是一个可能包含非 null 值的容器。 它比较适合表达查询方法的返回结果,比如按 id 查用户,有可能查不到。

下面示例最低版本:JDK 8。

1
2
3
4
5
6
7
8
9
10
11
import java.util.Optional;

public class OptionalDemo {
public static void main(String[] args) {
String nickname = null;
String displayName = Optional.ofNullable(nickname)
.filter(name -> !name.trim().isEmpty())
.orElseGet(() -> "匿名用户"); // 没有有效值时,才调用 Supplier
System.out.println(displayName);
}
}

输出为 匿名用户。如果把 nickname 改成一个有效名字,就使用该名字。

我们再看一下源码。下面摘自 OpenJDK JDK 25 的 Optional 实现;用于解释自 JDK 8 就存在的 ofNullable,并不是说它到 25 才出现:

1
2
3
4
public static <T> Optional<T> ofNullable(T value) {
return value == null ? (Optional<T>) EMPTY
: new Optional<>(value);
}

可以看到,传入 null 时得到空 Optional,否则包装这个值。Optional 不会让被包装的对象自动变成非 null;它把“可能为空”变成一种明确的返回类型。 源码参考 OpenJDK Optional.java,jdk-25+36。

orElse(expensiveCall()) 中的参数表达式会先求值;orElseGet(() -> expensiveCall()) 只有缺值时才调用 Supplier。不要无条件调用 get(),空值时会抛出异常,也不要给 Optional 变量本身赋 null。Java SE 8 Optional API给出了这些方法的契约。

java.time:把时间点和本地时间区分开

以前使用 Date、Calendar、SimpleDateFormat,常常要额外考虑可变对象、线程安全和时区。

JDK 8 引入了新的日期时间 API。先记住下面几个类型的职责:

类型 表达什么 常见用途
Instant 时间线上的一个瞬间 创建时间、事件发生时间
LocalDate 不带时区的日期 生日、营业日期
LocalDateTime 不带时区的日期和时间 表达某地约定的日历时间
ZonedDateTime 带时区的日期和时间 跨地区排期、时区转换
Duration 以秒、纳秒为基础的时间量 超时、耗时

下面示例最低版本:JDK 8。

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

public class TimeDemo {
public static void main(String[] args) {
Instant createdAt = Instant.parse("2026-10-05T00:00:00Z");
// 同一个时间点,按上海时区转换成当地日期时间
LocalDateTime local = LocalDateTime.ofInstant(
createdAt, ZoneId.of("Asia/Shanghai"));
System.out.println(local);
}
}

输出为 2026-10-05T08:00。这是 UTC 时间 00:00 按上海时区转换得到的本地时间。

LocalDateTime 自身不能唯一确定一个全球时间点。 如果只有“2026 年 10 月 5 日上午 8 点”,还需要时区才能映射到时间线;遇到夏令时切换还要考虑当地时间的重叠和缺口。

新 API 的主要值类型采用不可变设计,适合安全共享。订单时间、审计事件等数据要明确约定存储语义和时区,不能仅靠“把 Date 换成 LocalDateTime”解决全部问题。参考 Java SE 8 java.time 包说明。

CompletableFuture:组合异步任务

一个接口同时需要用户信息和积分。如果先查用户,再查积分,两个等待过程就串在一起了。

CompletableFuture 让我们可以表达:两个任务分别执行,完成后组合结果。

下面示例最低版本:JDK 8。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CompletableFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService ioPool = Executors.newFixedThreadPool(2);
try {
CompletableFuture<String> user = CompletableFuture.supplyAsync(
() -> "张三", ioPool); // 模拟读取用户信息
CompletableFuture<Integer> points = CompletableFuture.supplyAsync(
() -> 100, ioPool); // 模拟读取积分
String result = user.thenCombine(points,
(name, score) -> name + ",积分:" + score)
.get(2, TimeUnit.SECONDS); // 限制调用方等待时间
System.out.println(result);
} finally {
ioPool.shutdown(); // JDK 8 的 ExecutorService 还不能用于 try-with-resources
}
}
}

结果为 张三,积分:100。示例中的两个任务只是模拟返回数据,没有进行真实网络调用。

flowchart LR
    A[收到请求] --> B[读取用户信息]
    A --> C[读取积分]
    B --> D[thenCombine:组合两个结果]
    C --> D
    D --> E[返回展示数据]

这里显式指定了线程池,所以任务的执行资源是可见的。未指定执行器的 Async 方法通常使用 ForkJoinPool.commonPool();不带 Async 的后续操作可能由完成前一个任务的线程执行,并不保证切换线程。具体策略见 CompletableFuture API。

另外,get(2, TimeUnit.SECONDS) 只限制当前调用方等待结果的时间,不保证底层任务被中断。真实 HTTP、数据库调用仍需要配置自身超时;取消、异常传播和线程池容量也需要一起设计。

永久代移除,类元数据进入本地内存

从 JDK 8 开始,HotSpot 移除了永久代,类元数据改为使用本地内存中的元空间。老脚本中的 PermSize、MaxPermSize 需要清理,相关限制改为考虑 MaxMetaspaceSize。

1
-XX:MaxMetaspaceSize=256m

元空间不占 Java 堆,但仍占进程内存。 这里的“本地内存”也不能简单等同于 ByteBuffer.allocateDirect() 所管理的直接缓冲区。容器内存预算不能只看 -Xmx。参考 Oracle:Class Metadata。

JDK 9:模块化与运行时结构的变化

模块系统:把依赖和开放边界写出来

JDK 8 的 classpath 很像把所有 JAR 放在同一个仓库里:只要找到类,很多内部实现也能被应用依赖。

模块系统进一步表达“我依赖谁”“我允许谁访问哪些包”。 JDK 自身也被拆成模块,传统 rt.jar 等内部文件布局随之变化。Java 模块与 Maven 模块并不是同一个概念。参考 JEP 261:Module System和 java.lang.module 包说明。

1
2
3
4
5
6
7
// JDK 9,module-info.java,独立的模块声明示意
module com.example.orders {
requires java.sql; // 依赖 JDBC 模块
exports com.example.orders.api; // 对外提供公开 API
opens com.example.orders.entity
to com.example.mapper; // 只向指定框架模块开放深反射
}

这段声明假设 API 包、实体包和 com.example.mapper 模块实际存在;它不是一个可以单文件运行的 main 示例。

  • requires:声明模块依赖。
  • exports:开放包的公共 API 访问。
  • opens:开放运行时反射访问,包括适用条件下的非 public 成员。

exports 不等于 opens。 一个包允许正常调用 public 方法,不代表框架就能反射访问里面的 private 字段。

flowchart LR
    A[订单模块] -->|requires| B[java.sql]
    C[业务调用模块] -->|访问 exports 的 API 包| A
    D[映射框架模块] -->|深反射访问 opens 的实体包| A

企业项目不必为了升级 JDK,立即把所有业务 JAR 改成具名模块。传统 classpath 仍可使用;但依赖 JDK 内部 API 的代码仍需要迁移。真正的影响经常落在反射框架、字节码工具、旧启动脚本和自定义类加载器上。

模块化还带来了 jlink:为模块化应用组合需要的运行时模块。它可以缩小运行时分发内容,但仍要处理第三方依赖、自动模块等限制,不能把任意老项目 JAR 原样交给它就认为完成了裁剪。

集合工厂:创建不可修改集合更直接

JDK 9 增加 List.of()、Set.of()、Map.of();JDK 10 又增加 copyOf() 系列。

下面示例最低版本:JDK 9。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.util.ArrayList;
import java.util.List;

public class CollectionFactoryDemo {
public static void main(String[] args) {
List<String> fixed = List.of("订单", "支付");
try {
fixed.add("库存"); // 不可修改集合,抛出 UnsupportedOperationException
} catch (UnsupportedOperationException e) {
System.out.println("List.of 创建的集合不能添加元素");
}
List<String> mutable = new ArrayList<>(fixed);
mutable.add("库存"); // 需要修改时,显式创建可变副本
System.out.println(mutable);
}
}

这些集合适合表示固定配置、结果快照等内容,但需要明确它们的边界:

  • of() 创建的集合不允许 null。
  • Set.of() 的重复元素、Map.of() 的重复键会抛出异常。
  • 集合不可修改,不代表里面的对象不可变。 保存的是可变对象时,对象内部仍可能变化。
  • 不要依赖 Set.of()、Map.of() 的迭代顺序。

Collections.unmodifiableList(source) 通常是包装原列表的视图,原列表改变会反映到视图。List.copyOf(source) 则不会跟随 source 后续的结构变化,不过仍是浅拷贝,也不保证一定创建新实例。参考 JDK 9 List API、JEP 269。

紧凑字符串:从 char[] 到 byte[] 加编码标记

在 JDK 8 的 String 实现中,字符内容由 char[] 保存。即使字符串只包含英文和数字,每个 UTF-16 代码单元也需要两个字节。

JDK 9 的 Compact Strings 改为根据内容选择 Latin-1 或 UTF-16 存储。下面两个字段摘自 OpenJDK JDK 25 的 String 实现:

1
2
private final byte[] value;
private final byte coder;

value 保存内容,coder 标识内部编码。可以用下面这张示意图理解:

flowchart TD
    A[创建字符串] --> B{是否可用 Latin-1 表示}
    B -->|可以| C[byte 数组,每个字符一个字节]
    B -->|不可以| D[byte 数组,以 UTF-16 保存代码单元]
    C --> E[coder 标识内部编码]
    D --> E

这是 JVM 类库内部的存储优化,没有把 String 的对外语义改成 UTF-8。 length()、charAt() 仍按 UTF-16 代码单元工作;中文字符串通常不能享受 Latin-1 带来的那部分内容压缩。

对大量英文标识、JSON 字段名等场景,这可能降低内存开销,但不能推出“所有字符串对象都缩小一半”。对象头、数组头和对齐也占空间。参考 JEP 254、OpenJDK String.java,jdk-25+36。

G1 默认策略与统一 JVM 日志

JDK 9 将 G1 设为 server 配置的默认垃圾收集器。以前常见的“JDK 9 后所有环境默认都是 G1”说法并不严谨;全环境默认策略是在 JDK 27 进一步改变的。见 Oracle JDK 9 新特性说明。

同时,JDK 9 引入统一 JVM 日志。GC 日志通常用下面的形式配置:

1
java -Xlog:gc*:file=gc.log:time,uptime,level,tags:filecount=5,filesize=20M -jar app.jar

这里按 GC 标签记录日志,并配置轮转。企业升级时应同步检查日志采集和解析规则,不能继续假定新版本日志与 JDK 8 的 PrintGCDetails 输出完全一致。

JDK 10:局部变量推断与容器运行

var:减少重复类型信息

var 是编译期局部变量类型推断,不是动态类型。

下面示例最低版本:JDK 10。

1
2
3
4
5
6
7
8
9
10
import java.util.ArrayList;

public class VarDemo {
public static void main(String[] args) {
var names = new ArrayList<String>(); // 编译期推断为 ArrayList<String>
names.add("张三");
// names = 123; // 编译错误:变量类型不会因为使用 var 而改变
System.out.println(names.get(0));
}
}

编译器看右边的 new ArrayList<String>(),就知道 names 是什么类型,所以后面仍然进行静态类型检查。

var 不能直接用于字段、方法返回类型或普通方法参数;也不能写 var value = null,因为推断不出有用的类型。它适合右侧已经清楚说明类型的局部变量。var result = service.process() 这种写法如果隐藏了关键信息,就不一定值得使用。参考 Oracle:Local Variable Type Inference。

容器资源识别:JVM 需要知道自己实际能用多少资源

一个容器只能使用 2GB 内存,但宿主机有 64GB。如果 JVM 按宿主机资源估算堆大小和并行度,就可能超过容器边界。

JDK 10 改善了 Linux 容器中的 CPU、内存资源识别,并提供 UseContainerSupport、RAM 百分比等相关参数;部分能力也回移到了 8u191 等更新版本。是否完整识别 cgroups v2 等环境,还要看具体更新版本和发行商构建。参考 JDK 10 发布说明、JDK 8u191 发布说明。

1
java -XX:MaxRAMPercentage=60.0 -jar app.jar

这个例子只是一个比例配置,60% 并不是通用最佳值。进程总内存还包含元空间、线程栈、直接缓冲区、GC 数据结构等;容器预算必须覆盖这些部分。

在支持相关日志标签的现代 JDK 上,可以用下面的命令检查识别情况:

1
java -Xlog:os+container=trace -version

JDK 10 的另一项重要变化是 Application CDS(JEP 310):把类数据共享扩展到应用类,减少重复的类加载相关工作。后面的 AOT 优化是在这个方向上继续演进,见 Oracle:Class Data Sharing。

JDK 11:标准 HTTP 客户端与生产诊断

HttpClient 正式进入标准库

新 HTTP 客户端在 JDK 9、10 中孵化,JDK 11 以 java.net.http API 正式发布,支持 HTTP/1.1、HTTP/2,以及同步、异步请求。

下面示例最低版本:JDK 11。 为了可以直接运行,示例先启动一个临时本地 HTTP 服务,再请求它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import com.sun.net.httpserver.HttpServer;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

public class HttpClientDemo {
public static void main(String[] args) throws Exception {
// 启动临时本地服务,让示例不依赖外网;生产项目通常调用已有服务
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/health", exchange -> {
byte[] body = "ok".getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (var output = exchange.getResponseBody()) {
output.write(body);
}
exchange.close();
});
server.start();
try {
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/health"))
.timeout(Duration.ofSeconds(3)).GET().build();
HttpResponse<String> response = client.send(
request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.statusCode() + " " + response.body());
} finally {
server.stop(0);
}
}
}

输出为 200 ok。

connectTimeout 限制建立连接的等待;请求的 timeout 约束该请求。两者不是同一个概念。生产中通常复用 HttpClient,而不是每个请求都创建一个,从而复用连接等资源。

sendAsync() 返回 CompletableFuture,可以继续组合响应。但 HTTP 状态码 4xx、5xx 是收到的响应,不会仅仅因为状态码本身就自动变成 Java 异常,业务代码仍需要判断。内置客户端也不等于自动拥有重试、熔断和业务监控。参考 JDK 11 HttpClient API、JEP 321。

JFR:把运行时行为记录下来

JFR(JDK Flight Recorder)并不是到 JDK 11 才第一次诞生;重要变化是 JDK 11 通过 JEP 328 将它贡献到 OpenJDK,成为广泛可用的诊断能力。

它可以记录 GC、线程、锁、分配、方法采样和 I/O 等事件,帮助我们理解“为什么这个时间段变慢了”。

1
java -XX:StartFlightRecording=filename=app.jfr,settings=profile,duration=60s -jar app.jar

也可以对正在运行的进程启动记录:

1
jcmd <pid> JFR.start name=incident settings=profile duration=60s filename=incident.jfr

default 配置偏向低开销持续记录,profile 收集更多分析数据。开销取决于启用的事件和阈值,不能保证对所有应用都固定小于某个百分比。参考 Oracle JDK 11:Diagnostic Tools和 JFR 性能排查说明。

TLS 1.3 与内置模块移除

JDK 11 支持 TLS 1.3,这是网络安全的重要演进。同时,JDK 移除了部分过去随 JDK 提供的 Java EE、CORBA 模块,例如 JAXB、JAX-WS。

如果旧项目默认依赖 javax.xml.bind 这类 JDK 自带 API,升级后可能出现编译错误或 ClassNotFoundException。需要显式引入适配的外部依赖。这也不意味着所有 javax.* 包都被移除,例如 javax.sql、javax.crypto 仍有各自的标准 API。参考 Oracle JDK 迁移指南:JDK 11 移除项与 TLS 1.3。

JDK 14:switch 可以产生结果

switch 表达式

以前的 switch 主要用于执行分支语句。若想计算一个值,常常要提前声明变量,再在每个分支中赋值,而且还要注意是否遗漏 break。

JDK 14 正式发布 switch 表达式:直接让整个 switch 返回计算结果。 它在 JDK 12、13 中经历过预览。见 Oracle 语言变化汇总、JEP 361。

下面示例最低版本:JDK 14。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SwitchDemo {
enum OrderStatus { CREATED, PAID, CLOSED }

static String label(OrderStatus status) {
return switch (status) {
case CREATED -> "待支付";
case PAID -> "已支付";
case CLOSED -> {
String reason = "订单已经关闭";
yield reason; // 为整个 switch 表达式产生结果
}
};
}

public static void main(String[] args) {
System.out.println(label(OrderStatus.CLOSED));
}
}

可以看到,label 方法直接返回 switch 的结果。

  • case ... -> 不会像传统冒号分支那样自动贯穿到下一个分支。
  • 分支需要多条语句时,用代码块和 yield 产生该分支的结果。
  • 表达式需要穷尽可能的输入情况。示例覆盖了枚举全部常量,因此可以省略 default。

注意,这里的普通 switch 表达式,与 JDK 21 的模式匹配 switch 是两次不同的演进。 不能因为写了箭头,就认为 JDK 14 已支持 case SomeType value 这类类型模式。

CMS 被移除

CMS 在 JDK 9 被标记为废弃,并在 JDK 14 移除。如果旧启动脚本还带着 -XX:+UseConcMarkSweepGC,升级时必须清理并重新选择 GC 配置。参考 JEP 363:Remove the Concurrent Mark Sweep Garbage Collector。

更换收集器之后要重新观察暂停时间、吞吐量和内存开销,不能把 CMS 的整套调优参数直接搬给 G1。

JDK 15:文本块与低延迟垃圾收集器

文本块:多行文本不必反复拼接

SQL、JSON、HTML、测试报文经常跨多行。JDK 15 正式发布文本块,减少换行和引号的转义噪声。

下面示例最低版本:JDK 15。

1
2
3
4
5
6
7
8
9
10
public class TextBlockDemo {
public static void main(String[] args) {
String sql = """
SELECT id, amount
FROM orders
WHERE user_id = ?
"""; // 保留参数占位符,业务中交给 PreparedStatement 绑定
System.out.print(sql);
}
}

输出就是三行 SQL。文本块仍然产生普通 String,可以传给接受 String 的 API。

文本块不是原始字符串,也不是自动的字符串插值。 它会处理转义、规范化行结束符,并去除附带缩进;关闭分隔符的位置还会影响末尾是否保留换行。需要精确比较报文或签名内容时,要检查最终字符串。参考 Oracle:Programmer’s Guide to Text Blocks。

示例中的 ? 特意保留了 SQL 参数占位符。使用文本块并不会自动防止 SQL 注入,真正的参数仍需要交给 PreparedStatement 等机制绑定。

ZGC、Shenandoah 转为生产功能

ZGC 在 JDK 11 引入时是实验性收集器,在 JDK 15 转为生产功能;Shenandoah 在 JDK 12 引入,也在 JDK 15 转为生产功能。对应 JEP 377:ZGC、JEP 379:Shenandoah。

它们重点解决低暂停需求。是否适合应用,要结合 CPU 预算、堆大小、分配速率和吞吐量测试;“转为生产功能”也不表示自动成为默认收集器,发行包和平台还可能影响可用性。

ZGC 的后续分代演进和启用参数,放在 JDK 21—24 的章节中统一说明。

JDK 16:Record 与 instanceof 模式匹配

Record:为数据载体减少样板代码

一个返回给前端的用户视图,可能只包含 id、名字和角色。传统类通常需要写字段、构造器、访问器以及 equals、hashCode、toString。

Record 用声明组件的方式表达“这个类型主要承载这些数据”。 它在 JDK 14、15 中预览,在 JDK 16 正式发布。

下面示例最低版本:JDK 16。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.ArrayList;
import java.util.List;

public class RecordDemo {
record UserView(long id, String name, List<String> roles) {
UserView {
if (id <= 0) {
throw new IllegalArgumentException("id 必须为正数");
}
roles = List.copyOf(roles); // 防御性复制,避免外部修改原列表
}
}

public static void main(String[] args) {
List<String> source = new ArrayList<>();
source.add("USER");
UserView user = new UserView(1, "张三", source);
source.add("ADMIN");
System.out.println(user.name()); // 访问器是 name(),不是 getName()
System.out.println(user.roles()); // [USER],没有跟随原列表变化
}
}

运行结果:

1
2
张三
[USER]

编译器为组件生成 private final 字段、同名访问器,并按规则生成构造器、equals、hashCode、toString。上面的 UserView { ... } 是紧凑规范构造器:参数与组件对应,构造器结束时,组件字段按规则被初始化。参考 Oracle:Record Classes、JEP 395。

那么,Record 是不是完全不可变?

Record 只保证组件字段的引用不可重新赋值,并不递归冻结被引用对象。 如果直接保存外部可变 List,外部修改列表仍可能改变 Record 表现出的内容。所以示例用了 List.copyOf(roles);如果列表元素自身可变,这仍不构成深不可变。

flowchart LR
    A[Record 的 final 字段] --> B[引用一个 List]
    B --> C[列表中的元素]
    D[final 限制字段重新赋值] -.-> A
    E[元素是否可变由元素类型决定] -.-> C

Record 适合 DTO、查询投影、事件、值对象等明确的数据载体。它隐式 final,不能作为普通可继承实体使用,也不提供可随意 set 的组件。依赖无参构造、setter、继承代理等机制的框架,需要确认对应版本的适配能力。

instanceof 模式匹配:类型判断后直接使用变量

以前通常要先 instanceof,再显式强制类型转换。JDK 16 把这两步连在一起。

下面示例最低版本:JDK 16。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InstanceofDemo {
static int length(Object value) {
// && 右侧只有在匹配成功后才执行,因此可以安全使用 text
if (value instanceof String text && !text.isBlank()) {
return text.length();
}
return 0;
}

public static void main(String[] args) {
System.out.println(length("Java"));
System.out.println(length(null)); // instanceof 对 null 的结果为 false
}
}

输出为 4 和 0。

text 只在编译器可以证明匹配成功的路径中可用。&& 右侧需要左侧为 true,因此可以访问它;简单地改成 || 后,右侧在类型不匹配时也可能执行,不能照搬同一个变量用法。

这叫流作用域(flow scoping),它根据控制流分析保证变量使用安全。参考 Oracle:Pattern Matching with instanceof、JEP 394。

JDK 17:限制继承边界与加强封装

sealed:明确一种结果只有哪些形式

“支付结果”可以是成功,也可以是失败。普通接口默认可以被其他类型任意实现,这对开放扩展有好处,但有时领域模型需要限制结果类型。

sealed 允许我们声明受控的继承集合。

下面示例最低版本:JDK 17。

1
2
3
4
5
6
7
8
9
10
11
public class SealedDemo {
sealed interface PaymentResult permits Success, Failure {}
record Success(String tradeNo) implements PaymentResult {}
record Failure(String reason) implements PaymentResult {}

// record 隐式为 final,满足 sealed 对直接子类型的要求
public static void main(String[] args) {
PaymentResult result = new Success("T20261005001");
System.out.println(result);
}
}

permits 列出允许的直接子类型。直接子类型需要是 final、sealed 或 non-sealed;Record 隐式 final,因此很适合承载具体结果。

在具名模块中,允许的直接子类型必须处于同一模块;在未命名模块中,则必须处于同一包。sealed 在 JDK 15、16 中预览,在 JDK 17 正式发布。参考 Oracle:JDK 17 重要变化、JEP 409。

这个特性与 JDK 21 的模式匹配 switch 配合,可以让编译器检查业务分支是否覆盖全部结果。

强封装 JDK 内部实现

JDK 16 已经默认加强了封装,但还能用旧的全局机制放松一部分限制。JDK 17 通过 JEP 403 移除了这种一键放松封装的路径,--illegal-access=permit 不再能恢复以前的行为。

常见现象是老库反射访问 JDK 的 private 成员时抛出 InaccessibleObjectException。解决路径通常是升级相关库、迁移到受支持 API;确实需要临时兼容时,可以精确开放某个包,例如:

1
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar

这个参数允许 classpath 上的代码对 java.lang 包进行深反射,不是所有反射异常的通用修复,也不负责恢复已经被移除的 API。--add-exports 与 --add-opens 的职责也不同。参考 Oracle:从 JDK 8 迁移到后续版本、JEP 403。

JDK 18:默认字符集和资源释放的变化

默认字符集改为 UTF-8

同一个 new String(bytes),在过去不同操作系统或区域配置下可能使用不同字符集。JDK 18 通过 JEP 400 将标准 API 的默认字符集统一为 UTF-8,控制台 I/O 等场景仍需注意其单独的编码规则。

下面示例最低版本:JDK 18。

1
2
3
4
5
6
7
8
9
10
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class CharsetDemo {
public static void main(String[] args) {
System.out.println("默认字符集:" + Charset.defaultCharset());
byte[] bytes = "订单".getBytes(StandardCharsets.UTF_8);
System.out.println(new String(bytes, StandardCharsets.UTF_8));
}
}

普通默认配置下,第一行输出 UTF-8;第二行显式使用 UTF-8,避免依赖默认值。

默认字符集变化不会自动把旧文件转成 UTF-8。 如果历史文件是 GBK,升级后仍需要按 GBK 解码。像 Files.readString(path) 这样的 API 本来就默认 UTF-8,并不是所有读写方法都到 JDK 18 才改变行为。

迁移时可以用 -Dfile.encoding=COMPAT 临时检查旧默认行为,但长期应明确文件、网络和数据库边界上的编码。参考 Oracle:Be Aware Of The Default Charset、JDK 18 System API。

finalization 被标记为待移除

finalize() 存在不确定的执行时间、性能与安全问题。JDK 9 已废弃 Object.finalize();JDK 18 通过 JEP 421 将 finalization 标记为待移除。

这不代表 JDK 18 就移除了 Object.finalize()。关键是:新代码不要再依赖它释放连接、文件等资源。

1
2
3
4
5
// try-with-resources 从 JDK 7 就已存在;不是 JDK 18 的新语法
// 代码片段,放在可以处理 IOException 的方法内
try (var input = java.nio.file.Files.newInputStream(java.nio.file.Path.of("data.txt"))) {
// 在作用域内使用资源,退出时确定性地调用 close()
}

首选明确的 close() 与 try-with-resources;Cleaner 等机制只能作为某些资源的兜底,不能保证在业务期望的时刻执行。参考 Oracle:JDK 18 重要变化、JEP 421。

JDK 21:并发与业务建模的重要版本

虚拟线程:让大量等待不必占住大量操作系统线程

一个请求在等数据库响应时,CPU 通常没有一直计算。如果每个等待请求都占着一个操作系统线程,线程数量和线程栈开销就可能成为并发瓶颈。

虚拟线程是由 JDK 实现的轻量线程。它运行 Java 代码时仍需要底层平台线程,但在许多阻塞等待点可以卸载,释放承载线程去运行其他虚拟线程。 虚拟线程在 19、20 中预览,在 JDK 21 正式发布。

flowchart TD
    A[多个虚拟线程] --> B[JDK 调度器]
    B --> C[承载线程 1]
    B --> D[承载线程 2]
    C --> E[执行任务甲]
    E --> F[甲等待可卸载的 I/O]
    F --> G[保存执行状态并卸载甲]
    G --> H[同一承载线程执行任务乙]
    F --> I[I/O 就绪后重新调度甲]

这是一张概念示意图,不表示所有阻塞操作都能够卸载。平台线程通常与操作系统线程关联;虚拟线程与承载线程之间不保持永久的一对一关系。详细模型见 JEP 444、Java 官方:Virtual Threads。

下面示例最低版本:JDK 21。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.Semaphore;

public class VirtualThreadDemo {
public static void main(String[] args) throws Exception {
Semaphore permits = new Semaphore(10); // 最多 10 个任务同时使用下游资源
List<Future<Boolean>> tasks = new ArrayList<>();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 100; i++) {
tasks.add(executor.submit(() -> {
permits.acquire();
try {
Thread.sleep(10); // 模拟等待 I/O,这里不做性能基准
return Thread.currentThread().isVirtual();
} finally {
permits.release();
}
}));
}
boolean allVirtual = true;
for (Future<Boolean> task : tasks) {
allVirtual &= task.get();
}
System.out.println("全部使用虚拟线程:" + allVirtual);
} // 关闭执行器并等待任务结束
}
}

输出为 全部使用虚拟线程:true。

这里为每个任务创建虚拟线程,同时用 Semaphore 把资源使用并发量限制为 10。两个数字并不矛盾:虚拟线程便宜,数据库连接、远程服务容量等资源仍然有限。

我们看一下创建执行器的源码,摘自 OpenJDK JDK 25 的 Executors 实现:

1
2
3
4
public static ExecutorService newVirtualThreadPerTaskExecutor() {
ThreadFactory factory = Thread.ofVirtual().factory();
return newThreadPerTaskExecutor(factory);
}

可以看到,它为每个任务使用一个新线程,而不是复用少量虚拟线程。不要通过“把虚拟线程池限制为固定几个线程”来代替资源限流;应该在需要约束的资源入口使用连接池、Semaphore 等机制。源码见 OpenJDK Executors.java,jdk-25+36。

对企业接口来说,需要一起考虑下面几个条件:

  • 大量时间用于等待可有效卸载的 I/O,才更可能受益。
  • CPU 密集计算仍受核心数约束,增加虚拟线程不会凭空增加 CPU。
  • 虚拟线程数量大时,ThreadLocal 中保存大对象的成本会被放大。
  • 对任务数量、超时、排队和下游资源仍需施加边界。

所以,虚拟线程的主要价值是让“按请求执行的顺序代码”更容易扩展到高并发;不是对任何负载都保证降低单次请求耗时。

模式匹配 switch 与 Record 模式

在前面的章节中,我们已经有 Record 数据载体和 sealed 结果集合。JDK 21 进一步允许 switch 按类型和组件模式处理这些结果。

下面示例最低版本:JDK 21。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.math.BigDecimal;

public class PatternDemo {
sealed interface PaymentResult permits Success, Failure {}
record Success(BigDecimal amount) implements PaymentResult {}
record Failure(String reason) implements PaymentResult {}

static String describe(PaymentResult result) {
return switch (result) {
case null -> "没有支付结果"; // null 必须明确处理
case Success(BigDecimal amount)
when amount.compareTo(new BigDecimal("1000")) >= 0 -> "大额支付";
case Success(BigDecimal amount) -> "支付成功:" + amount;
case Failure(String reason) -> "支付失败:" + reason;
}; // sealed 的全部直接子类型已经覆盖,不需要 default
}

public static void main(String[] args) {
System.out.println(describe(new Success(new BigDecimal("1200"))));
System.out.println(describe(new Failure("余额不足")));
System.out.println(describe(null));
}
}

输出为 大额支付、支付失败:余额不足、没有支付结果。

可以看到:

  • case Success(BigDecimal amount) 同时检查类型并提取 Record 组件。
  • when 是守卫条件,用来继续判断这个分支是否适用。
  • 大额分支放在普通 Success 分支之前,避免被更宽泛的分支支配。
  • case null 显式处理 null;仅有 default 不等于处理了 null。
  • sealed 的已知结果类型均已覆盖,编译器可以检查穷尽性。

模式匹配 switch 与 Record 模式分别在 JDK 21 通过 JEP 441、JEP 440 正式发布。业务层现在可以把“结果有哪些形式”和“每种形式如何处理”联系起来,而不是四处使用 Object 加强制转换。参考 Oracle:Pattern Matching with switch、Record Patterns。

Sequenced Collections:统一有顺序集合的首尾操作

List、Deque、LinkedHashSet 都有遇见顺序,但以前访问首尾、得到反向视图的 API 不统一。

JDK 21 引入 SequencedCollection、SequencedSet、SequencedMap,为有定义顺序的集合提供统一接口。

下面示例最低版本:JDK 21。

1
2
3
4
5
6
7
8
9
10
11
12
13
import java.util.ArrayList;
import java.util.List;

public class SequencedDemo {
public static void main(String[] args) {
List<String> steps = new ArrayList<>(List.of("下单", "支付", "发货"));
System.out.println(steps.getFirst());
List<String> reverse = steps.reversed(); // 返回反向视图
reverse.addFirst("签收"); // 对原列表表现为尾部添加
System.out.println(steps);
System.out.println(reverse);
}
}

运行结果:

1
2
3
下单
[下单, 支付, 发货, 签收]
[签收, 发货, 支付, 下单]

reversed() 在这里返回的是反向视图,不是独立副本。 修改可修改的视图会反映到原集合;若原集合不可修改,不能假定视图可以修改。HashSet、HashMap 不会仅因为 JDK 升级就拥有稳定遇见顺序。

这些接口统一了语义,但不保证所有集合的首尾操作都是 O(1),也不改变集合原有的线程安全属性。参考 SequencedCollection API、JEP 431。

分代 ZGC:按对象生命周期进一步优化

原始 ZGC 不分代。JDK 21 引入 Generational ZGC,把对象分成年轻对象和老对象,利用“多数对象很快死亡”的特征减少回收工作。

下面把关键版本和启动方式放在一起,避免混用参数:

JDK ZGC 的状态或模式变化 典型启用方式
11 实验性、非分代;初期平台支持有限 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC
15 非分代 ZGC 转为生产功能 -XX:+UseZGC
21、22 增加分代模式,需要显式选择 -XX:+UseZGC -XX:+ZGenerational
23 分代成为 ZGC 默认模式 -XX:+UseZGC
24 及后续本文覆盖版本 非分代模式被移除 -XX:+UseZGC

这些变化分别对应 JEP 333、377、439、474、490。JDK 24 的模式移除也可由 Oracle JDK 24 迁移说明交叉核对。

“分代成为 ZGC 默认模式”,不等于“ZGC 成为 JVM 默认收集器”。 我们仍需要 UseZGC 来选择它;ZGenerational 到后续版本也不再是切换到非分代模式的手段。

ZGC 面向很短的 GC 暂停,但暂停目标不是对所有应用的硬保证,更不能等同于接口延迟保证。CPU 调度、Safepoint、I/O、资源耗尽等问题仍可能影响应用响应。

动态加载 Agent 开始告警

这项变化归属 JDK 21,而不是 JDK 23。 JEP 451 对运行时动态加载 Agent 发出告警,为未来默认限制做准备。启动时通过 -javaagent、-agentlib 显式加载的 Agent 不属于同一种情况。

APM、诊断工具、测试中的动态插桩可能受到影响。升级时要查看工具的使用方式和版本支持,不要简单地把所有告警当成业务错误,也不要忽视它们指出的未来兼容性问题。参考 JEP 451:Prepare to Disallow the Dynamic Loading of Agents。

JDK 22:FFM API 正式发布

更明确地管理堆外内存与本地调用

传统上,Java 调用本地库常用 JNI,底层框架也可能依赖 Unsafe 来操作内存。代码复杂,而且容易产生生命周期与边界问题。

Foreign Function & Memory API(FFM)为访问外部内存、调用本地函数提供标准 API。 它在多个版本孵化、预览后,于 JDK 22 通过 JEP 454 正式发布。

先认识三个关键概念:

API 主要职责
MemorySegment 表达一段有边界和生命周期的内存
Arena 控制所分配本地内存的生命周期与线程访问策略
Linker 建立 Java 与本地函数之间的调用链接

下面示例最低版本:JDK 22。 先看内存管理,暂时不依赖操作系统的 C 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.lang.foreign.Arena;
import java.lang.foreign.MemorySegment;
import java.lang.foreign.ValueLayout;

public class FFMDemo {
public static void main(String[] args) {
MemorySegment segment;
try (Arena arena = Arena.ofConfined()) { // 当前线程使用,生命周期显式管理
segment = arena.allocate(ValueLayout.JAVA_INT);
segment.set(ValueLayout.JAVA_INT, 0, 42);
System.out.println(segment.get(ValueLayout.JAVA_INT, 0));
} // 关闭 arena,释放它分配的本地内存
try {
segment.get(ValueLayout.JAVA_INT, 0);
} catch (IllegalStateException e) {
System.out.println("Arena 已关闭,不能继续访问该内存段");
}
}
}

输出为 42,然后提示 Arena 已关闭。

flowchart LR
    A[打开 Arena] --> B[分配 MemorySegment]
    B --> C[按 ValueLayout 读写]
    C --> D[关闭 Arena]
    D --> E[内存段失效,后续访问被拒绝]

Arena.ofConfined() 的显式关闭与访问策略,让“这段内存归谁管理、何时失效”比一个裸地址更清楚。关闭后 Java 引用还可能存在,但不能继续访问已经失效的本地内存。参考 JDK 22 Arena API、JEP 454。

上面的分配与读写不需要启用预览,也不需要 native-access 参数。调用受限制的本地链接功能时则要按所属模块显式授权,例如 classpath 应用可使用:

1
java --enable-native-access=ALL-UNNAMED -jar app.jar

FFM 改善了 Java 侧的内存与调用边界,不会自动让任意 C/C++ 函数变得安全,也没有移除 JNI。 网络、存储、压缩等底层库可能逐步受益,普通业务代码不必为了追新而强行改写成堆外内存操作。

JDK 23:重点关注 ZGC 默认模式变化

本文筛选出的主要正式变化,是 启用 ZGC 后默认采用分代模式,具体参数已经在前面的版本表中说明。

升级 JVM 时应检查最终生效的参数和启动告警。GC 相关参数跨版本可能变成默认值、被忽略或被废弃,不能因为复制了旧启动命令就认为行为完全相同。

JDK 24:虚拟线程、Stream 与工具链继续完善

synchronized 不再造成原来的主要固定问题

在 JDK 21—23 中,虚拟线程持有 synchronized 监视器时发生阻塞,可能固定在承载线程上,使承载线程也被占住。高频、长时间固定会限制并发扩展。

JDK 24 的 JEP 491 改进了监视器实现,让虚拟线程可以在持有监视器、等待进入监视器以及 Object.wait 等相关场景中卸载。 原来最主要的一类固定问题因此被消除。见 Oracle JDK 24 重要变化、OpenJDK 实现讨论。

但这不能理解为“所有固定和阻塞问题都消失了”:本地代码等场景仍需要检查;持有同一个锁的任务仍要互斥执行,锁的业务串行化不会因为承载线程可释放就自动消失。

所以,“为了虚拟线程必须把所有 synchronized 换成 ReentrantLock”不是跨版本通用结论。JDK 21 项目应测量实际固定问题;JDK 24 及以后,应根据锁语义和实测选择实现。

Stream Gatherers:支持更复杂的中间操作

JDK 8 的 Stream 已有 filter、map、flatMap,但“按固定大小分批”“滑动窗口”等处理不容易直接表达。

Gatherer 为 Stream 提供可扩展的中间操作。 它在 JDK 22、23 中预览,在 JDK 24 通过 JEP 485 正式发布。

下面示例最低版本:JDK 24。

1
2
3
4
5
6
7
8
9
10
11
import java.util.List;
import java.util.stream.Gatherers;

public class GathererDemo {
public static void main(String[] args) {
var batches = List.of(1, 2, 3, 4, 5).stream()
.gather(Gatherers.windowFixed(2)) // 每两个元素组成一批
.toList();
System.out.println(batches);
}
}

输出为:

1
[[1, 2], [3, 4], [5]]

windowFixed(2) 把有序元素按每两个一批组织起来,最后不足两个的元素形成末批。

还可以使用滑动窗口、前缀累计、并发映射等内置 Gatherer,或者定义自己的中间操作。它与 Collector 的位置不同:Gatherer 扩展流水线中的转换;Collector 常用于终止操作的结果收集。 参考 Gatherer API、Gatherers API。

分批处理适合组织业务数据,但不会自动建立数据库事务、重试与限流规则;上例终止时还会保存全部批次,不等同于一个无限流式且零缓存的处理系统。

Class-File API:标准方式解析、生成和转换 class

框架、Agent、编译工具经常需要操作字节码。过去通常依赖 ASM 等第三方库;新的 class 文件版本出现时,需要同步考虑这些库的支持范围。

JDK 24 通过 JEP 484 正式提供 Class-File API,把 class 文件解析、生成、转换能力放进 JDK,随 JDK 的 class 格式一起演进。

下面示例最低版本:JDK 24。 需要先编译,再读取生成的 class 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import java.lang.classfile.ClassFile;
import java.nio.file.Files;
import java.nio.file.Path;

public class ClassFileDemo {
public static void main(String[] args) throws Exception {
// 读取自己的 class 文件,示例需要先用 javac 编译
Path path = Path.of(args.length == 0 ? "ClassFileDemo.class" : args[0]);
var model = ClassFile.of().parse(Files.readAllBytes(path));
System.out.println(model.thisClass().asInternalName());
for (var method : model.methods()) {
System.out.println(method.methodName().stringValue()
+ method.methodType().stringValue());
}
}
}

在示例目录中执行:

1
2
javac -encoding UTF-8 ClassFileDemo.java
java ClassFileDemo

输出包含类的内部名 ClassFileDemo,以及构造器、main 方法的名称和 JVM 方法描述符。这里只读取元数据,没有加载、修改或执行待解析文件中的字节码。

对普通业务代码,这个 API 不会天天使用;对框架、监控和工具作者,它可以减少直接依赖非标准 JDK 内部字节码接口的需求。第三方库不会因为 JDK 升级而自动迁移,工具链仍需要检查支持的 class 版本。参考 ClassFile API。

AOT 类加载与链接:把启动工作提前做

JDK 24 的 JEP 483:Ahead-of-Time Class Loading & Linking 允许将一部分启动时的类加载与链接工作提前处理,并保存到缓存中。

可以把它理解成:每次开店都要重新准备的一部分固定工作,先整理好,下次开店直接利用准备结果。JDK 25、26 会继续扩展这个机制,后面统一展示命令。演进路线参考 OpenJDK Project Leyden。

本地访问、Unsafe 和 Security Manager 的调整

JDK 24 还有几项变化,未必需要业务代码主动使用,却可能影响项目启动:

变化 影响
JEP 472:JNI / FFM 访问告警策略 依赖本地库的组件需要核对 native-access 授权
JEP 498:Unsafe 内存访问方法告警 旧版网络、存储、序列化组件可能暴露迁移需求
JEP 486:永久禁用 Security Manager 依赖旧沙箱与权限策略的程序不能继续启用它

Unsafe 的部分方法告警,不等于整个 Unsafe 类已经全部移除;Security Manager 被永久禁用,也不等于它的全部 API 在同一版本已经删除。 这些区别对判断兼容性很重要。参考 Oracle JDK 24 重要变化、Security Manager 永久禁用说明。

JDK 25:上下文、内存布局与启动优化

ScopedValue:在受控作用域中传递请求上下文

请求 id、租户信息等数据,要在 Controller、Service、底层调用之间传递。除了显式方法参数,也常用 ThreadLocal。

ThreadLocal 能完成这个任务,但需要考虑清理和生命周期。如果把数据放进去后忘记 remove,线程池复用时可能遗留上下文;大量虚拟线程还会放大每线程保存数据的成本。

ScopedValue 用受控的动态作用域绑定一个值。 在该调用范围内可以读取,调用结束后绑定自动退出。它在 JDK 20 孵化、21—24 预览,在 JDK 25 通过 JEP 506 正式发布。

下面示例最低版本:JDK 25。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ScopedValueDemo {
private static final ScopedValue<String> TRACE_ID = ScopedValue.newInstance();

static void service() {
System.out.println("当前请求:" + TRACE_ID.get());
}

public static void main(String[] args) {
ScopedValue.where(TRACE_ID, "req-001").run(() -> {
service();
ScopedValue.where(TRACE_ID, "req-002").run(ScopedValueDemo::service);
service(); // 离开嵌套作用域,恢复 req-001
});
System.out.println("外层调用结束后是否绑定:" + TRACE_ID.isBound());
}
}

运行结果:

1
2
3
4
当前请求:req-001
当前请求:req-002
当前请求:req-001
外层调用结束后是否绑定:false
flowchart TD
    A[进入外层绑定 req-001] --> B[service 读取 req-001]
    B --> C[进入内层绑定 req-002]
    C --> D[service 读取 req-002]
    D --> E[离开内层,恢复 req-001]
    E --> F[离开外层,绑定结束]

可以看到,内层重新绑定不覆盖外层生命周期;正常返回或抛出异常退出作用域时,外层绑定均会按规则恢复。

ScopedValue 更适合从上游向下游单向传递上下文,不是任意可变线程状态的完整替代品。绑定不可直接 set,也不代表值对象内部被深度冻结,因此仍应使用不可变上下文。

它不会自动把上下文传播到任意线程池任务或 CompletableFuture 回调。 跨线程继承与 StructuredTaskScope 的受控创建方式有关,需要按照 API 的结构化规则使用。参考 JDK 25 ScopedValue API、Oracle:Scoped Values。

紧凑对象头:从实验功能变为正式可选功能

一个 Java 对象除了业务字段,还需要对象头保存运行时信息。对象很多且每个对象很小时,对象头占比就比较明显。

紧凑对象头在 JDK 24 作为实验功能引入,在 JDK 25 转为生产功能,但 JDK 25 仍需要显式开启:

1
java -XX:+UseCompactObjectHeaders -jar app.jar

JDK 24 的实验启用方式则还需要解锁:

1
java -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders -jar app.jar

对典型启用压缩类指针的 64 位 HotSpot 布局,对象头从 12 字节变为 8 字节;其他传统配置下也可能是从 16 字节缩到 8 字节。数组长度、实例字段和对象对齐仍要另外考虑,不能把对象头减少 4 字节直接当成每个对象总大小一定减少 4 字节。 参考 JEP 450、JEP 519。

flowchart LR
    A[传统布局:对象头加字段及对齐] --> B[紧凑布局:更小对象头加字段及对齐]
    B --> C[实际节省由字段和对齐共同决定]

比如在常见 8 字节对齐下,一个对象的“头加字段”从 12 变为 8 字节,可能使总大小从 16 变为 8;若从 16 变为 12,则两者最终都可能对齐为 16。这里是便于理解的布局算例,并非所有对象的统一实际尺寸。

该变化适合关注对象数量大、内存预算紧的业务。收益与对象结构有关,应在自己的负载下观察堆占用、GC 与吞吐量。

AOT 缓存与方法画像:启动之后也需要更快进入稳定状态

微服务运行时不仅要“main 开始执行”,还要经历类加载、初始化和 JIT 预热。频繁扩容的应用尤其在意到达稳定吞吐量需要多久。

JDK 25 带来两个重要进展:

  • JEP 514:简化 AOT 缓存命令行,让训练与缓存生成更容易使用。
  • JEP 515:提前记录方法执行画像,帮助后续运行时更早做出编译优化决策。

下面用示例包中的 StartupDemo.java 演示 JDK 25 的流程。在一个独立目录中执行:

1
2
3
4
5
6
7
8
javac -encoding UTF-8 StartupDemo.java
jar --create --file app.jar --main-class StartupDemo StartupDemo.class

# 训练运行;应用退出后生成 AOT 缓存
java -XX:AOTCacheOutput=app.aot -jar app.jar

# 再次运行,读取缓存;日志用于确认缓存实际使用情况
java -XX:AOTCache=app.aot -Xlog:aot -jar app.jar

上面两个带 # 的说明行适用于 PowerShell / 常见 Unix shell,使用其他终端时只需执行命令行。

flowchart LR
    A[代表性工作负载] --> B[训练运行]
    B --> C[生成 AOT 缓存]
    C --> D[后续启动加载缓存]
    D --> E[减少启动和预热中的部分重复工作]

缓存与应用、JDK 版本、操作系统及 CPU 架构等条件相关。 更换代码或运行环境后需要重新生成,并检查 JVM 参数和 Agent 是否影响缓存可用性。训练负载也需要覆盖实际运行路径。

这套机制仍然运行在 HotSpot 上,不能把它直接等同于生成独立本地可执行文件的 Native Image;也不能把“缓存方法画像”误解成“已经把全部业务方法预编译成机器码”。参考 OpenJDK Project Leyden、JDK 25 java 命令:Ahead-of-Time Cache。

灵活构造器:先验证参数,再调用父类构造器

过去构造器中显式的 super(...) 或 this(...) 调用必须处于最前面,复杂参数验证通常只能塞进辅助方法。

JDK 25 正式允许在构造器调用之前执行符合规则的准备与验证语句。

下面示例最低版本:JDK 25。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ConstructorDemo {
static class BaseOrder {
final long id;
BaseOrder(long id) { this.id = id; }
}

static class Order extends BaseOrder {
Order(long id) {
if (id <= 0) { // JDK 25:允许在 super(...) 之前验证参数
throw new IllegalArgumentException("订单 id 必须为正数");
}
super(id);
}
}

public static void main(String[] args) {
System.out.println(new Order(1).id);
}
}

可以先确认订单 id 合法,再调用父类构造器。这个变化能让校验失败更早暴露,也减少为了满足语法顺序而创建的辅助方法。

不过,构造早期阶段仍有严格限制,不能任意调用当前对象的实例方法或让尚未完成初始化的 this 逃逸。顺序限制放宽,不代表初始化规则被取消。 参考 Oracle:Flexible Constructor Bodies、JEP 513。

JDK 26:网络能力和框架兼容性

HttpClient 支持 HTTP/3

JDK 26 的 JEP 517 为标准 HttpClient 增加 HTTP/3。HTTP/3 使用 QUIC,而 QUIC 基于 UDP;它提供与 TCP 上的 HTTP/2 不同的传输行为。

1
2
3
4
// JDK 26,代码片段;本机未安装 JDK 26,按官方 API 核对
java.net.http.HttpClient client = java.net.http.HttpClient.newBuilder()
.version(java.net.http.HttpClient.Version.HTTP_3)
.build();

需要显式选择 HTTP/3;默认偏好仍是 HTTP/2。 能否成功使用还取决于服务端支持、协议发现和回退策略,以及代理、防火墙是否允许相关 UDP 流量。不能只换一个枚举值就认为所有调用都变成 HTTP/3,也不能保证每种网络条件都更快。

这个变化针对标准客户端,没有同时把旧 HttpURLConnection 或服务器 API 都升级为 HTTP/3。参考 JDK 26 HttpClient.Version API、JEP 517。

通过深反射修改 final 字段开始告警

一些框架会通过深反射修改 final 实例字段。这个行为让“构造完成后不再变化”的假设失去可靠性,也妨碍相关优化。

JDK 26 的 JEP 500 默认先告警,为未来更严格的限制做准备。 它不是在 JDK 26 就全面禁止所有此类修改,也不是所有反射读取都被禁用。

排查时可以使用:

1
java --illegal-final-field-mutation=debug -jar app.jar

用于提前验证更严格限制下的兼容性:

1
java --illegal-final-field-mutation=deny -jar app.jar

若确实有无法立即替换的依赖,classpath 上的代码可由部署方显式授权:

1
java --enable-final-field-mutation=ALL-UNNAMED -jar app.jar

允许包的深反射访问,与允许修改 final 字段,是两项不同的条件。 仅添加 --add-opens 不能代替 final 修改授权;具体字段是否可修改仍受反射规则限制。参考 Oracle:Preparing for final Field Mutation Restrictions。

对企业项目,优先定位发出告警的依赖,再检查序列化、依赖注入等组件是否已有更合适的构造与赋值方式。

AOT 对象缓存可以配合所有 GC

JEP 516 让 AOT 对象缓存支持所有垃圾收集器,包括 ZGC。 以前直接映射缓存对象的格式会受到 GC 内存布局限制,新的机制使用更中立的格式加载对象。

这对希望同时利用启动优化和低延迟 GC 的应用有意义。JDK 26 还通过 JEP 522 减少 G1 中的一部分同步开销;它属于运行时优化,实际收益仍需用业务负载测量。参考 Oracle JDK 26 发布说明。

JDK 27:默认行为变化与安全诊断

G1 成为所有环境的默认收集器

JDK 9 的变化针对 server 配置;JDK 27 通过 JEP 523 将 G1 的默认范围扩展到所有环境。低资源配置过去可能默认使用 Serial,因此相同业务在新版 JVM 上的默认 GC 行为可能变化。

这里说的是没有显式选择收集器时的默认策略。启动命令如果明确指定其他收集器,仍需要按该收集器的支持条件判断。参考 Oracle:The Arrival of Java 27。

紧凑对象头默认开启

对象头的版本变化,现在可以完整串起来:

flowchart LR
    A[JDK 24:实验性、默认关闭] --> B[JDK 25、26:生产功能、默认关闭]
    B --> C[JDK 27:默认开启]

JEP 534 把紧凑对象头变为默认布局。 因此即使不修改业务代码,对象密集型应用的内存表现也可能变化;依赖对象布局假设的测量工具和底层组件要同步检查。具体收益不能直接拿对象头比例替代整堆比例。参考 Oracle JDK 27 发布说明。

TLS 1.3 后量子混合密钥交换

JEP 527 为 TLS 1.3 增加混合密钥交换,把传统算法和抗量子算法结合。默认支持的组中,X25519MLKEM768 被放到优先位置。

使用 JDK javax.net.ssl 实现的应用,可以在对端支持、配置允许并成功协商时受益。它不等于对任意服务端强制采用新算法,也不代表所有加密功能自动升级成后量子方案。 网络设备和互操作兼容性仍要测试。参考 Oracle Java 27 发布公告、JDK 27 javax.net.ssl API。

JFR 在进程内处理敏感信息

JEP 536 让 JFR 对命令行参数,以及环境变量、系统属性的初始值进行脱敏处理,在数据离开进程之前处理匹配的信息。

默认过滤器并不代表所有业务事件内容都自动脱敏。 自定义 JFR 事件中写入的订单信息、Token 等数据,仍需业务方控制;也应检查默认规则是否覆盖应用特有的参数名称。参考 Oracle JDK 27 发布说明:JFR In-Process Data Redaction。

值得关注,但还没有正式定型的特性

截至本文覆盖的 JDK 27,下面这些特性仍需要按其状态评估:

特性 JDK 27 状态 主要方向
Structured Concurrency,结构化并发 第七次预览,JEP 533 把相关子任务作为一个整体,管理生命周期、取消、异常
Lazy Constants,惰性常量 第三次预览,JEP 531 在更灵活的时间初始化不可修改的值
原始类型的模式匹配等扩展 第五次预览,JEP 532 扩展模式、instanceof、switch 对原始类型的能力
Vector API 第十二次孵化,JEP 537 对受支持 CPU 的 SIMD 向量计算进行表达

状态参考 Oracle JDK 27 主要新功能。其中 Lazy Constants 曾在 JDK 25 以 Stable Values 的名称预览,阅读旧资料时需要核对名称与 API 的变化;不能直接把早期示例当作当前接口。

结构化并发可以帮助表达“请求发起的相关任务应当在请求作用域内结束”,但仍处于预览。本文没有把不同版本的 StructuredTaskScope 代码混写为稳定示例,也没有因为某项 API 预览了多次就判定它已经转正。

企业项目升级时,应该一起检查什么

编译成功,只代表完成了其中一步

升级可以分为几个独立维度:

flowchart LR
    A[更新运行时 JDK] --> B[检查依赖和 JVM 参数]
    B --> C[验证业务行为、性能与诊断工具]
    C --> D[更新编译器和目标版本]
    D --> E[按需要逐步使用新语言与 API]

这是一种便于控制变量的升级思路,不是所有项目必须遵循的唯一顺序。先跑通旧业务,再逐步使用新特性,通常更容易定位问题。

检查项 重点
编译与构建 Maven / Gradle、编译插件、注解处理器是否支持目标 JDK
字节码工具 Agent、覆盖率、Mock、ASM 等是否支持新的 class 文件版本
反射与内部 API 是否依赖 JDK private 成员、Unsafe、反射修改 final
依赖与移除项 JAXB、旧 GC 参数、旧沙箱、被移除的启动选项
I/O 行为 默认编码、时区、TLS、证书和协议协商
容器内存 堆之外的内存、线程数、连接池、容器资源识别
运行性能 冷启动、预热、吞吐量、P95 / P99、GC、CPU 与 RSS

不要只测“接口有没有报错”。GC 默认值、对象头、线程模型和编码变化,都可能带来行为或容量差异。官方迁移入口见 Oracle JDK 迁移指南。

–release:同时约束语言、字节码和标准 API

用新编译器构建老运行时应用时,可以使用:

1
javac --release 17 -encoding UTF-8 Demo.java

与单独使用 -source、-target 相比,--release 还约束可用的目标版本标准 API,帮助避免“生成了旧版本字节码,却调用了新版本 JDK 方法”的问题。它从 JDK 9 引入。

–release 不能保证第三方依赖也符合目标版本。 依赖 JAR 的 class 版本、框架支持范围和运行时行为仍需检查。参考 JEP 247:Compile for Older Platform Versions。

同一 JVM,也不是一种特性越新就一定越合适

业务需求 优先评估的能力
集合筛选和聚合 Lambda、Stream
不存在的查询结果 Optional 返回值
数据载体和受控结果类型 Record、sealed、模式匹配
大量阻塞 I/O 虚拟线程,并配合下游资源限制
受控请求上下文 显式参数或 ScopedValue
本地库、堆外内存 FFM 与相应访问授权
低 GC 暂停 在实际负载下比较 G1、ZGC 等方案
冷启动与预热 CDS、AOT 缓存及训练负载

这些能力解决的问题各不相同。业务代码可以继续保持简单,只有当一个特性确实改善表达、资源使用或运行效果时,再把它引入对应场景。

官方资料与源码入口

各小节已在相应知识点旁标注来源。下面列出适合继续查阅的官方入口:

资料 用途
OpenJDK JEP 索引 查特性编号、正式交付版本、动机、限制与历史
Oracle Java SE 8 文档 查询 JDK 8 API 与基础新特性
Oracle Java 语言变化汇总 区分语言特性的正式和预览版本
Oracle JDK 26 迁移指南 检查升级行为变化与兼容性问题
Oracle JDK 27 发布说明 核对 JDK 27 正式交付的变化
OpenJDK Project Leyden 查启动、预热与 AOT 演进
OpenJDK JDK 源码仓库 阅读对应版本的真实实现

源码片段固定链接到 jdk-25+36 标签,避免主分支更新后无法对应本文。其余完整 Java 示例是为讲解编写的独立示例;概念图是说明机制的示意图,不代表完整实现流程或性能保证。