同一段代码,从 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 是升级顺带的收益。
| 版本 | 关键变化 |
|---|---|
| 9 | JPMS 模块系统、G1 成为默认 GC、Compact Strings、jlink、jshell |
| 10 | 容器感知、G1 并行 Full GC、应用 CDS |
| 11 | 移除 Java EE/CORBA、ZGC(实验)、TLS 1.3、JFR 开源、Epsilon GC |
| 12 | Shenandoah(实验)、G1 归还空闲内存 |
| 13 | 动态 CDS、ZGC 归还内存、Socket API 重写 |
| 14 | 友好 NPE、移除 CMS、jpackage(孵化)、JFR Event Streaming |
| 15 | ZGC/Shenandoah 转正、移除 Nashorn、偏向锁禁用 |
| 16 | 强封装默认开启、弹性元空间、jpackage 正式 |
| 17 | 强封装强制、--illegal-access 失效、浮点始终严格 |
| 18 | UTF-8 成为默认字符集、finalize 弃用 |
| 19 | 虚拟线程(预览) |
| 21 | 虚拟线程正式、分代 ZGC、KEM API |
module-info.javaJPMS(Java Platform Module System)把 JDK 拆成了模块,也允许应用自己声明模块。但绝大多数 Spring Boot 应用仍然跑在 classpath(未命名模块) 上,不需要写 module-info.java,也不需要把依赖迁到 module path。
真正会咬人的是另一件事:强封装。
--illegal-access 被忽略,不能再靠它统一放行。老库如果反射 java.lang、java.util、java.nio 的内部结构,就会抛:
textjava.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
--add-opensbash# 找出使用了 JDK 内部 API 的依赖
jdeps --jdk-internals target/app.jar
# 找出使用了已废弃 API 的类
jdeprscan --release 21 target/classes
jdeps --jdk-internals 会列出 sun.misc.Unsafe、jdk.internal.* 之类的使用点;jdeprscan 会告诉你哪些 API 在目标版本已被废弃或移除。
处理优先级:
sun.misc.Unsafe、内部序列化结构的依赖。--add-opens 兜底:只对确认无法短期升级的库使用,并记录原因。bashjava --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。两者都不是长久方案。
| 场景 | 建议 |
|---|---|
| 通用 Web 服务、堆不大 | G1(默认) |
| 大堆、对暂停时间敏感 | ZGC(先压测) |
| 小内存容器、单核/少核 | SerialGC 或 G1,按实测 |
| 追求吞吐、能接受较长停顿 | ParallelGC |
| 极低暂停、发行版支持 | Shenandoah |
没有“最好的 GC”,只有“对这个堆大小、分配速率和延迟目标最合适的 GC”。切换前至少记录:吞吐、p99、Full GC 次数、单次最长暂停、容器内存曲线。
不要再用固定 -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 给堆外内存、线程栈、元空间、直接内存留出余量。-XX:ActiveProcessorCount=4 显式指定,但优先修容器配置。低延迟服务可以试:
bash-XX:+UseZGC -XX:+ZGenerational -XX:MaxRAMPercentage=70
ZGC 不是“无脑更快”。小堆、短生命周期对象多的场景,G1 往往更稳;ZGC 的收益主要体现在大堆和低暂停目标上。先压测,再换 GC。
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 日志里重点看:
Java 8:
textException in thread "main" java.lang.NullPointerException at com.example.OrderService.create(OrderService.java:42)
Java 15+:
textException 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 去解析。
| 版本 | 移除/禁用 | 替代 |
|---|---|---|
| 11 | Java EE 模块、CORBA | 显式引入 Jakarta 依赖 |
| 14 | CMS GC、Pack200 | G1/ZGC;jlink/jpackage |
| 15 | Nashorn | GraalJS 或其他 JS 引擎 |
| 15 | 偏向锁禁用 | 重新评估高竞争 synchronized |
| 17 | SecurityManager 弃用 | 容器/OS 权限控制 |
| 18 | finalize 弃用 | 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:
javavar encoded = Base64.getEncoder().encodeToString(bytes);
4. Nashorn
JDK 15 移除。以前用 ScriptEngineManager().getEngineByName("nashorn") 执行 JS 的代码,需要换 GraalJS,或者把这段逻辑改成 Java/服务化。
5. finalize
不是立刻删除,但不要再用它释放资源。用 try-with-resources,或者 Cleaner(JDK 9+)作为兜底。
JEP 400 之后,file.encoding 默认是 UTF-8。对中文 Windows 项目,这意味着:
new FileReader(file)、new FileWriter(file)、String.getBytes() 这类不显式指定编码的代码,行为变了。native.encoding 仍然可能是 GBK,日志可能依旧乱码。原则只有一条:所有跨字节的边界都显式指定字符集。
javaFiles.readString(path, StandardCharsets.UTF_8);
Files.writeString(path, content, StandardCharsets.UTF_8);
string.getBytes(StandardCharsets.UTF_8);
new String(bytes, StandardCharsets.UTF_8);
不要依赖:
javanew FileReader(file); // 使用默认编码
new FileWriter(file); // 使用默认编码
string.getBytes(); // 使用默认编码
bash# 统一文件编码
-Dfile.encoding=UTF-8
# JDK 19+ 可以单独指定标准输出/错误输出编码
-Dstdout.encoding=UTF-8
-Dstderr.encoding=UTF-8
如果必须兼容旧行为,可以临时:
bash-Dfile.encoding=COMPAT
但这是权宜之计,不要把它写进长期启动脚本。
yamlserver:
servlet:
encoding:
charset: UTF-8
force: true
spring:
messages:
encoding: UTF-8
logging:
charset:
console: UTF-8
file: UTF-8
数据库和 JDBC 也要一起确认:
characterEncoding=utf8(或 utf8mb4 对应配置)。utf8mb4,避免 emoji 和四字节字符丢失。Compact Strings 对中文帮助有限。 它只对 Latin-1 字符压缩到 1 字节,中文仍然是 2 字节/字符。不要指望升级后中文文本内存直接减半。
类数据共享可以把常用类提前归档,缩短启动时间:
bash# 训练:跑一遍典型启动流程
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar
# 生产:使用归档
java -XX:SharedArchiveFile=app.jsa -jar app.jar
训练用的 JDK 版本、启动参数、依赖要和生产一致,否则归档可能失效。Spring Boot 3.3+ 对 CDS 有更好的支持,可以结合分层提取使用。
JFR 从 JDK 11 起开源,生产环境可以直接开:
bash-XX:StartFlightRecording=filename=app.jfr,settings=profile,dumponexit=true
运行时也可以手动采集:
bashjcmd <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 已经够用。
Spring Boot 3 支持 GraalVM Native Image,可以把应用编译成原生可执行文件,换来更快的启动和更低的内存占用。代价是:
RuntimeHints 或注解显式注册如果团队没有明确的启动时间/内存硬指标,JVM 模式 + 虚拟线程通常是更划算的选择。
MaxRAMPercentage,不要写死 -Xmx。--enable-preview 带进生产镜像。jdeps --jdk-internals 和 jdeprscan 检查内部 API、废弃 API。new FileReader、new FileWriter、getBytes() 无参调用。HeapDumpOnOutOfMemoryError 和持久化路径。MaxRAMPercentage 匹配。运行时升级的核心不是“调几个参数”,而是确认行为变化:反射能不能过、字符集有没有变、GC 在容器里是否读对了限制、被移除的 API 有没有替代。
下一篇进入 Spring:为什么真正的门槛是 Boot 3 的 javax → jakarta,record/sealed 怎么落到项目里,虚拟线程开了之后为什么 QPS 可能不涨。
本系列共三篇:
本文作者:Weee
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!