前言
关注 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 | // JDK 8,代码片段:函数式接口只有一个抽象方法 |
amount -> ... 就是 discount
方法的具体行为。Lambda
的目标类型必须是函数式接口;它也可以引用外部局部变量,但该变量必须是
final 或“事实上不再被赋值”的 effectively final。
同时,接口可以定义 default
方法。这样为接口添加某些新能力时,旧实现类不必全部补上实现。不过多个接口默认方法发生冲突时,仍需要实现类明确解决。参考
Oracle:Java
SE 8 语言增强。
Stream:把集合处理写成流水线
我们看一下“筛选姓张的用户,再生成展示文本”怎么写。
下面示例最低版本:JDK 8。
1 | import java.util.Arrays; |
运行结果:
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 | import java.util.Optional; |
输出为 匿名用户。如果把 nickname
改成一个有效名字,就使用该名字。
我们再看一下源码。下面摘自 OpenJDK JDK 25 的 Optional
实现;用于解释自 JDK 8 就存在的
ofNullable,并不是说它到 25 才出现:
1 | public static <T> Optional<T> ofNullable(T 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 | import java.time.Instant; |
输出为 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 | import java.util.concurrent.CompletableFuture; |
结果为
张三,积分: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 | // JDK 9,module-info.java,独立的模块声明示意 |
这段声明假设 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 | import java.util.ArrayList; |
这些集合适合表示固定配置、结果快照等内容,但需要明确它们的边界:
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 | private final byte[] value; |
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 | import java.util.ArrayList; |
编译器看右边的 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 | import com.sun.net.httpserver.HttpServer; |
输出为 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 | public class SwitchDemo { |
可以看到,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 | public class TextBlockDemo { |
输出就是三行 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 | import java.util.ArrayList; |
运行结果:
1 | 张三 |
编译器为组件生成 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 | public class InstanceofDemo { |
输出为 4 和 0。
text
只在编译器可以证明匹配成功的路径中可用。&&
右侧需要左侧为 true,因此可以访问它;简单地改成 ||
后,右侧在类型不匹配时也可能执行,不能照搬同一个变量用法。
这叫流作用域(flow scoping),它根据控制流分析保证变量使用安全。参考 Oracle:Pattern Matching with instanceof、JEP 394。
JDK 17:限制继承边界与加强封装
sealed:明确一种结果只有哪些形式
“支付结果”可以是成功,也可以是失败。普通接口默认可以被其他类型任意实现,这对开放扩展有好处,但有时领域模型需要限制结果类型。
sealed 允许我们声明受控的继承集合。
下面示例最低版本:JDK 17。
1 | public class SealedDemo { |
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 | import java.nio.charset.Charset; |
普通默认配置下,第一行输出 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 | // try-with-resources 从 JDK 7 就已存在;不是 JDK 18 的新语法 |
首选明确的 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 | import java.util.ArrayList; |
输出为 全部使用虚拟线程:true。
这里为每个任务创建虚拟线程,同时用 Semaphore 把资源使用并发量限制为 10。两个数字并不矛盾:虚拟线程便宜,数据库连接、远程服务容量等资源仍然有限。
我们看一下创建执行器的源码,摘自 OpenJDK JDK 25 的 Executors 实现:
1 | public static ExecutorService newVirtualThreadPerTaskExecutor() { |
可以看到,它为每个任务使用一个新线程,而不是复用少量虚拟线程。不要通过“把虚拟线程池限制为固定几个线程”来代替资源限流;应该在需要约束的资源入口使用连接池、Semaphore 等机制。源码见 OpenJDK Executors.java,jdk-25+36。
对企业接口来说,需要一起考虑下面几个条件:
- 大量时间用于等待可有效卸载的 I/O,才更可能受益。
- CPU 密集计算仍受核心数约束,增加虚拟线程不会凭空增加 CPU。
- 虚拟线程数量大时,ThreadLocal 中保存大对象的成本会被放大。
- 对任务数量、超时、排队和下游资源仍需施加边界。
所以,虚拟线程的主要价值是让“按请求执行的顺序代码”更容易扩展到高并发;不是对任何负载都保证降低单次请求耗时。
模式匹配 switch 与 Record 模式
在前面的章节中,我们已经有 Record 数据载体和 sealed 结果集合。JDK 21 进一步允许 switch 按类型和组件模式处理这些结果。
下面示例最低版本:JDK 21。
1 | import java.math.BigDecimal; |
输出为
大额支付、支付失败:余额不足、没有支付结果。
可以看到:
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 | import java.util.ArrayList; |
运行结果:
1 | 下单 |
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 | import java.lang.foreign.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 | import java.util.List; |
输出为:
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 | import java.lang.classfile.ClassFile; |
在示例目录中执行:
1 | javac -encoding UTF-8 ClassFileDemo.java |
输出包含类的内部名 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 | public class ScopedValueDemo { |
运行结果:
1 | 当前请求:req-001 |
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 | javac -encoding UTF-8 StartupDemo.java |
上面两个带 # 的说明行适用于 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 | public class ConstructorDemo { |
可以先确认订单 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 | // JDK 26,代码片段;本机未安装 JDK 26,按官方 API 核对 |
需要显式选择 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
示例是为讲解编写的独立示例;概念图是说明机制的示意图,不代表完整实现流程或性能保证。




