作者 | 朴朴科技平台组
在企业级系统架构演进中,是否进行 JDK 版本升级往往是一个令人头疼的难题。一方面,升级可以享受新版本带来的性能提升和特性增强,另一方面,升级需要面对潜在的兼容性风险和巨大的升级成本。本文将分享我们如何在没有生产故障的前提下,用 6 个月时间,完成 660 个项目从 JDK8 升级到 JDK21 的完整实践,希望能为读者提供参考和借鉴。
现状困境
多年来,我们一直以 JDK8 作为后端 Java 研发的主力版本。然而,随着业务量的持续增长与行业技术的持续演进, JDK8 逐渐暴露出以下问题:
性能与资源瓶颈
随着业务量的增长,JVM 的内存占用和 CPU 使用率不断攀升,部分核心服务需不断扩容计算资源来维持业务正常运转,但这也造成了一定的资源浪费,并且运维的压力日趋加价。
生态兼容受限
Java 社区已将高版本 JDK 作为新技术演进的主战场,且众多新一代主流开源项目(如 Spring Boot 3.x、Kafka 4.0 等)已陆续停止 JDK8 支持,这使得相关依赖的升级变得繁琐,组件兼容性风险逐步显现。
技术持续演化受阻
JDK8 缺乏后期版本 Java 提供的语言特性、开发工具与监控能力,团队难以拥抱新特性和新模式,影响开发体验与效率,阻碍了技术持续演进。
安全可控性下降
JDK8 的历史稳定并不代表未来依然安全。随着攻击方式和行业合规要求的升级,老版本系统面临的新型威胁和治理难度将不断上升,维持旧版本将带来更高的安全和运维压力。
为应对上述问题和挑战,我们启动了 JDK 版本升级专项,力求从根本上解决瓶颈问题,消除历史技术包袱,增强企业的技术创新力。
升级价值
在升级之前,我们系统地评估了将 JDK8 升级到 JDK21 可以带来的价值,具体主要体现在以下几个方面:
性能提升
JDK21 对 JIT 编译器、线程并发管理、垃圾回收、对象管理、内存分配等几个方面进行了优化。比如,下面两图展示的就是同样使用 G1 GC 的情况下,JDK21 相比 JDK8 吞吐量提升近 50%,内存使用率下降了近 60%。
新的语言特性和功能支持
生态与未来演进能力
升级的价值令人期待,但大规模基础架构升级绝非一帆风顺。为了确保整个工程能够顺利推进,我们不仅前置梳理了 JDK 升级带来的潜在风险,也提前对实际落地过程中的可能存在挑战进行了分析。
核心风险识别
JDK 升级涉及的风险不仅在技术兼容层面,更广泛覆盖业务连续性、工程配合和运维响应等多个领域。我们将风险主要归类为三种:
兼容性风险:
运维风险:
隐藏风险:
升级挑战
识别风险只是起点,对于如此大规模系统迁移,在项目实施阶段还面临一系列工程和组织层面的实际挑战。我们梳理了下在本次 JDK 版本升级过程中,挑战主要来自以下几方面:
依赖包量大且关系复杂
项目中存在庞大的自研二方包、三方依赖库,以及各式各样的插件。依赖之间彼此交错,不同应用采用的版本差异也很大,部分底层依赖包可能社区都已经停止维护好多年了,这使得兼容性验证和适配工作量巨大。
项目体量庞大
全公司需升级的核心应用超过 660 个,涵盖了订单、库存、支付、数据分析等各类关键业务模块。庞大的项目数量极大增加了版本兼容性改造、功能回归测试和上线推进的难度。
跨多团队协助
升级项目分布在数十个业务团队和基础架构、运维、测试、稳定等多个职能组。需统筹协调各方资源、确保信息同步、统一进度调控,对组织横向协同和流程治理能力是一个巨大挑战。
升级目标
经过以上分析,团队清晰地认识整个升级专项的难度与挑战。为了能有效地把控升级节奏、降低潜在风险,并确保最终达到预期收益,针对本次专项我们制定了以下核心目标:
按期完成
在 6 个月内完成包括前期调研、工具建设以及 660 多个项目从 JDK8 至 JDK21 升级等工作。
无 P3 以上故障
升级过程中确保业务稳定不受影响,因升级引发的业务故障等级最高不超过 P3,且数量不超过 1。
高效低成本交付
单项目升级总时长控制在 1 小时以内,过程自动化、一键操作为主,最大程度降低人力和协作投入。
升级过程开发无感
升级由平台团队牵头,业务开发团队仅需最小配合,确保业务研发、交付进度和用户体验不受影响。
整体升级策略
为了确保以上升级目标能够实际落地,我们制定了“风险前置、工具先行、分批推进”的总体升级策略。核心理念是在前置环节充分识别和消解升级难点,再通过自动化和规范化工具,最大限度降低团队协作和技术操作门槛,从而保障 2 个季度内按计划、高质量实现业务无感知升级。此外,为了应对预期外的异常,升级方案支持自动回退机制,保障风险可控和业务连续。
上图是围绕这一策略制定的整个升级流程的关键时间节点。在整个升级流程中,最值得关注的是“兼容性问题的全量识别与处理”、“升级工具优化”以及“分级分批推进”这 3 部分,以下将依次展开介绍。
兼容性扫描与方案制定
兼容性深度扫描
通过前置步骤的梳理我们发现扫描的目标对象量大且依赖之间彼此交错,纯靠人工是无法完成的,因此我们引进了开源的 EMT4J 扫描工具。依靠工具和人工验证相结合的方式,对以下关键对象实行全面扫描:
通过 EMT4J 工具对公司各服务所用到的所有插件、二方包和三方包依赖进行兼容性扫描,共计扫描了 2800+ 个依赖包。扫描完后发现总共有 130 个二方、三方包存在兼容性问题。
检视核心单元测试框架(如 JUnit、Mockito 等)、Sonar 等工具链在新 JDK 下是否可用、Mock 能力是否失效。验证发现单测框架和 sonar 都存在兼容性问题,需要升级适配。
梳理 Maven 构建脚本、CICD 流水线、调试工具(arthas)等对 JDK21 的兼容能力。通用验证发现除了构建部署脚本存在兼容性问题需要改造外,arthas 的部分功能在 JDK21 下也无法使用,需要升级适配。
检查应用在新 JDK 版本下的指标采集、链路追踪、告警平台等是否正常,确定升级 JDK 版本对监控告警的影响。验证过程中未发现监控和告警存在不兼容问题。
问题归类与解决方案
对于扫描发现的问题,我们统一收集汇总,并逐个给出解决方案。总的来看,JDK8 到 JDK21 升级产生的兼容性问题可以归纳为 3 大类:反射访问限制、依赖包兼容性,以及参数配置项变化。各类问题的产生原因和解决方案归纳汇总如下:
反射访问问题:
Java
Caused by: java.lang.reflect.InaccessibleObjectException: Unable to make protected final java.lang.Class java.lang.ClassLoader.defineClass(java.lang.String,byte[],int,int,java.security.ProtectionDomain) throws java.lang.ClassFormatError accessible: module java.base does not "opens java.lang" to unnamed module @7a5ceedd
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:354)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:297)
at java.base/java.lang.reflect.Method.checkCanSetAccessible(Method.java:199)
at java.base/java.lang.reflect.Method.setAccessible(Method.java:193)
at com.google.inject.internal.cglib.core.$ReflectUtils$1.run(ReflectUtils.java:52)
at java.base/java.security.AccessController.doPrivileged(AccessController.java:318)
at com.google.inject.internal.cglib.core.$ReflectUtils.<clinit>(ReflectUtils.java:42)
SQL
--add-reads java.base=ALL-UNNAMED
--add-reads java.management=ALL-UNNAMED
--add-opens java.base/sun.reflect.generics.reflectiveObjects=ALL-UNNAMED
--add-opens java.base/java.io=ALL-UNNAMED
--add-opens java.base/java.lang=ALL-UNNAMED
......
--add-opens java.base/java.math=ALL-UNNAMED
--add-opens java.base/java.net=ALL-UNNAMED
--add-opens java.base/java.net.spi=ALL-UNNAMED
--add-opens java.base/java.nio=ALL-UNNAMED
......
--add-opens java.base/java.security=ALL-UNNAMED
......
--add-opens java.base/java.text=ALL-UNNAMED
--add-opens java.base/java.text.spi=ALL-UNNAMED
--add-opens java.base/java.time=ALL-UNNAMED
......
--add-opens java.base/java.util=ALL-UNNAMED
......
--add-opens java.base/javax.crypto=ALL-UNNAMED
......
--add-opens java.base/javax.net=ALL-UNNAMED
--add-opens java.base/javax.net.ssl=ALL-UNNAMED
--add-opens java.base/sun.net.util=ALL-UNNAMED
--add-opens java.base/sun.reflect.annotation=ALL-UNNAMED
--add-opens java.base/jdk.internal.vm=ALL-UNNAMED
......
--add-opens java.base/sun.misc=ALL-UNNAMED
--add-opens java.compiler/javax.annotation.processing=ALL-UNNAMED
--add-opens java.desktop/java.applet=ALL-UNNAMED
--add-opens java.desktop/java.awt=ALL-UNNAMED
......
--add-opens java.datatransfer/java.awt.datatransfer=ALL-UNNAMED
......
--add-opens java.desktop/java.beans=ALL-UNNAMED
--add-opens java.desktop/java.beans.beancontext=ALL-UNNAMED
--add-opens java.desktop/javax.accessibility=ALL-UNNAMED
--add-opens java.desktop/javax.imageio=ALL-UNNAMED
......
--add-opens java.desktop/javax.print=ALL-UNNAMED
......
--add-opens java.desktop/javax.sound.midi=ALL-UNNAMED
......
--add-opens java.desktop/javax.swing=ALL-UNNAMED
......
--add-opens java.sql/java.sql=ALL-UNNAMED
--add-opens java.net.http/java.net.http=ALL-UNNAMED
--add-opens java.compiler/javax.lang.model=ALL-UNNAMED
......
--add-opens java.management/javax.management=ALL-UNNAMED
......
--add-opens java.management/java.lang.management=ALL-UNNAMED
--add-opens java.management/sun.management=ALL-UNNAMED
--add-opens java.management/com.sun.jmx.mbeanserver=ALL-UNNAMED
--add-opens java.management.rmi/javax.management.remote.rmi=ALL-UNNAMED
--add-opens java.naming/javax.naming=ALL-UNNAMED
......
--add-opens java.rmi/sun.rmi.transport=ALL-UNNAMED
--add-opens java.rmi/java.rmi=ALL-UNNAMED
......
--add-opens java.scripting/javax.script=ALL-UNNAMED
--add-opens java.security.jgss/org.ietf.jgss=ALL-UNNAMED
--add-opens java.security.jgss/javax.security.auth.kerberos=ALL-UNNAMED
--add-opens java.security.sasl/javax.security.sasl=ALL-UNNAMED
--add-opens java.smartcardio/javax.smartcardio=ALL-UNNAMED
--add-opens java.sql/javax.sql=ALL-UNNAMED
--add-opens java.sql.rowset/javax.sql.rowset=ALL-UNNAMED
......
--add-opens java.compiler/javax.tools=ALL-UNNAMED
--add-opens java.transaction.xa/javax.transaction.xa=ALL-UNNAMED
--add-opens java.instrument/java.lang.instrument=ALL-UNNAMED
--add-opens java.xml/javax.xml=ALL-UNNAMED
......
--add-opens java.xml/org.xml.sax=ALL-UNNAMED
......
--add-opens java.xml/jdk.xml.internal=ALL-UNNAMED
--add-opens jdk.accessibility/com.sun.java.accessibility.util=ALL-UNNAMED
--add-opens jdk.jdi/com.sun.jdi=ALL-UNNAMED
......
--add-opens jdk.httpserver/com.sun.net.httpserver=ALL-UNNAMED
--add-opens jdk.httpserver/com.sun.net.httpserver.spi=ALL-UNNAMED
--add-opens jdk.sctp/com.sun.nio.sctp=ALL-UNNAMED
--add-opens jdk.security.auth/com.sun.security.auth=ALL-UNNAMED
--add-opens jdk.security.auth/com.sun.security.auth.callback=ALL-UNNAMED
......
--add-opens jdk.compiler/com.sun.source.doctree=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.source.tree=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.source.util=ALL-UNNAMED
--add-opens jdk.attach/com.sun.tools.attach=ALL-UNNAMED
--add-opens jdk.attach/com.sun.tools.attach.spi=ALL-UNNAMED
--add-opens jdk.compiler/com.sun.tools.javac=ALL-UNNAMED
--add-opens jdk.jconsole/com.sun.tools.jconsole=ALL-UNNAMED
--add-opens jdk.management/com.sun.management=ALL-UNNAMED
--add-opens jdk.management.jfr/jdk.management.jfr=ALL-UNNAMED
--add-opens jdk.dynalink/jdk.dynalink=ALL-UNNAMED
......
--add-opens jdk.incubator.vector/jdk.incubator.vector=ALL-UNNAMED
--add-opens jdk.javadoc/jdk.javadoc.doclet=ALL-UNNAMED
--add-opens jdk.jfr/jdk.jfr=ALL-UNNAMED
--add-opens jdk.jfr/jdk.jfr.consumer=ALL-UNNAMED
--add-opens jdk.jshell/jdk.jshell=ALL-UNNAMED
......
--add-opens jdk.net/jdk.net=ALL-UNNAMED
--add-opens jdk.net/jdk.nio=ALL-UNNAMED
--add-opens jdk.nio.mapmode/jdk.nio.mapmode=ALL-UNNAMED
--add-opens jdk.jartool/jdk.security.jarsigner=ALL-UNNAMED
--add-opens jdk.jsobject/netscape.javascript=ALL-UNNAMED
依赖包兼容性问题:
Plain Text
java: java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'
java.lang.RuntimeException: java.lang.NoSuchFieldError: Class com.sun.tools.javac.tree.JCTree$JCImport does not have member field 'com.sun.tools.javac.tree.JCTree qualid'
at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.invocationHelper(JavacTaskImpl.java:168)
at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.doCall(JavacTaskImpl.java:100)
at jdk.compiler/com.sun.tools.javac.api.JavacTaskImpl.call(JavacTaskImpl.java:94)
at org.jetbrains.jps.javac.JavacMain.compile(JavacMain.java:237)
at org.jetbrains.jps.javac.ExternalJavacProcess.compile(ExternalJavacProcess.java:196)
at org.jetbrains.jps.javac.ExternalJavacProcess.access$400(ExternalJavacProcess.java:30)
at org.jetbrains.jps.javac.ExternalJavacProcess$CompilationRequestsHandler$1.run(ExternalJavacProcess.java:269)
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
at java.base/java.lang.Thread.run(Thread.java:1583)
参数变更问题:
EMT4J 报告优化
通过原始的 EMT4J 的规则配置,一个项目的扫描报告中往往会产出数百甚至上千条各类告警,涉及 API 变更、内部访问、过时用法等。下图是一个项目的扫描结果,可以看出这个项目被发现存在 2000+ 个兼容性问题,实际其中需要开发同学处理的问题只有十几个。面对报告中的海量信息,业务团队难以迅速定位必需治理的问题点,一个项目往往需要修改半天甚至一天才能改完。
为此,我们对 EMT4J 进行了二次开发和定制优化,去除一些没有必要的检测规则。去除的规则主要可以归为 2 类:
Plain Text
"spring-core|spring-context|spring-beans|spring-webflux|spring-web|spring-tx|spring-test|spring-oxm|spring-orm|spring-context-support|spring-websocket|spring-webmvc|spring-messaging|spring-jms|spring-jdbc|spring-jcl|spring-expression|spring-aspects|spring-aop", "$version.ge('5.1')"
改造后,EMT4J 的扫描报告简洁明了,只显示需要开发处理的问题。优化后的报告如下图所示。
升级向导开发
为提升整个升级专项的工程效率与一线研发体验,我们自主研发了“JDK 升级向导”,将其作为公司各项目 JDK 升级的统一入口和过程管控中枢。升级向导实现了端到端的自动化覆盖,极大降低升级门槛与出错概率。
在升级过程中,各项目开发人员只需选择目标项目,点击 JDK21 升级,即可跳转到升级向导页面,开始升级流程。下图是升级向导主页面,从图中可以看出升级向导涵盖了服务变更发布全流程,它会自动完成兼容性参数检测与修正、代码扫描与分析、Dockerfile 的适配调整以及各个环境的构建部署,每个步骤中开发人员只需确认即可,除了代码兼容性修订以外,其它都无需人为改动代码。
若升级过程中发现意料外的问题或业务表现偏差,平台还提供一键回退机制,可随时通过界面操作,其代码和配置都会自动还原至上一个稳定版本,做到“过程可视、风险可控、升级无忧”。通过升级向导的建设,我们实现了升级路径标准化、工具化与自动化,大幅提升了团队的协同效率和升级信心。
分批推进
本次 JDK 升级,我们先做了前期的试点,精选了一些简单项目和典型的复杂项目,这样既能够快速打通整体升级流程,也有助于在早期主动发现和解决潜在的复杂兼容性问题,为大规模推广打下基础。但在实际推进过程中,即便试点较充分,仍然会遇到一些预期之外的问题,特别是随着更多项目的逐步上线,也暴露出一些线下难以发现的隐蔽情况。举个例子:
CompletableFuture
xxxFuture = CompletableFuture.supplyAsync(SupplierWrapper.of(() ->
xxxApi.findxxxId(xxx.getId())));
而 JDK9 以后为了修正 tomcat 使用 commonPool 内存泄露问题,将 commonPool 指向 systemClassLoader,导致异步情况下获取不到 Spring 的 classLoader 加载的类,发生 ClassNotFound 问题。
正是因为考虑到会出现像这样前期试点无法覆盖的问题,我们最终采用了分批推进策略。试点后,把所有项目按优先级和影响范围分三批,每批上线前都确认上一批已经平稳运行一段时间。这样一旦新问题暴露,我们也能有充足时间定位和迭代方案,避免风险扩散,保证核心业务和高优系统的稳定。
整个分批推进中,每项升级都会自动做回归测试,按灰度 - 监控 - 正式上线的顺序推进,质量有保障,遇到紧急情况还能一键回滚,尽量将风险和影响降到最低。
经过有序、标准化的升级流程实践,我们成功顺利了 660 个项目从 JDK8 到 JDK21 的平滑升级。以下是已完成的项目列表。
此次升级在交付效率、风险控制、系统性能和成本效益等多个方面取得了显著成效,具体体现在:
升级过程高效稳定
性能提升 & IT 降本
内存大幅下降:在所有升级的 660 个服务中,从 jvm 内存指标上看,平均内存占用下降 51.33%,总计节省数 T 的内存。下图是其中一个服务 JDK 升级前后的堆内存使用情况。
CPU 使用率下降:在 CPU 密集型的服务和原本服务 CPU 使用率较高的情况下,CPU 收益会比较明显。总的来看,约 13% 的服务 CPU 有下降,下降幅度在 10%~30%。
整体吞吐提升:算法侧的服务表现比较明显,其 RT 降低约 10~30%。
本次全公司项目大规模的从 JDK8 到 JDK21 的升级,不仅消除了历史技术债务、提升了系统性能与资源利用效率,更在自动化建设、流程标准化和团队协作模式上有了较大突破。通过统一的升级向导、深度的兼容性扫描和多轮分批实践,我们成功将数百个核心服务的平滑升级变为可大规模复制的工程实践,实现了“高效率、零故障、无感知”的升级体验。
随着云原生、AI 等新技术形态不断涌现,底层技术栈的健康与演进将成为企业核心竞争力之一。我们将继续沉淀升级自动化、兼容性治理和工程协同经验,不断优化平台治理能力,为后续 Spring Boot 主干版本升级、云原生转型等更多技术挑战做好准备。相信有了本次体系化工程升级的经验积累,企业 IT 架构将具备更强弹性和适应力,能够更从容地应对未来的技术变革和业务创新。