JDK 25 LTS发布了。消息出来的时候,我正好在跟团队核对一个老服务的依赖树,群里有人甩了个链接,问是不是该把那个跑了两年多的 JDK 21 服务升上去了。这个问题其实没那么好回答——LTS 版本升级从来不是“新版本出来了那就升”这么简单,它牵扯到编译链、字节码库、Agent、框架支持、运维监控,甚至还包括你自己写的那堆反射代码还活不活。
但反过来说,JDK 25 的发布节点又确实值得认真梳理一遍。这是继 JDK 21 之后又一个 LTS,意味着接下来几年内补丁、安全、稳定性维护会围绕它展开。这篇文章我不打算做成特性列表的复述,而是从一个实际做升级、做迁移的 Java 开发者角度,把 JDK 25 里我真正在意的东西拆开讲:哪些特性值得上手,运行时层面有什么体感变化,生态配套到了什么程度,以及最关键的——一条可落地的升级路径。适合正在做技术选型、准备从 17 或 21 往上升、或者单纯想了解这个版本到底改了什么的人。
1. JDK 25 LTS的发布节点:为什么这次升级比以往更有说头
1.1 从JDK 21到JDK 25,中间隔着的不是四个版本,而是一整轮特性筛选
Java 的版本节奏是半年一个功能版本,两年一个 LTS。JDK 21 在 2023 年 9 月落地,之后是 22、23、24 这三个中间版本,再到 2025 年 9 月的 JDK 25,正好凑成一轮完整的 LTS 周期。
我见过不少团队的态度是“中间版本看都懒得看,反正只用 LTS”。这个思路可以理解,但它会带来一个副作用:当你从 21 直接跨到 25 的时候,中间三个版本里堆积的所有变化会一次性砸过来。这里不只是“多了几个新 API”的问题,还包括行为变化、废弃项、工具链要求、第三方库的最低版本要求。说白了,你在 21 上写了两年代码,突然要升到 25,本质上是做了一次跨越两年的变更,而不是一次普通的小版本升级。
中间版本的实际意义,是让整个生态提前验证新特性。很多特性在功能版本里从孵化走到预览,再从预览走到正式,经历过的反馈和修修补补,最后才会沉淀进 LTS。JDK 25 更像是把 22、23、24 里那些被市场验证过的东西做了一次收敛和固化。所以与其说 25 有什么“惊天动地的新东西”,不如说它是 Java 生态下一阶段的地基。
1.2 “LTS”这个标签到底给了你什么
从我接触过的企业项目来看,LTS 的核心价值不是新特性多,而是可预期性。Java 的 LTS 意味着官方和主流发行版会给出一个明确的支持窗口,修复计划、安全更新、兼容性承诺都在一个可控的时间线内。对于需要过等保、过审计、或者有严格运维窗口的团队来说,这一点比任何新语法都重要。
JDK 21 之所以被很多团队接受,是因为它在 ZGC、虚拟线程这些方向上有足够大的吸引力,同时又是 LTS,大家敢把它放进生产。JDK 25 站在同样的逻辑上:虚拟线程用了一两个版本之后趋于稳定,GC 在低延迟方向上继续演进,对象布局和启动速度这两个“硬骨头”也终于有了看得见的进展。对决策者来说,JDK 25 意味着未来几年内有一个稳定的 Java 底座可以依赖。
这里提醒一个容易被忽略的点:虽然 LTS 版本本身支持期长,但你在升级时同时升上去的第三方依赖、运行时参数、部署方式,并不会自动享受“LTS 式承诺”。升级动作本身仍然需要按一次正式变更去做评估。
1.3 两个常见误区,先说破免得后面踩坑
第一个误区:LTS 一发布就急着全量升级。哪怕特性再稳,只要你的依赖树里有任何一个字节码增强库没跟上,上线就可能直接炸。第二个误区相反,觉得“等等再说”最安全,结果拖到旧版本的公共安全更新快过期了才被动升级,时间窗口被压缩得很紧,反而更容易出问题。
我的建议是,把升级看成一条持续几周的渐进路线,而不是一个周末的加班动作。先选一个内部系统做试点,跑完测试再灰度,最后再铺开。这篇文章后面会专门讲这条路线怎么走。
2. 语言层和API层的实质变化:哪些特性会写进你的代码
2.1 转正与预备转正:从预览到可上线的距离
这一轮我最关注的是 Stream Gatherers 的进展。如果你一直在写 Java 的 Stream 链式调用,一定遇到过这种场景:想给流里加一个“按固定窗口打包”的中间操作,或者想在元素流通过程中做有状态去重,但标准 Stream API 给不出一个理想的中间算子,只能硬着头皮写 collect,或者引入第三方库。
Stream Gatherers 就是来补这个缺口的。从目前节奏看,它已经经历了多轮预览,在 JDK 25 转正的概率相当大。它引入了一个新的gather中间操作,配合各种内置 Gatherer 使用。一个很直观的例子:
// 按每 3 个元素为一个窗口分批,窗口之间不重叠 Stream.of(1, 2, 3, 4, 5, 6, 7, 8) .gather(Gatherers.windowFixed(3)) .toList(); // 结果是 [[1, 2, 3], [4, 5, 6], [7, 8]]另一个让我觉得“终于来了”的方向是 Flexible Constructor Bodies。传统 Java 构造器里有一条严格规则:在this()或super()调用之前,你不能给实例字段赋值,也不能做太多参数校验。这就导致一些参数校验逻辑只能写在静态方法里绕弯子。新的方向允许在调用父类构造器之前执行字段初始化和参数验证,代码能写得更直接。这类改变对库作者非常友好,普通业务代码可能感受没那么强,但它会简化很多框架层的实现。
除此之外,我还比较关注字符串模板的后续状态。它之前在预览阶段收到不少反馈,在落地细节上反复打磨。字符串模板如果能转正,对日志组装、SQL 拼接、文本生成这类代码的简化效果会是肉眼可见的。如果这次还没转正,至少说明它离稳定越来越近,值得在内部项目里提前尝试。
注意:上面提到的“转正概率极大”“比较关注”这类说法,是基于当前 JEP 排期和预览状态的判断,最终以发布版特性列表为准。但方向是确定的:这些特性已经进入主流视野,就算这个版本没完全落地,下一个版本也基本跑不掉。
2.2 还在预览或孵化区的方向:现在碰要有个度
JDK 25 不只带来转正特性,也会继续推进一些“未来时”的能力。这类特性我一般建议看看就好,除非团队有很强的尝鲜意愿,否则别往生产代码里引。
目前最值得关注的方向是 Primitive Types in Patterns,也就是把基本类型纳入模式匹配的体系。原来instanceof Integer i这种写法在拆箱和装箱之间会有效率损耗,如果基本类型可以直接参与模式匹配,那么在大量数值处理的场景下会有性能收益。还有一个持续活跃的方向是 Valhalla 项目相关的值类型和基本类型泛型支持,它指向的是 JVM 对现代硬件和内存布局的重新思考。这些方向在 25 里更多还是预览或孵化级别,真正大规模使用可能还要再等一两个版本。
对于预览特性,常见做法是编译时需要加--enable-preview,运行时要额外加 JVM 参数,而且后续版本可能调整行为甚至移除。所以我的态度是:可以不看,但不能不知道。你身边总有同事会用新 JDK 写点东西,到时候他跑出来一个 “preview features are enabled” 的报错,你得知道那是什么意思。
2.3 编译输出与兼容性:为什么“能编译”不等于“能上线”
很多人在升级 JDK 时默认“代码能编译过、测试能跑通就完事了”,但 Java 的兼容性风险从来不只是源码层面的。JDK 25 生成的 class 文件主版本号是 69,如果你的某个依赖里捆着一个老版本 ASM,或者你用的字节码增强库还没更新,它在加载 class 时就可能直接抛出 “Unsupported class file major version 69”。这不是你的代码写错了,而是工具链不认识新的字节码形态。
另一个容易忽略的点是--release参数。用 JDK 25 的 javac 编译时,如果指定--release 17,编译输出的字节码版本会被压到 JDK 17 能识别的范围,同时编译器也会用对应版本的 API 做检查。这个做法在混合 JDK 部署时很实用:编译和运行可以不同步升级,减少一次性变更的风险。
javac --release 17 -encoding UTF-8 src/main/java/**/*.java所以“能编译”只是第一步,真正决定能不能上线的是:整个运行的字节码增强链路、反射访问、内部 API 调用、第三方 Agent,全都得认识 JDK 25 的运行时形态。这部分后面会展开讲。
3. 虚拟机和运行时层面的改进:体感差异从这些地方来
3.1 GC策略的成熟:默认还是G1,但其他选项更香了
普通业务服务的默认垃圾回收器仍然是 G1,这一点没变。但 JDK 25 这个节点上,ZGC 和 Shenandoah 这些面向低延迟的收集器已经进入比较成熟的状态。
分代 ZGC 从 JDK 21 转正之后,已经经过多个版本的打磨,到 25 上属于可以放心使用的水准。Shenandoah 的分代支持也在后续版本里重新回到主线,并且继续演进。对大多数互联网应用来说,真正重要的指标不是单次 GC 多快,而是 TP99 和 TP999 的长尾延迟。如果你在 JDK 8 上被 Full GC 停顿折磨过,转到 25 上的分代 ZGC 会是一个很直观的改善。
实际操作中我建议这样选:延迟敏感、堆内数据大、追求稳定 P99 的服务,可以开-XX:+UnlockExperimentalVMOptions -XX:UseZGC之类的方式来跑对比;对吞吐要求高的批处理任务,继续用 G1 也没问题。GC 不是越新越好,是越适合业务场景越好。
3.2 对象头压缩带来的内存收益
我一直觉得 JVM 在堆内存上的浪费被很多人忽略了。一个 Java 对象在 64 位 JVM 上默认有很厚的对象头,尤其是在开启压缩指针前,对象头能占到一定比例的内存。JDK 25 这条线上,Compact Object Headers 是一个非常值得关注的方向。它想做的事是压缩对象头,让每个对象的内存占用降下来,从而提升缓存友好性,降低 GC 压力。
这个方向的收益用一句话概括:在小对象特别多的服务里,内存占用会肉眼可见地下降。假设你的服务堆里有几百万个只有几个字段的小对象,对象头压缩省下来的可能不止几十 MB,在某些极端情况下能省出几百 MB 乃至更多。这在云原生环境里直接对应着成本下降。
当然,压缩对象头不是没有代价。它会影响对象访问的偏移计算、与压缩指针的交互,以及部分 native 内存访问方式。所以这种改动一定是在大量实验和兼容性测试之后才会彻底放开。这个方向上 25 大概率还是渐进推进,但“对象头占用可以被压缩”这件事本身,已经值得所有做容量规划和性能调优的人关注。
3.3 启动时间与镜像化:云原生场景的隐性红利
Java 在云原生环境里一直被吐槽的点是启动慢。JDK 25 在启动时间和内存占用上继续给出了不少优化手段,包括增强的 CDS、AppCDS,以及 Project Leyden 带来的提前编译方向。这些措施对微服务尤其有意义:副本扩容、弹性伸缩、周期性离线任务,都对冷启动时间有很高要求。
我自己在测试一个 Spring Boot 类项目时,一个很深的体感是,配合 CDS 归档和基础镜像优化后,冷启动耗时能比裸跑少一大截。这不是 JDK 25 单独带来的,而是新版本对原有的 AppCDS 、类数据共享机制做了更好的默认调整,让这些能力更容易被普通开发者用起来。
如果你在跑短生命周期进程,或者经常做弹性扩缩容,建议在升级后花点时间测一下启动时间,这可能是你最先感受到的差异。
4. 生态兼容性实测:真正劝退你的往往是这里的坑
4.1 字节码增强和Agent:升级后最容易炸的一环
JDK 版本升级的“第一杀手”几乎都是字节码库。不是业务代码不兼容,而是那些以 ASM、ByteBuddy、CGLib 为基础的库,在遇到新 class 版本时集体罢工。
常见的场景包括:测试覆盖率工具在启动时注入字节码、链路追踪 Agent 做类增强、APM 工具在运行时修改类结构、热部署工具、AOP 相关的动态代理。这些工具一旦内置了老版本字节码解析器,遇到 class file major version 69 就会抛异常。异常信息往往还很不直观,你只知道服务启动到一半挂了,日志里翻半天才看到一句关于 class version 的话。
所以升级前的第一件事,不是看自己的代码改没改,而是扫描核心运行时链路里有哪些字节码相关依赖,逐个确认新版本支持情况。这一步省不掉,也别指望“先启动看看,有问题再说”——生产环境没那么多试错机会。
4.2 框架、构建工具与中间件客户端的适配节奏
字节码库之外,第二大坑是框架层面的版本要求。主流框架一般会在 LTS 发布后迅速跟进官方支持声明,但如果你用的框架版本比较老,很可能没被新 JDK 验证过。比如某些老版本 Web 框架里直接依赖了 JDK 反射的内部行为,换到新 JDK 上可能就会报InaccessibleObjectException。
构建工具同样如此。Maven 和 Gradle 本身对 JDK 版本的适配通常比较快,但项目里的编译插件、代码生成插件、Lombok 这类注解处理器就需要特别留意。一个典型的报错是 Lombok 在处理某个类时报“java.lang.ExceptionInInitializerError”,表面上看是代码问题,其实是注解处理器对新 JDK 内部 API 的支持没跟上。
中间件客户端也会影响升级。注册中心客户端、消息队列客户端、RPC 框架往往通过反射扫描类路径,如果内部调用了 JDK 模块里未导出的包,升级后就会出现运行时错误。这些依赖大概率在你最初的“编译检查”里毫无问题,所以不要只依赖编译结果来判断兼容性。
4.3 一个内部服务的迁移复盘:class file version 69的早晨
我们这边有个内部老服务“模拟项目 X”,维护了几年,依赖树里躺着一堆年纪不小的库。当时准备从 JDK 17 升 25,第一轮编译很顺利,所有代码都过了,连单元测试也跑了大半。我们以为这事儿稳了。
结果第一次集群联调,服务刚启动就崩。日志里有这样一段:
java.lang.IllegalArgumentException: Unsupported class file major version 69 at ...ASM...ClassReader.<init>(ClassReader.java:...)报错指向一个老版本的动态代理库。它底层还在用 ASM 6,只认识 class file 52(JDK 8 对应的版本),自然啃不动 69。排查过程其实不难:先看抛异常的栈顶框架,再反查是哪个库在调用,然后到依赖树定位版本。真正的问题是,这个库埋在很深的传递依赖里,直接改顶层项目还改不到,最后只能借助依赖树滚动更新,把那条链上的组件统一升级。
那次之后我们定了一条规矩:任何 JDK 大版本升级,必须提前跑一遍“Agent 和字节码库兼容性清单”,并且用jdeps把内部 API 的引用全部揪出来。这套动作看起来不起眼,但能把上线日的“惊喜”提前两周消化掉。
5. 一套稳妥的升级路径:从“能编译”到“敢上线”
5.1 第一步:依赖与工具链体检
先把目标定成“能编译”,而不是“能上线”。用新 JDK 把项目完整编译一遍,这一步的意义是快速暴露源码层面的兼容问题。编译时别直接用默认参数,建议显式指定--release或者通过构建工具统一设置 toolchain,避免“编译用的 JDK 和运行用的 JDK 不一致”这种低级问题。
同时跑一遍依赖扫描,把字节码库、Agent、IDE 插件、注解处理器全部过筛。一个很实用的命令是:
jdeps --jdk-internals --multi-release 25 你的应用.jar这个命令会列出你应用里对 JDK 内部 API 的调用位置,方便你提前处理那些“未导出包”的问题。扫描结果里如果出现了JDK removed. internal API之类标志,基本都是升级后的潜在雷点。
5.2 第二步:编译期修复与测试基线
把编译错误、静态检查警告清理完之后,进入测试阶段。这里有一个我特别推荐的技巧:把编译警告升级为编译错误来跑一遍测试。
// 比如在 Maven 里配置 <compilerArgs> <arg>-Xlint:all</arg> <arg>-Werror</arg> </compilerArgs>这么做的原因很简单:JDK 新版本往往会把原来的黄色警告变成运行时问题,警告解决得越干净,升级后出问题的概率就越低。然后跑一遍完整的单元测试、集成测试,再看测试覆盖率。如果项目的用例足够健壮,这一轮能过滤掉大部分问题。
注意,测试环境用到的 Agent 也要跟着升级。很多用例在本地 IDE 里能跑,到了 CI 环境因为测试工具版本太老而瘫痪,这种情况也常见。
5.3 第三步:分阶段灰度与指标对比
测试过了不代表生产可以一把梭。稳妥做法是先在非核心服务上跑,选择一个流量占比极小的节点池,切成新版本,跑 24 到 48 小时。灰度期间要盯的指标,我一般用下面这张表:
| 观测维度 | 具体指标 | 关注原因 |
|---|---|---|
| GC 表现 | Full GC 次数、Young GC 耗时、GC 总停顿 | 判断 GC 策略调整是否有效 |
| 内存表现 | 堆使用率、MetaSpace 大小、对象头压缩是否生效 | 验证新版内存收益 |
| 延迟表现 | P99、TP999、错误率 | 判断运行时性能衰退 |
| 启动性能 | 冷启动耗时、类加载耗时 | 验证 CDS 和启动优化效果 |
| 工具兼容 | Agent 日志、监控链路是否正常上报 | 提前捕捉生态坑 |
灰度放量节奏可以按 1% 到 5% 到 20% 再到 50% 这样递进,避免一个隐藏问题在尚未发现时被放大到全量流量上。每放大一档,至少观察一个完整业务周期。
5.4 第四步:回退方案与长期观测
升级最后一步不是上线,而是想清楚“怎么退”。保留旧版本的部署模板和镜像,确认回退操作可以在几分钟内完成。即便升级顺利,也需要在 GA 后继续观察一段时间的指标,因为有些问题是流量高峰才会暴露的。
长期观测中最容易被忽视的是“渐进式问题”:Java 进程的内存缓慢增长、某些异常在特定输入下偶发、低版本 Agent 在长时间运行后被触发。这些很难在灰度初期发现,需要你给监控系统配好告警阈值,并至少在升级后连续观察两周。
另外有一个实操小技巧:在 JVM 参数里加-XX:+PrintCommandLineFlags,确认最终运行时生效的参数是你预期的那组,避免某个平台脚本悄悄覆盖了。这个动作我每次升级都会做,省下来的排查时间不计其数。
关于这次升级,我的个人体会
JDK 25 LTS 给我的感觉,不是“某个划时代的巨变”,而是一轮扎实的沉淀。虚拟线程、分代 ZGC、对象头压缩、启动时间优化,这些方向在中间版本里已经能看到雏形,到 LTS 节点聚合成一个可以放心依赖的底座。真正的挑战从来不在 JDK 本身,而在你身处的生态依赖树里。
我自己做升级有个习惯:先在本地写一个最小化 Demo,把项目里最核心的一条业务链路跑通,然后对着jdeps和启动日志检查每个可疑点,之后才轮到测试环境、灰度环境。这套流程帮我们避开了好几次“上线前才发现 Agent 不兼容”的尴尬。这次 JDK 25 也不例外。如果你正在评估升级,我建议你先别急着改代码,把依赖树和工具链清单拉出来看一眼,比什么都管用。