Java 17 发布已经有段时间了,但直到现在,我在很多技术群里看到的第一个问题依然是:“Java 17 到底新增了哪些新特性?升级值不值?”说明大部分人其实都在观望,手里还牢牢握着 Java 8 或者 Java 11。作为一个从 Java 8 时代一路维护老系统、后来又接连把几个服务迁到 Java 17 的实践者,我可以负责任地说:Java 17 不是那种“宣传很猛、实际没感觉”的版本,它的每一项改动都直接影响你的编译参数、运行时报错和安全策略。
这篇文章我不想写成官方 JEP 的翻译稿,而是尽量用踩过坑的口吻,把 Java 17 的新特性拆开揉碎,讲清楚它解决了什么问题、在什么场景下用、迁移时会遇到什么坑。同时考虑到很多朋友搜到这篇文章其实是想先解决“怎么装”的问题,我还会把 Linux 和 macOS 上安装 Java 17 的完整流程、环境变量配置、以及从 JDK 8/11 升级的实操建议一起放进来。无论你是刚入门的新人,还是在维护老项目的资深开发,这篇文章都能帮你少走不少弯路。
1. 为什么要关注 Java 17
1.1 LTS 版本的含义与版本节奏
Java 从 2017 年开始改成半年一个功能版本,Oracle 每六年发布一个 LTS(长期支持)版本。Java 8 是 2014 年发布的 LTS,Java 11 是 2018 年发布的 LTS,Java 17 则是 2021 年 9 月发布的 LTS。很多人不理解为什么那么多团队一直在 Java 8 上不动,其实不是因为 Java 8 够用,而是因为非 LTS 版本的生命周期太短,企业不敢把生产环境跑到一个半年后就停止免费维护的版本上。
Java 17 作为 LTS,享受的是至少五年的 Oracle 高级支持,后续还有延长的维护期。对于大多数公司来说,从 Java 8 或 Java 11 升级,最安全的下一个着陆点就是它。更重要的是,Java 17 在语言、运行库、平台支持和内部封装方面都做了大量收口工作,很多从 Java 9 以来一直“预览”“孵化”的特性,到 17 已经正式落地,这意味着你今天学的东西未来几年都不会被推翻。
1.2 从 Java 8 直接跳到 Java 17,相差到底有多大
如果你还在 Java 8,那我直接告诉你:中间差的不只是一个版本号,而是一整套开发范式的变化。Java 9 带来了模块系统(JPMS),Java 11 移除了 Java EE 和 CORBA 模块,Java 14 带来了 switch 表达式正式版,Java 15 带来了 Text Blocks 正式版,Java 16 带来了 Record 正式版和 instanceof 模式匹配正式版。而 Java 17 在这些基础上,又增加了密封类正式版、强封装 JDK 内部、增强的伪随机数生成器等。
也就是说,从 Java 8 跳到 Java 17,你会第一次感受到“编译器能帮你检查更多东西”。过去写switch忘记break导致穿透错误,过去写 DTO 要手动生成一堆getter/setter/equals/hashCode,过去想限制继承关系只能靠文档约定,这些问题在 Java 17 里都有了语言层面的原生解法。如果你的项目已经用上了 Spring Boot 3.x 或较新的版本,那底层早就是基于 Java 17 编译的了,继续停留在 Java 8 反而会成为上游框架升级的阻碍。
1.3 Java 17 全部 JEP 速览
在进入细节之前,先把 Java 17 包含的 JEP 清单摆出来。JEP 是 JDK Enhancement Proposal 的缩写,每个 JEP 代表一个经过评审的增强提案,看这一张表就能理解 Java 17 的改动范围:
| JEP 编号 | 标题 | 类型 | 一句话概括 |
|---|---|---|---|
| JEP 306 | Restore Always-Strict Floating-Point Semantics | 运行时 | 默认恢复严格浮点语义,结果更可预测 |
| JEP 356 | Enhanced Pseudo-Random Number Generators | API | 新增统一随机数接口和多种算法实现 |
| JEP 382 | New macOS Rendering Pipeline | 平台 | macOS 上 Swing/AWT 渲染走新管道 |
| JEP 391 | Port to macOS/AArch64 | 平台 | 原生支持 Apple Silicon |
| JEP 398 | Deprecate the Applet API for Removal | 清理 | Applet API 进入弃用流程 |
| JEP 403 | Strongly Encapsulate the JDK Internals | 运行时 | 默认拒绝反射访问 JDK 内部 API |
| JEP 406 | Pattern Matching for switch (Preview) | 语言 | switch 支持模式匹配,预览状态 |
| JEP 407 | Remove RMI Activation | 清理 | 彻底移除 RMI Activation |
| JEP 409 | Sealed Classes | 语言 | 密封类正式转正 |
| JEP 410 | Remove the Experimental AOT and JIT Compiler | 清理 | 移除实验性 AOT/JIT 编译器 |
| JEP 411 | Deprecate the Security Manager for Removal | 运行时 | 弃用 SecurityManager |
| JEP 412 | Foreign Function & Memory API (Incubator) | API | 外部函数与内存 API 进入孵化 |
| JEP 414 | Vector API (Second Incubator) | API | 向量 API 进入第二孵化器 |
| JEP 415 | Context-Specific Deserialization Filters | 安全 | 上下文特定反序列化过滤器 |
看完这张表你就会发现,Java 17 的新特性并不是“拍脑袋加功能”,而是“该收的收、该放的放”。语言层面的密封类和 switch 模式匹配负责让代码更安全、更表达意图;运行时层面的强封装负责让 JDK 内部不再裸奔;平台层面的 AArch64 和 macOS 渲染支持则让 Java 在现代硬件上继续工作。下面我按这几个层面逐个展开。
2. 语言层面:密封类与 switch 模式匹配
2.1 密封类正式落地,限制继承从此有语法保证
JEP 409 把密封类(Sealed Classes)带到了 Java 17 的正式版本。什么是密封类?通俗地说,你可以用sealed修饰一个类或接口,然后通过permits显式列出哪些类可以继承或实现它。这样做的目的是打破“继承体系不受控”的局面:过去你定义了一个Shape接口,谁都能实现它,导致下游代码永远无法穷举所有可能;现在你可以告诉编译器,这个接口只允许这三个类实现。
看一段最简单的代码:
public sealed interface Shape permits Circle, Rectangle, Triangle { double area(); } public final class Circle implements Shape { private final double radius; public Circle(double radius) { this.radius = radius; } @Override public double area() { return Math.PI * radius * radius; } } public non-sealed class Rectangle implements Shape { private final double width; private final double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double area() { return width * height; } }这里有一个特别容易踩的坑:被permits列出的子类,必须显式声明为final、sealed或non-sealed三者之一。很多人第一次写的时候直接写public class Circle implements Shape,编译立刻报错,提示缺少修饰符。这个设计很容易理解:如果子类既不封闭也不终态,那“密封”就名存实亡了,因为别人还能在那个子类之后继续无限派生。final表示这条路到此为止,sealed表示子类还可以再列自己的允许列表,non-sealed等于主动放弃密封约束、回归普通类。这三种选择正好对应了三种真实的设计意图。
密封类最大的价值在于配合模式匹配做穷举判断。当你有一个Shape变量,并且确定了它的所有子类后,编译器就能帮你检查switch里的分支是否覆盖完整,不需要再写尴尬的default兜底分支。这也是 Java 团队为什么要同时推动 switch 模式匹配的原因。
2.2 switch 模式匹配进入预览,模式匹配时代开启
JEP 406 把模式匹配扩展到了switch语句和表达式上,在 Java 17 中处于预览状态。预览的意思是:功能已经实现,但 API 和语法仍可能调整,必须显式开启--enable-preview才能使用。你可以在 switch 里直接判断对象的类型,并绑定变量:
public static String handle(Object o) { return switch (o) { case Integer i -> "Integer: " + i; case Long l -> "Long: " + l; case String s -> "String: " + s; case int[] arr -> "Array, length=" + arr.length; default -> "Unknown: " + o.getClass().getName(); }; }注意,这里单个case里能直接声明变量并使用,不用再写instanceof加显式强转。如果分支是带代码块的,需要用yield返回值,否则会报编译错误。我在 IDEA 里测试时遇到最多的问题是:写完case String s -> ...之后,忘了在块里使用s,然后 IDEA 就提示变量 s 未使用或不可达。其实这是好事,说明编译器在强迫你写出更清晰的逻辑。
一旦你习惯了这种写法,再回头看旧的if (obj instanceof Integer) { Integer i = (Integer) obj; }就会觉得非常啰嗦。虽然 switch 模式匹配在 Java 17 还是预览,但 Java 21 已经把它转正并继续扩展了,现在提前学完全值得。
2.3 Record 和 Text Blocks 已成为新代码的默认风格
严格来说 Record 是 Java 16 正式发布的,Text Blocks 是 Java 15 正式发布的。但很多直接看待 Java 17 的开发者,实际接触到它们就是在 17 上。Record 用一行代码定义不可变数据载体:
public record User(Long id, String name, String email) {}这段代码会自动生成构造函数、accessor方法、equals、hashCode和toString。不用再手写十几行样板代码,更重要的是,Record 的字段默认是final的,天然不可变,这在线程安全和缓存场景里太有用了。
Text Blocks 则解决了多行字符串的转义地狱。以前写 SQL 或 HTML 片段时,你要在每行末加\n,还要转义双引号,错误率极高;现在三个双引号就能搞定:
String sql = """ SELECT id, name, email FROM users WHERE status = 'ACTIVE' ORDER BY id DESC """;实际迁移的时候,我的感受是:新特性里最“润物细无声”的就是这两个。它们不像密封类那样有强烈的存在感,但它们让日常写代码的速度和可读性提升了一个档次。如果你从 Java 8 直接切到 Java 17,建议先在项目里把 DTO 改成 Record,把长 SQL 改成 Text Blocks,马上就能感受到差别。
3. 运行时与平台增强:浮点语义、随机数与平台支持
3.1 恢复严格浮点语义:结果稳定压倒一切
JEP 306 的标题是“Restore Always-Strict Floating-Point Semantics”,翻译过来就是“恢复始终严格的浮点语义”。很多人觉得这个特性跟自己无关,其实它关系到任何依赖浮点运算结果一致性的程序。Java 在早期版本里,浮点运算默认是严格按照 IEEE 754 标准进行的,但后来为了利用某些 CPU 的扩展精度寄存器,引入了“非严格”模式。也就是说,同样的表达式在不同平台、不同 JVM 版本上,结果可能有一点点差别;只有显式加了strictfp关键字,才能保证每次都严格按标准计算。
Java 17 直接取消了这种差异:所有浮点运算默认就是严格模式,strictfp关键字虽然还保留,但已经没有什么实际作用了。对开发者的影响主要有两点:一是涉及科学计算、数据比对的系统,在升级到 17 后测试数据的基线可能要重新校准;二是性能上可能会有细微差别,因为 JVM 不能再走某些不严格的快速优化路径。我在做数值指标归因时,曾经因为一个浮点数尾差导致校验任务失败,查到最后就是升级带来的行为变化。所以遇到这类问题,别急着怀疑 Java 17 有bug,先检查浮点运算的前后一致性。
3.2 增强伪随机数生成器:终于不用啥都靠第三方
JEP 356 给 JDK 的随机数体系做了一次完整升级。以前我们生成随机数常用的java.util.Random、ThreadLocalRandom、SecureRandom,虽然各自能用,但并没有统一的抽象,而且算法选择非常有限。Java 17 引入了新的顶级接口RandomGenerator以及一系列子接口,还内置了大量的新算法实现。
可以通过RandomGenerator.of("算法名")直接获取一个生成器:
RandomGenerator generator = RandomGenerator.of("L128X256MixRandom"); generator.nextInt(100);也可以用RandomGeneratorFactory.all()列出当前 JVM 支持的全部算法。Java 17 内置的算法分为几大系列,表格整理一下:
| 算法类型 | 代表实现 | 典型用途 |
|---|---|---|
| 传统 Linear Congruential | L32X64MixRandom | 轻量级通用随机 |
| Xoroshiro/Xoshiro 系列 | Xoshiro256PlusPlus | 高性能、非加密场景 |
| LXM 系列(L64X128,L128X256 等) | L128X256MixRandom | 高周期、并行流友好 |
| 加密强随机 | SecureRandom | 密码学安全 |
新增接口还细分了能力类型:SplitableGenerator负责拆分成多条独立流,适合并行计算;JumpableGenerator和LeapableGenerator支持跳跃式前进,适合蒙特卡洛模拟中生成互不重叠的随机流。如果你正在做 A/B 测试分桶、分布式仿真或游戏抽奖系统,这套 API 能帮你省下不少自研算法的时间和 bug。唯一要注意的是,SecureRandom并没有被替代,涉及加密场景还是得走专门的强随机方案。
3.3 macOS 渲染管道与 Apple Silicon 支持
JEP 382 和 JEP 391 是纯平台层面的改动,但对一部分开发者影响非常直接。JEP 382 说的是 macOS 上 Swing/AWT 渲染会采用新的渲染管道,用来适配现代 macOS 的图形栈;JEP 391 则是把 JDK 官方移植到了 macOS/AArch64 平台,也就是 Apple Silicon 芯片。
如果你是 Mac 用户,此前在 M1/M2 上跑 Java 应用,一般要靠 Rosetta 转译 x86 版本,启动慢、内存占用也偏高。Java 17 提供了原生 AArch64 版本,实测在内存分配和启动速度上都有明显改善。如果你做桌面客户端,涉及 Swing 界面渲染,升级到 17 后可能还会发现 UI 交互更跟手了。这两项特性对于只做后端服务的人可以忽略,但它们决定了 Java 生态在现代 Mac 上能不能继续顺畅地作为主力开发环境。
4. 内部机制收紧与清理:强封装 JDK 内部与其他移除
4.1 强封装 JDK 内部:升级后最让人头疼的一刀
JEP 403 把 JDK 内部 API 的“强封装”规则正式落实到默认状态。什么意思?以前在 Java 8 或 Java 11 中,虽然 JDK 内部类不推荐直接使用,但通过反射往往还是能访问到,比如sun.misc.Unsafe、jdk.internal.*等;从 Java 17 开始,默认情况下直接反射调用这些内部 API 会被拒绝,抛InaccessibleObjectException或IllegalAccessError。
我在迁移老项目时,就遇到过这样一个问题:一个用来做对象序列化的第三方工具,为了绕过类型检查,反射扫描了java.util下的某些内部字段,Java 11 还跑得好好的,换到 Java 17 之后应用启动直接失败。排查下来,根因是内部 API 被封装,第三方库版本太旧。解决办法有两个优先顺序:
- 首选:把第三方库升级到兼容 Java 17 的版本,这是最干净的做法;
- 临时兜底:在启动命令里显式放开某个模块包的访问权限:
java --add-opens java.base/java.util=ALL-UNNAMED -jar app.jar类似的参数还有--add-exports,用于导出内部包。但这类参数只应该在迁移过渡期使用,别把它当成长期方案。它等于把 JDK 内部重新暴露出来,破坏了强封装带来的好处,也让团队对内部 API 的依赖永远清理不掉。
很多老项目抱怨 Java 17 启动就崩,其实绝大多数都是这个原因。好消息是整个 Java 生态在 17 发布前后已经大范围适配,大部分流行框架和中间件的新版本都对 Java 17 有良好支持。你会遇到的坑,通常集中在那些长期没有人维护的冷门依赖上。
4.2 移除实验性 AOT/JIT 编译器
JEP 410 移除了 Java 9 引入的实验性 AOT 和 JIT 编译器,也就是jaotc工具和与之相关的编译路径。当年设计 AOT 编译是为了让 Java 应用直接编译成原生代码,降低启动时间,但由于适用场景有限、维护成本高,最终在 Java 17 被正式移除。
如果你没有用过jaotc,这条对你没有影响;如果构建脚本里还写着jaotc --output libapp.so App.class之类的命令,升级后这些命令会直接不存在。更准确地说,Java 17 移除的是“实验性的提前编译整体方案”,而不是禁止 JIT。现代 JVM 默认的 C1/C2 层级编译依然在工作,只是不再提供官方 AOT 途径。想显著降低启动时间的话,反而是推荐叠加 CDS(Class Data Sharing)或 AppCDS,这些在 Java 17 上更成熟也更实用。
4.3 弃用 SecurityManager 和 Applet API
JEP 411 宣布弃用SecurityManager,为将来移除做准备。SecurityManager是 Java 1.0 时代的沙箱机制,原本用来限制不可信代码的权限,但今天 Java 应用的形态早就变了:大量代码来自可信的依赖库,应用运行在自己的容器或虚拟机上,传统沙箱反而成为维护负担和攻击面。Java 17 里调用System.setSecurityManager会输出弃用警告,API 上标记了@Deprecated(forRemoval=true)。我的建议是:不要再在新项目里使用它,老项目如果用了也要尽早规划替代方案。
JEP 398 则是弃用 Applet API。Applet 是浏览器时代 Java 在网页里跑小程序的技术,随着浏览器插件时代结束,它已经没有任何实际使用场景。这条和普通后端开发基本无关,但它代表 Java 团队清理历史包袱的决心。遇到这类弃用 API 时,最简单的应对策略就是无视它们——只要不在新代码里引用,升级到 Java 17 不会产生任何编译问题。
4.4 RMI Activation 被彻底移除
JEP 407 移除了 RMI Activation 机制,也就是java.rmi.activation包。RMI Activation 原本用于让远端 Java 对象在网络中延迟激活,常用于老式分布式系统和工作流引擎。由于设计复杂、安全性问题突出,这个机制很早就进入了弱维护状态,Java 17 直接把它删掉。
如果你的项目在用Activatable或ActivationGroup相关的类,升级前一定要扫描代码和依赖。可以使用jdeps工具对构建产出进行依赖分析,或者直接用 grep 搜索java.rmi.activation,确认没有引用再升级。大多数现代项目都不会碰到这个包,但遗留系统迁移时需要格外留个心眼。
5. 面向未来的孵化特性:外部函数、向量计算与反序列化安全
5.1 外部函数和内存 API:跟 JNI 说再见的前奏
JEP 412 把外部函数和内存 API(Foreign Function & Memory API)以孵化器模块jdk.incubator.foreign的形式带到了 Java 17。这个 API 的目标是最终替代 JNI 调用 C 库和用sun.misc.Unsafe访问堆外内存的旧方案。
JNI 的痛苦想必大家都有耳闻:写本地方法声明、生成头文件、用 C 编译动态链接库,稍有跨平台需求就要维护多套产物。Unsafe虽然能直接读写堆外内存,但绕过了 Java 的类型安全检查,官方一直在收缩它的可用范围。Java 17 孵化版提供了一种更安全的描述方式,你可以用MethodHandle描述一个 C 函数的签名,然后用MemorySegment表示一段堆外内存空间,并在MemorySession的管理下自动释放。
try (MemorySession session = MemorySession.openConfined()) { MemorySegment segment = session.allocate(100); segment.asSlice(0, 4).set(ValueLayout.JAVA_INT, 42); }这段代码在 Java 17 的孵化 API 里完全合法。虽然孵化阶段的 API 随时可能调整,我个人强烈建议所有玩过 JNI 或对底层操作有兴趣的人提前体验。未来 Java 应用在调用原生库、处理高并发内存操作时,会有一条远比今天干净得多的路径可以走。
5.2 Vector API:把 SIMD 指令带进 Java 代码
JEP 414 是 Vector API 的第二个孵化器版本,它的目标很简单:让 Java 开发者不用写内联汇编或 JNI,就能直接使用 CPU 的 SIMD 向量运算指令。图像处理、音频编码、推荐系统里的特征向量点积,这些场景的瓶颈往往不在内存而在计算指令的并行度,Vector API 恰好就是补这块短板的。
用法上,它引入了VectorSpecies和一系列专用向量类。比如用FloatVector对两个float[]做逐元素乘法并收集到新数组:
var species = FloatVector.SPECIES_256; var vectorA = FloatVector.fromArray(species, a, 0); var vectorB = FloatVector.fromArray(species, b, 0); vectorA.mul(vectorB).intoArray(result, 0);这里的SPECIES_256代表一次处理 256 位数据,也就是 8 个float。如果你的 CPU 支持 AVX2 或 NEON,运行效率会明显好于普通 for 循环。需要提醒的是,Vector API 同样还在孵化模块,API 兼容性不稳定,生产项目直接用风险太大,但做技术预研和性能摸底是非常合适的。
5.3 上下文特定反序列化过滤器:安全加固的务实之选
JEP 415 是 Java 17 中相对低调但非常重要的一项安全增强,它让反序列化过滤器能够根据调用上下文动态生效。过去,ObjectInputFilter要么是全局配置,要么得在每个ObjectInputStream上手动设置,控制粒度很粗。JEP 415 引入了更灵活的规则:可以基于当前线程或调用栈的上下文来设定过滤器。
对实际项目来说,最直接的价值是:你可以在网关层或 RPC 入口统一配置反序列化过滤规则,限制反序列化对象的类型、数组长度、对象图深度,从而掐断恶意反序列化攻击的入口。常用的配置方式有两种,一种是在 JVM 启动参数里加:
-Djdk.serialFilter=maxdepth=100;maxrefs=100;maxarray=100000;另一种是在代码里设置全局过滤器:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter( "java.base/*;com.example.**;maxdepth=100;maxrefs=1000" ); ObjectInputFilter.Config.setSerialFilter(filter);我从安全角度给所有做服务端开发的读者一个建议:升级 Java 17 之后,别只盯着语言特性,把反序列化过滤器顺手配上,这是投入产出比极高的一次安全加固。尤其是暴露了 HTTP/JSON、RPC 或者消息队列接口的系统,反序列化攻击仍然是 JVM 生态里最危险的漏洞类型之一,过滤器能帮你限制攻击面。
6. 安装与升级实操:从下载到迁移
6.1 Linux 上安装 OpenJDK 17 的完整流程
很多文章标题写“Java17 下载安装教程详细”,但最省心的其实是命令行安装。如果你在 Linux(比如 CentOS 或 Ubuntu)上手动作业,可以按下面步骤走:
# 建议先把旧版本路径清理干净,避免多版本混淆 sudo rm -rf /opt/jdk/jdk-17* # 创建目录并下载 OpenJDK 17 的官方构建 sudo mkdir -p /opt/jdk cd /tmp wget https://download.java.net/java/GA/jdk17.0.2/dfd4a8d099574f18bedb7c4f2a4e6e83/8/GPL/openjdk-17.0.2_linux-x64_bin.tar.gz # 校验哈希(官方网站会提供对应 SHA256) sha256sum openjdk-17.0.2_linux-x64_bin.tar.gz # 解压到安装目录 sudo tar -zxvf openjdk-17.0.2_linux-x64_bin.tar.gz -C /opt/jdk然后配置环境变量,在/etc/profile.d/java.sh里写入:
export JAVA_HOME=/opt/jdk/jdk-17.0.2 export PATH=$JAVA_HOME/bin:$PATH执行source /etc/profile.d/java.sh后,运行java -version,如果显示openjdk version "17.0.2"就说明安装成功。我特别想提一句:不要忘记sha256sum这一步,官方下载页给出的哈希值就是用来防止下载包被篡改或传输损坏的,省这一步容易在后面编译时遇到各种莫名其妙的错误,再回来排查反而更费时间。
6.2 macOS 和 Windows 安装方式的对比
macOS 上最推荐用 Homebrew,一条命令搞定:
brew install openjdk@17安装完成之后,Homebrew 会提示你设置JAVA_HOME,我一般习惯把这几行加到~/.zshrc:
export JAVA_HOME=$(/usr/libexec/java_home -v 17) export PATH=$JAVA_HOME/bin:$PATHjava_home这个工具会按版本号自动找到对应 JDK 的路径,比自己写死路径要可靠得多。Windows 上则是下载 Oracle 或 OpenJDK 的.msi安装包,一路下一步,然后把JAVA_HOME指到安装目录,再把%JAVA_HOME%\bin加入Path。注意 Windows 上如果还装有旧版本 JDK,多版本切换容易把Path搞乱,建议只保留一个JAVA_HOME入口,不要同时塞多个 Java 路径。
如果你需要在开发机上频繁切换 Java 8、11、17,强烈推荐SDKMAN。因为 wget 下载、解压、软链、改环境变量这些操作,SDKMAN 都替你做了:
sdk install java 17.0.2-tem sdk use java 17.0.2-tem我个人常年同时维护多个项目,有的还锁在 Java 8,用 SDKMAN 切换版本真的能把“切环境”的时间从十分钟压缩到几秒,谁用谁知道。
6.3 升级前的依赖健康检查清单
从 JDK 8 或 11 升级到 17,建议先做一轮完整的体检,别急着改代码。我用得比较顺手的三个命令是:
jdeps --ignore-missing-deps -s target/classes:分析这个 jar 或目录的模块依赖,能看到第三方库依赖了哪些 JDK 模块;jdeprscan --release 17 target/classes:检查代码里是否使用了已弃用的 API;java -XX:+PrintFlagsFinal -version:确认当前 JDK 实际可用的 JVM 参数,提前发现写死的老参数。
依赖检查的重点是那些基于字节码增强或反射的库,比如过旧的 Mockito、CGLIB、ByteBuddy、Fastjson 之类。它们最容易踩“强封装 JDK 内部”的雷。只要把第三方库版本一列,逐个到官网或 GitHub 查一下是不是兼容 Java 17,基本就能预判出大部分问题。
6.4 迁移过程中的典型参数与兜底策略
迁移期最常见的启动报错就是InaccessibleObjectException。遇到它,先不要慌,更不要随手把一串--add-opens全堆上去。更合理的排查路径是:用-verbose:class或堆栈日志确定具体是哪个内部包被访问,再针对性加参数。比如访问了java.util内部:
java --add-opens java.base/java.util=ALL-UNNAMED -jar app.jar如果访问了java.lang内部:
java --add-opens java.base/java.lang=ALL-UNNAMED -jar app.jar这类参数列表长了之后很难维护,我建议统一放在启动脚本或容器环境变量里,加上注释说明是给哪个依赖兜底的。等第三方依赖升级完成后,再逐条删除这些参数,直到最终做到零--add-opens。这也是 Java 17 团队希望所有人做到的最终状态:依赖符合规范,不再触碰 JDK 内部。
7. 常见问题与排查技巧
7.1 密封类编译报错:子类必须声明为 final、sealed 或 non-sealed
这个问题我在前面提到过,但因为它出现的频率实在太高,值得单列。第一次写密封类的人,几乎都会把子类写成普通类。记住,permits里列出的类必须“接住”密封规则,否则编译器会直接拒绝。解决方式是回到设计本身:你是想让这个子类彻底封闭,就加final;想让它的后代继续可控,就加sealed并写自己的permits;想让它的后代完全放开,就加non-sealed。没有第三种写法,也没有“省略修饰符”这个选项。
7.2 启动报InaccessibleObjectException或NoClassDefFoundError
这类报错在升级到 Java 17 的过渡期很常见。NoClassDefFoundError有时并不代表类不存在,而是指该类所在的包不允许被当前模块访问,本质上是封装问题而不是缺包问题。排查顺序应该是:先看堆栈里是哪个包,再查是哪一段反射代码,最后决定是升级依赖还是加--add-opens。千万不要一上来就加一堆兼容参数,那只会把问题藏起来,后面某个环境随时会爆出来。
7.3 反序列化过滤器配置了却没生效
如果你在代码里用了ObjectInputFilter.Config.setSerialFilter,但发现某些场景仍然能反序列化恶意对象,先检查两点:一是这个静态设置是否在所有反序列化操作发生之前执行;二是过滤器配置的语法是否写对。Java 17 的过滤规则支持包名、通配符、深度和引用数量限制,如果配置字符串里有个拼写错误,JVM 可能在启动时没有任何提示,直到真正使用过滤器时才暴露。我的做法是在启动参数里加-Djava.security.debug=serial做验证,能看到系统加载了哪些过滤规则,判断是否真的生效。
7.4 GC 与启动参数变化带来的性能波动
Java 17 的默认垃圾回收器还是 G1,如果你之前用的是 Java 8 的默认配置,升级后 GC 行为会有变化。如果发现某个服务的延迟指标变差,建议先用 JFR 或-Xlog:gc记录一段 GC 日志,对比 GC 暂停频率和暂停时间,再决定要不要调-XX:MaxGCPauseMillis或换用 ZGC。另外,Java 11 之前很多常见的参数,比如-XX:+UseConcMarkSweepGC已经被移除了,启动时如果写得有,JVM 会直接退出。最好的办法是升级后用java -XX:+PrintFlagsFinal -version验证全部参数都在当前版本支持范围内,再进入下一轮压测。
7.5 下载安装时版本冲突和校验问题
Linux 上如果之前用 yum 或 apt 装过 OpenJDK,你手动解压的 JDK 很可能没被正确全局生效。原因多半是PATH里存在旧路径。运行which java看一下当前java指向哪里,再运行echo $JAVA_HOME确认环境变量,就能定位问题。还有,如果下载后解压失败,建议优先校验 SHA256——国内网络环境下下载大文件偶尔会不完整,一个校验能帮你省一晚上的排查时间。网络正常的情况下,也可以直接用包管理工具安装发行版提供的 OpenJDK 17 包,省去手动下载的步骤。
7.6 CDS 和应用打包的坑
最后补充一个容易被忽略的细节:Java 17 是 LTS,很多团队为了追求启动速度,会启用 AppCDS(Application Class Data Sharing)。用 CDS 时一定要确认 JDK 版本一致,跨版本或不同发行商的 JDK 使用同一份 CDS 存档,可能触发校验失败。实测下来,最好的做法是把 CDS 的生成和启动脚本打包在一起,不要在构建机上生成一次存档就到处复用。这个坑虽然不直接属于“新特性”,但在 17 上用得多了,自然会遇到,提前知道能少折腾很久。
这些就是我在迁移和日常开发中反复踩过的典型问题。Java 17 作为 LTS 版本,生态成熟度已经很高,新特性的稳定性和工具链支持也基本到位。对老项目来说,最大的成本从来不是“理解密封类”,而是把历史依赖和内部 API 的账清干净;对新项目来说,Java 17 值得你直接从第一天就在全链路中使用,尤其结合 Spring Boot 3.x,整体体验远优于停留在 Java 8 上的任何方案。升级不必等到“项目空闲”时才做,挑个业务低峰,跑一遍依赖扫描、修复几个兼容点、再观察一两周 GC 和延迟指标,你就能把 Java 17 稳稳接住。