编辑
2026-10-25
学习记录
00

目录

一、运行时变化时间线
二、JPMS 与强封装
1. 不需要为了 Spring 写 module-info.java
2. 先定位,再决定要不要 --add-opens
三、GC 与容器
1. 先记住这几个事实
2. 怎么选
3. 容器里的内存参数
4. 排查命令
四、友好 NPE(JDK 14 引入,15 默认开启)
五、被删掉的东西
六、UTF-8 成为默认字符集(JDK 18)
1. 变化是什么
2. 怎么修
3. JVM 参数
4. Spring 侧配置
5. 一个容易误解的点
七、启动、镜像与观测
1. 应用 CDS
2. JFR
3. jlink / jpackage
4. AOT 与 Native Image
5. 容器镜像
八、运行时升级检查清单
九、小结

同一段代码,从 JDK 8 换到 JDK 21,语法一个字没改,行为却可能变:启动时多了一个 InaccessibleObjectException,日志里的中文突然乱码,老 GC 参数不生效,某个脚本引擎找不到实现。

语言篇讲的是“怎么写”,这篇讲“跑起来之后会发生什么”。升级真正让人加班的部分,大多在这里。

TL;DR

  • JPMS 对 Spring 应用的真实影响是强封装,不是逼你写 module-info.java。
  • G1 已是默认;ZGC 适合大堆低延迟,但要压测后再换;容器里优先用 MaxRAMPercentage。
  • JDK 18 起 file.encoding 默认 UTF-8,这是中文项目最容易被忽略的隐性变更。
  • Java EE/CORBA、CMS、Nashorn 已被移除;CDS 和 JFR 是升级顺带的收益。

一、运行时变化时间线

版本关键变化
9JPMS 模块系统、G1 成为默认 GC、Compact Strings、jlink、jshell
10容器感知、G1 并行 Full GC、应用 CDS
11移除 Java EE/CORBA、ZGC(实验)、TLS 1.3、JFR 开源、Epsilon GC
12Shenandoah(实验)、G1 归还空闲内存
13动态 CDS、ZGC 归还内存、Socket API 重写
14友好 NPE、移除 CMS、jpackage(孵化)、JFR Event Streaming
15ZGC/Shenandoah 转正、移除 Nashorn、偏向锁禁用
16强封装默认开启、弹性元空间、jpackage 正式
17强封装强制、--illegal-access 失效、浮点始终严格
18UTF-8 成为默认字符集、finalize 弃用
19虚拟线程(预览)
21虚拟线程正式、分代 ZGC、KEM API

二、JPMS 与强封装

1. 不需要为了 Spring 写 module-info.java

JPMS(Java Platform Module System)把 JDK 拆成了模块,也允许应用自己声明模块。但绝大多数 Spring Boot 应用仍然跑在 classpath(未命名模块) 上,不需要写 module-info.java,也不需要把依赖迁到 module path。

真正会咬人的是另一件事:强封装。

  • JDK 16 起,非法反射访问 JDK 内部 API 默认被拒绝。
  • JDK 17 起,--illegal-access 被忽略,不能再靠它统一放行。

老库如果反射 java.lang、java.util、java.nio 的内部结构,就会抛:

text
java.lang.reflect.InaccessibleObjectException: Unable to make field private final byte[] java.lang.String.value accessible: module java.base does not "opens java.lang" to unnamed module @6f1fba17

2. 先定位,再决定要不要 --add-opens

bash
# 找出使用了 JDK 内部 API 的依赖 jdeps --jdk-internals target/app.jar # 找出使用了已废弃 API 的类 jdeprscan --release 21 target/classes

jdeps --jdk-internals 会列出 sun.misc.Unsafe、jdk.internal.* 之类的使用点;jdeprscan 会告诉你哪些 API 在目标版本已被废弃或移除。

处理优先级:

  1. 升级依赖:CGLIB、Lombok、Byte Buddy、Netty、Mockito、Hibernate 的新版本通常已经适配。
  2. 换实现:去掉对 sun.misc.Unsafe、内部序列化结构的依赖。
  3. --add-opens 兜底:只对确认无法短期升级的库使用,并记录原因。
bash
java --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED -jar app.jar

ALL-UNNAMED 表示“classpath 上的所有代码”。它能解决问题,但也会扩大反射面,不要把它当成默认配置复制到所有服务。

--add-opens 是运行时deep reflection 放行;如果是编译期依赖 JDK 内部 API,还需要 --add-exports。两者都不是长久方案。


三、GC 与容器

1. 先记住这几个事实

  • JDK 9 起 G1 成为默认 GC,Java 8 时代常用的 CMS 在 JDK 14 被移除。
  • JDK 10 起支持容器感知(8u191 也回移了),JVM 会读取 cgroup 的 CPU/内存限制。
  • JDK 11 ZGC 实验,JDK 15 转正,JDK 21 提供分代 ZGC。
  • JDK 12 Shenandoah 实验,JDK 15 转正;是否可用取决于 JDK 发行版。
  • JDK 23 起分代 ZGC 成为默认的非分代替代方案,非分代 ZGC 逐步退场。

2. 怎么选

场景建议
通用 Web 服务、堆不大G1(默认)
大堆、对暂停时间敏感ZGC(先压测)
小内存容器、单核/少核SerialGC 或 G1,按实测
追求吞吐、能接受较长停顿ParallelGC
极低暂停、发行版支持Shenandoah

没有“最好的 GC”,只有“对这个堆大小、分配速率和延迟目标最合适的 GC”。切换前至少记录:吞吐、p99、Full GC 次数、单次最长暂停、容器内存曲线。

3. 容器里的内存参数

不要再用固定 -Xmx,除非你非常确定容器的内存规格:

bash
-XX:+UseG1GC -XX:MaxRAMPercentage=70 -XX:InitialRAMPercentage=30 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app -Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20m
  • MaxRAMPercentage=70 给堆外内存、线程栈、元空间、直接内存留出余量。
  • 如果 CPU limit 识别不准,可以用 -XX:ActiveProcessorCount=4 显式指定,但优先修容器配置。
  • 堆 dump 路径要挂持久卷,否则 Pod 重启后文件就没了。

低延迟服务可以试:

bash
-XX:+UseZGC -XX:+ZGenerational -XX:MaxRAMPercentage=70

ZGC 不是“无脑更快”。小堆、短生命周期对象多的场景,G1 往往更稳;ZGC 的收益主要体现在大堆和低暂停目标上。先压测,再换 GC。

4. 排查命令

bash
# 堆与 GC 概况 jcmd <pid> GC.heap_info jstat -gcutil <pid> 1000 # 原生内存跟踪(有额外开销,排障时开) -XX:NativeMemoryTracking=summary jcmd <pid> VM.native_memory summary # 线程栈 jcmd <pid> Thread.print

GC 日志里重点看:

  • Young GC 频率和耗时
  • Full GC 是否出现
  • 每次 GC 后老年代占用是否持续上升(内存泄漏信号)
  • 堆外内存是否增长(NMT)

四、友好 NPE(JDK 14 引入,15 默认开启)

Java 8:

text
Exception in thread "main" java.lang.NullPointerException at com.example.OrderService.create(OrderService.java:42)

Java 15+:

text
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "name" is null at com.example.OrderService.create(OrderService.java:42)

不需要改代码,升级就有。它依赖编译时保留局部变量表;Maven 的 debug 默认是 true,一般没问题。如果看到 <local1> 这样的名字,检查一下是否关掉了调试信息。

注意:这只是排障辅助,不要把异常消息格式当成稳定 API 去解析。


五、被删掉的东西

版本移除/禁用替代
11Java EE 模块、CORBA显式引入 Jakarta 依赖
14CMS GC、Pack200G1/ZGC;jlink/jpackage
15NashornGraalJS 或其他 JS 引擎
15偏向锁禁用重新评估高竞争 synchronized
17SecurityManager 弃用容器/OS 权限控制
18finalize 弃用try-with-resources / Cleaner

几个最常见的迁移点:

1. JAXB / JAX-WS

JDK 11 移除了 Java EE 模块,javax.xml.bind.*、javax.xml.ws.* 不再随 JDK 提供。需要显式加 jakarta.xml.bind-api 和对应的运行时实现(如 jaxb-runtime),或者改用 Jackson 处理 XML。

2. javax.annotation

@PostConstruct、@Resource 这些注解在 JDK 11+ 不再自带。Spring Boot 3 里统一换成 jakarta.annotation.*,由 starter 带入依赖。

3. sun.misc.BASE64Encoder

JDK 9 就移除了,换成 java.util.Base64:

java
var encoded = Base64.getEncoder().encodeToString(bytes);

4. Nashorn

JDK 15 移除。以前用 ScriptEngineManager().getEngineByName("nashorn") 执行 JS 的代码,需要换 GraalJS,或者把这段逻辑改成 Java/服务化。

5. finalize

不是立刻删除,但不要再用它释放资源。用 try-with-resources,或者 Cleaner(JDK 9+)作为兜底。


六、UTF-8 成为默认字符集(JDK 18)

1. 变化是什么

JEP 400 之后,file.encoding 默认是 UTF-8。对中文 Windows 项目,这意味着:

  • 以前默认可能是 GBK,new FileReader(file)、new FileWriter(file)、String.getBytes() 这类不显式指定编码的代码,行为变了。
  • 控制台编码不一定跟着变。中文 Windows 的 native.encoding 仍然可能是 GBK,日志可能依旧乱码。

2. 怎么修

原则只有一条:所有跨字节的边界都显式指定字符集。

java
Files.readString(path, StandardCharsets.UTF_8); Files.writeString(path, content, StandardCharsets.UTF_8); string.getBytes(StandardCharsets.UTF_8); new String(bytes, StandardCharsets.UTF_8);

不要依赖:

java
new FileReader(file); // 使用默认编码 new FileWriter(file); // 使用默认编码 string.getBytes(); // 使用默认编码

3. JVM 参数

bash
# 统一文件编码 -Dfile.encoding=UTF-8 # JDK 19+ 可以单独指定标准输出/错误输出编码 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8

如果必须兼容旧行为,可以临时:

bash
-Dfile.encoding=COMPAT

但这是权宜之计,不要把它写进长期启动脚本。

4. Spring 侧配置

yaml
server: servlet: encoding: charset: UTF-8 force: true spring: messages: encoding: UTF-8 logging: charset: console: UTF-8 file: UTF-8

数据库和 JDBC 也要一起确认:

  • MySQL 连接串加 characterEncoding=utf8(或 utf8mb4 对应配置)。
  • 表/列的字符集用 utf8mb4,避免 emoji 和四字节字符丢失。
  • 文件上传、模板渲染、CSV 导出都要显式指定编码。

5. 一个容易误解的点

Compact Strings 对中文帮助有限。 它只对 Latin-1 字符压缩到 1 字节,中文仍然是 2 字节/字符。不要指望升级后中文文本内存直接减半。


七、启动、镜像与观测

1. 应用 CDS

类数据共享可以把常用类提前归档,缩短启动时间:

bash
# 训练:跑一遍典型启动流程 java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # 生产:使用归档 java -XX:SharedArchiveFile=app.jsa -jar app.jar

训练用的 JDK 版本、启动参数、依赖要和生产一致,否则归档可能失效。Spring Boot 3.3+ 对 CDS 有更好的支持,可以结合分层提取使用。

2. JFR

JFR 从 JDK 11 起开源,生产环境可以直接开:

bash
-XX:StartFlightRecording=filename=app.jfr,settings=profile,dumponexit=true

运行时也可以手动采集:

bash
jcmd <pid> JFR.start name=profile settings=profile duration=60s filename=app.jfr jfr summary app.jfr jfr print --events jdk.GCPhasePause app.jfr

JFR 对定位 GC、线程阻塞、锁竞争、虚拟线程 pinning 都很有用,开销通常比想象中小。

jlink 用来裁剪定制运行时,jpackage 用来打原生安装包。它们适合工具类应用和桌面应用;Spring Boot fat jar 不是天然契合,真要做裁剪,Boot 3.3+ 的 tools jarmode 可以先分层提取。

大多数服务不需要走到这一步,标准 JDK 镜像 + CDS 已经够用。

4. AOT 与 Native Image

Spring Boot 3 支持 GraalVM Native Image,可以把应用编译成原生可执行文件,换来更快的启动和更低的内存占用。代价是:

  • 构建时间长、产物大
  • 反射、动态代理、资源加载需要 RuntimeHints 或注解显式注册
  • 峰值吞吐不一定比 JVM 好
  • 与虚拟线程、动态字节码增强的组合需要逐项验证

如果团队没有明确的启动时间/内存硬指标,JVM 模式 + 虚拟线程通常是更划算的选择。

5. 容器镜像

  • 用 Temurin、Corretto、Zulu 等有长期维护的发行版。
  • 基础镜像和构建 JDK 版本保持一致。
  • 容器里用 MaxRAMPercentage,不要写死 -Xmx。
  • 健康检查、优雅停机、堆 dump 路径一起配好。
  • 不要把 --enable-preview 带进生产镜像。

八、运行时升级检查清单

  • 先在 JDK 17 上跑通全量测试,再上 21。
  • jdeps --jdk-internals 和 jdeprscan 检查内部 API、废弃 API。
  • 确认没有依赖自定义 agent、字节码增强、老版本 CGLIB/Lombok/Byte Buddy。
  • 搜索 new FileReader、new FileWriter、getBytes() 无参调用。
  • 用中文内容做一次文件读写、CSV 导出、日志输出验证。
  • 记录升级前的 GC 日志、堆使用、p99 作为基线。
  • 配好 HeapDumpOnOutOfMemoryError 和持久化路径。
  • 确认容器内存/CPU limit 与 MaxRAMPercentage 匹配。
  • 开启 JFR,至少保留一份启动和压测期间的记录。
  • 准备回滚镜像和回滚开关。

九、小结

运行时升级的核心不是“调几个参数”,而是确认行为变化:反射能不能过、字符集有没有变、GC 在容器里是否读对了限制、被移除的 API 有没有替代。

下一篇进入 Spring:为什么真正的门槛是 Boot 3 的 javax → jakarta,record/sealed 怎么落到项目里,虚拟线程开了之后为什么 QPS 可能不涨。

本系列共三篇:

  1. 语言篇:语法现代化与标准库
  2. 运行时篇:JPMS、GC、字符集与启动
  3. Spring 迁移篇:Boot 2.x → 3.x → 虚拟线程

本文作者:Weee

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!