ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Java 8到Java 21全链路升级:虚拟线程、GC与兼容性避坑复盘

Java 8到Java 21全链路升级:虚拟线程、GC与兼容性避坑复盘 做Java后台十年从JDK 1.6一路用到今天我太清楚Java 8有多舒服了语法顺手、生态成熟、面试八股文都有标准答案生产环境里一堆老系统靠它在扛。但这两年形势明显不对了第三方框架集体要求JDK 17起步新同事开口就是虚拟线程和record老系统还在用ConcurrentHashMap加手动锁硬扛并发。最麻烦的是Java 8已经停止免费商用更新安全补丁要么买商业授权要么就得自己盯公告。我所在团队去年做了一个大决定把核心业务链路从Java 8整体升级到Java 21 LTS。这篇文章就是这次全链路架构升级的完整复盘从版本节奏、核心特性、JVM底层演进到生产环境里踩过的兼容性大坑一一拆开讲希望能给正在做同样事的人省点时间。1. 先看清版本节奏为什么8到21不是一次简单升级1.1 六年三代LTS你要跨过的不只是编译版本很多人觉得升级JDK就是装个新环境、改个java_home、把sourceCompatibility从1.8改成17就行这是最容易翻车的认知。Java 8发布在2014年Java 21发布在2023年中间隔了整整9年、13个大版本类文件主版本号从52跳到65。换句话说你编译出的class在JVM眼里已经不是同一代的东西了更不用说JVM内部的实现逻辑已经换了好几茬。更麻烦的是版本节奏本身变了。Java 8之前的LTS没有明确规律但从Java 9开始Oracle采用每6个月一个功能版本、每2年一个LTS的节奏所以现在的主力LTS是11、17、21三个点。很多团队从8直接跳到21中间跳过了9到20的所有过渡版本这虽然可行但代价是你得一次性吸收400多个JDK Enhancement Proposal带来的变化。反过来看Java 8到11是第一次大的分水岭模块化和Java EE模块移除都在这一段11到17是第二次强封装和密封类都是这个阶段定型的17到21才是新体验爆发虚拟线程、record模式匹配、顺序集合都在这里。所以我把这次升级拆成三个子阶段来理解而不是一把梭。这里先给一个判断如果你的系统还在Java 8现在最合适的落点是Java 21不是Java 11也不是Java 17。原因很简单21是当前最新的LTS支持周期最长而且虚拟线程这类能直接改变高并发编程模型的特性已经正式发布。虽然17也很稳但你已经付出了升级成本再往前多走一步的边际成本并不高省得几年后又被迫再来一次。1.2 升级的直接收益性能、稳定性和安全基线先算一笔账不然老板那关也过不去。Java 8默认的垃圾回收器是Parallel Scavenge加Parallel Old到Java 9开始默认变G1Java 15之后ZGC和Shenandoah也都生产可用了。同样的代码光是换到G1默认参数很多服务的Full GC频率就能肉眼可见地下降因为G1把大堆并发标记和可预测停顿做得比老回收器好得多。对于响应时间敏感的服务Java 21下的ZGC可以做到亚毫秒级停顿这在Java 8时代是想都不敢想的事。安全方面同样重要。Java 8的SSL/TLS栈默认只到TLS 1.2而Java 11开始默认支持TLS 1.3握手更快、密码套件更安全。新版JDK还会在主版本更新里持续收紧jdk.tls.disabledAlgorithms把不安全的算法逐步禁用。对做支付、电商这类对安全合规有硬性要求的系统来说长期停留在Java 8等于一直在裸奔。还有一个很多人忽略的点Java 10之后对容器有了原生友好支持-XX:UseContainerSupport在8u191虽然也有但后续版本对容器CPU和内存的识别更智能配合-XX:MaxRAMPercentage可以避免JVM在容器里按物理机内存去设置堆大小这个后面实操部分会细讲。2. 语言与API层面的核心特性盘点哪些能用、哪些要慎用2.1 日常最值得立即落地的特性var、record、switch表达式、文本块升级到Java 21之后团队最大的感受不是某个杀手级特性而是日常写代码的舒适度上来了。先从最没风险的开始var。它是Java 10引入的局部变量类型推断只能用在局部变量上不能用在字段和方法返回值上。我一般只在MapString, ListOrderDetail这种长长泛型场景下用比如var details orderService.getDetails();读起来清爽很多。但一定要克制如果你自己都看不出右边new出来的是什么类型那读者也猜不到这时候写全类型反而更好。然后是record这是Java 14预览、16正式定稿的。一个record就是一组不可变数据的载体编译器自动生成final字段、构造器、equals、hashCode和toString。我用它替换了大量以前的POJO加LombokData组合最直观的好处是代码量骤减而且record天然是final的配合不可变集合就能把小对象的线程安全问题从根上解决。这里顺带说一个和“Java对象深度拷贝”相关的点record本身不可变不需要深度拷贝但如果你持有的record字段里有个ArrayList那它还是可变的我习惯在record的compact constructor里做防御性拷贝比如this.tags List.copyOf(tags);。switch表达式和文本块也是上手即赚的特性。switch表达式从Java 14正式定稿箭头语法不会再掉进fall-through陷阱配合yield可以返回值比传统写法清晰太多。文本块从Java 15定稿写SQL、JSON、XML模板时再也不用\n转义堆成山了我们内部一个报表系统升级后所有SQL模板全部重写成文本块可读性上了两个档次。这些特性属于“零成本改写”只要代码评审控制好范围值得立刻推进。2.2 细粒度控制与不可变性密封类、模式匹配、顺序集合Java 17定稿了密封类Java 21把record模式匹配和switch模式匹配也正式落地这几样东西放一起才真正让Java走了向“代数数据类型”靠近的路子。说人话就是你可以把类型层次画死然后用一行switch把不同子类型的逻辑分干净。举个例子之前我们有个订单状态机用枚举加一堆if-else判断类型升级后用带模式匹配的switch写起来是这样的sealed interface OrderEvent permits Created, Paid, Shipped, Cancelled {} record Created(long orderId, long userId) implements OrderEvent {} record Paid(long orderId, String payNo) implements OrderEvent {} record Shipped(long orderId, String carrier, String trackingNo) implements OrderEvent {} record Cancelled(long orderId, String reason) implements OrderEvent {} OrderResult handle(OrderEvent event) { return switch (event) { case Created(var id, var uid) - createOrder(id, uid); case Paid(var id, var payNo) - markPaid(id, payNo); case Shipped(var id, var carrier, var trackingNo) - ship(id, carrier, trackingNo); case Cancelled(var id, var reason) - cancel(id, reason); }; }编译器能静态帮你检查所有分支是否穷尽漏一个case直接编译不过这比运行期才知道漏分支回退到default强太多了。补一句和“Java怎么保证数据一致性”相关的心得record加密封类并不能直接替代分布式事务但它在单服务内把状态流转在编译期约束住极大减少了非法状态转换导致的数据不一致。顺序集合是Java 21一个不太起眼但很实用的API。List、Deque、SortedSet这些接口以前没有一个统一的“取第一个/最后一个”的方法老代码里经常见到list.get(list.size() - 1)这种写法现在直接用getFirst()、getLast()还能reversed()得到反向视图。老代码里大量Collections.reverse(list)再遍历的场景都可以简化掉。2.3 全新的并发编程模型虚拟线程与结构化并发虚拟线程是Java 21里最值得单独开一节讲的特性。它不是用来替代平台线程的而是专门针对“高并发、高阻塞、IO密集”的场景。以前我们用Tomcat的200个线程池硬扛200个并发请求每个请求内部还要调下游RPC、查数据库、调缓存线程大部分时间都在等待IO。虚拟线程相当于把“每请求一线程”的模型重新带回来了但线程变成了由JVM调度的轻量级对象你可以轻松创建几十万个而不用害怕栈内存爆掉。代码里最常用的入口就是try (var executor Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() - callRemoteService()); }使用上有一个在Java 21里必须注意的坑如果虚拟线程在synchronized块里执行阻塞IO会钉住pin底层载体线程导致载体线程被白白占住吞吐反而下降。这是因为synchronized的监视器锁实现不允许释放载体线程。升级之后我专门做过一次代码审查把所有热点路径上的synchronized改成ReentrantLock虚拟线程的吞吐立刻恢复正常。这个问题在后续JDK 24里会从JVM层面修复但你在21上生产使用就得先绕开。建议团队定一条规矩凡是会跑在虚拟线程里的代码锁一律用java.util.concurrent.locks不要用内置锁。结构化并发在21还是预览特性我没把它放进生产代码但思路值得提前理解以前你提交多个子任务等结果时要么CountDownLatch要么CompletableFuture.allOf子任务和父任务的生命周期没有绑定。结构化并发是让子任务的scope和父任务绑定要么全成功要么在第一个失败时取消其余避免线程泄漏和“野火式”失控任务。它试用于编排类服务等转正后再大规模推广。3. 底层演进JVM运行时、GC与内存管理的变化3.1 GC之变CMS已死G1要调参ZGC才是低延迟终点这一节不讲清楚生产环境很容易上来就踩性能大坑。先说结论Java 8默认的Parallel GC在9以后不再是默认G1成为默认回收器CMS在Java 9被标记废弃Java 14正式移除——所以如果升级后你还带着-XX:UseConcMarkSweepGC启动直接会报Unrecognized VM option这是最常见的第一道坎。G1本身的设计是“区域化堆 并发标记 可预测停顿”但它默认停顿目标-XX:MaxGCPauseMillis200对很多应用来说不够激进。我们线上的一个大内存服务堆里有12GB用G1时Young GC有时还是会到400ms以上后来把-XX:MaxGCPauseMillis调成100再把-XX:G1HeapRegionSize从默认自动改为4MB停顿明显平滑了。注意不要盲目把停顿目标调太低比如设到10msG1会因为拼命压缩回收量而导致吞吐下降CPU反而飙升。调G1一定要结合业务。吞吐优先的就别太激进。如果你做的是交易、网关这类低延迟敏感服务直接考虑ZGC。ZGC从Java 15转正Java 21又加入了分代版本叫Generational ZGC启动参数是-XX:UseZGC -XX:ZGenerational。分代ZGC把热对象和冷对象分开处理实测下来吞吐比第一代ZGC好不少停顿依然维持在亚毫秒级。不过ZGC适合大堆机器如果你服务的堆就1-2GBG1已经够了不必为了炫技上ZGC。还有个技巧升级后别急着换GC先在Java 21上保持G1默认跑一周把GC日志和业务指标拉出来对比确认G1参数是否够用再决定要不要上ZGC或Shenandoah。一次只变一个变量出了问题才知道是谁引起的。3.2 字符串、类加载与安全封装的暗线变化这些变化平时看不见但一出问题就很诡异。第一个是Compact StringsJEP 254Java 9开始String的内部存储从char[]改成byte[]如果字符串内容全是Latin-1可表示就用1字节每字符存储否则用2字节。好处是很多中文字符串的内存占用没有变纯ASCII场景内存直接少一半。代价是charAt、substring这些操作的内部实现变了所以千万别再去反射String内部的value字段很多老框架干过这种事在Java 9以后要么拿不到字段要么拿到的是字节数组而不是字符数组处理不当就是乱码。字符串拼接也从Java 9开始改成invokedynamic动态生成拼接逻辑老代码里那种“手动new StringBuilder再拼”的微优化已经没有意义了。第二个暗线是类加载与CDSClass Data Sharing。Java 10之后应用类也能做CDS归档叫AppCDS这玩意儿对启动时间的优化非常直接。我们的一个服务原来启动要8秒用AppCDS后降到5秒左右冷启动扩容的体验好了很多。有兴趣的可以研究-XX:ArchiveClassesAtExit和-XX:SharedArchiveFile这两个参数生产环境配合固定类路径使用效果很稳。第三个暗线是模块化导致的强封装。Java 9的模块系统不只是新语法它对JDK内部包做了强封装。Java 8时代你可以随便反射sun.misc.Unsafe、com.sun.*下面的内部类Java 9到16默认允许访问但打印警告到Java 17之后--illegal-access参数直接被移除了所有JDK内部包默认不允许反射访问。这会直接引爆一堆老库下一部分详细展开。3.3 反射、动态代理与字节码最容易翻车的底层讲完GC和模块得专门留一段给反射和动态代理。因为这和“Java动态代理”、“Java反射”这些高频面试点直接相关也是升级中报错重灾区。Java 8到21JDK的动态代理本身没变变的其实是底层对类定义的约束。老代码用Proxy.newProxyInstance没问题但CGLIB这类基于字节码生成子类的库在Java 9之后会撞上模块封装和类加载器的双重变化生成子类时访问被代理类所在的非开放模块就可能抛InaccessibleObjectException或者IllegalAccessError。还有字节码库本身要跟得上新版class文件格式。Java 21的class文件主版本号是65如果你的ASM版本太老代理库在生成或解析class时就会抛ClassFormatError: Unknown constant tag这类诡异异常。我们的经验是直接用官方升级矩阵核对一遍ASM至少9.5、ByteBuddy至少1.14.9、CGLIB必须换掉或者用基于ByteBuddy的替代实现、Lombok至少1.18.30。这些版本号我都是踩过坑才记住的下面第5部分会做一张速查表。4. 生产级升级实操从评估到灰度发布的完整路径4.1 升级前的资产盘点与兼容性矩阵升级的第一步不是装JDK而是盘点。我把盘点拆成四张清单服务清单、依赖清单、代码清单、运行参数清单。服务清单要列出每个服务的JDK版本、框架版本、负责人和最近发版节奏依赖清单用mvn dependency:tree或Gradle dependencies把全量依赖导出来重点看Spring、Netty、MyBatis、Hutool这些基础设施库的版本要求代码清单就是在代码库里搜索import sun.*、import com.sun.*、Class.forName、setAccessible(true)这些敏感用法运行参数清单收集线上每台机器实际的JVM参数包括-XX前缀的所有配置。然后做一张兼容性矩阵表格式大概是这样的组件Java 8Java 11Java 17Java 21备注Spring Boot 2.7.x支持支持需2.7.9不推荐Spring Boot 3才全面支持17Spring Boot 3.2.x不支持不支持支持支持包名要迁移到jakarta.*Lombok1.18.101.18.101.18.201.18.301.18.30前不支持class 65ByteBuddy1.9.x1.12.x1.14.x1.14.9Mockito等代理的底层ASM679.29.5老版本解析class 65直接崩Netty4.1.x4.1.x4.1.794.1.91高版本修复JDK17反射问题MyBatis3.5.x3.5.x3.5.x3.5.x主要看配套Spring版本这张表是动态维护的直到所有服务升级完毕。盘点完你会很清楚哪些服务可以快速升级哪些要等框架先升级哪些只能维持Java 8然后走网络隔离。4.2 构建链路的切换Maven/Gradle、编译参数与依赖修复构建链路的切换是整个升级里最需要耐心的一环。我建议的路线是先让老代码能跑在新JDK上再动编译目标。具体来说先用JDK 21运行时去跑一个用Java 8编译的旧包验证二进制兼容性。绝大多数场景下JVM 21能正常加载class 52的类但如果代码里用到已被移除的API就会在运行期抛NoClassDefFoundError或NoSuchMethodError这时候按第5部分的清单一个个修。跑绿之后再把编译目标逐步往上提。Maven里最安全的做法是配置maven.compiler.release而不是source和target因为release会把编译期用的JDK API也限制在对应版本避免你开发机能编译、生产环境却找不到新API的问题。比如properties maven.compiler.release17/maven.compiler.release /propertiesGradle 7.6里用toolchain更干净java { toolchain { languageVersion JavaLanguageVersion.of(21) } }第一次用JDK 21编译时几乎必现的报错是“warning: [options] source value 8 is obsolete”以及一堆因为内部API被封禁导致的编译错误。内部API的修复原则是能换公共API就换公共API比如sun.misc.BASE64Encoder换成java.util.Base64sun.reflect.Reflection.getCallerClass换成StackWalker。千万不要习惯性加--add-opens——如果你发现某个依赖必须配合--add-opens java.base/java.langALL-UNNAMED才能跑那就说明这个依赖太老了优先升级依赖本身而不是用JVM参数兜底。4.3 运行参数与容器化部署的调整运行参数这块最核心的一句话老参数能删就删新参数按需再加。升级后第一件事就是把所有-XX:UseConcMarkSweepGC、-XX:UseParNewGC、PermSize、MaxPermGen这些在老JDK上已经不存在的参数全部清掉留下一堆就是启动即失败。原来的-Xms、-Xmx这类参数可以保留但要配合容器新特性一起看。服务部署在Docker/K8s的话强烈建议改用比例式堆设置。Java 8时代我们经常在启动脚本里写死-Xmx4g但同一个镜像在2C4G和4C8G的Pod里跑写死堆就很不合理。Java 10以后的推荐写法是java -XX:InitialRAMPercentage50 -XX:MaxRAMPercentage50 -XX:MinRAMPercentage50 -jar app.jar这样JVM会自动按容器可用内存的50%来定初始堆和最大堆扩缩容时不用改镜像。GC日志这块也要注意Java 9开始统一日志系统以前那种-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc.log的组合已经废弃现在用-Xlog:gc*,gcmetaspaceinfo,gcheapinfo:file/logs/gc.log:time,uptime,level,tags:filecount10,filesize64m记录GC日志并接入监控是升级期间最重要的观察手段别等出了问题再回忆发生了什么。JMX、远程调试这类运维端也要注意Java 9之后com.sun.management.jmxremote相关参数变了jconsole默认连不上的情况很常见。建议统一用JMX_OPTS配合-Dcom.sun.management.jmxremote.portxxxx -Dcom.sun.management.jmxremote.rmi.portxxxx -Djava.rmi.server.hostnamexxx显式设置避免端口随机和hostname不对导致的连不上。4.4 灰度发布、回滚预案与监控对比升级不可能一次性全量铺开。我们的灰度策略是先在一个不核心的边缘服务上用新镜像跑三天观察GC、CPU、内存、错误率、SLA五个维度的指标确认没问题后把核心服务的其中一个节点拉出来作为金丝雀节点流量控制在5%以内跑至少两个完整的业务高峰周期最后再逐步扩大到50%、100%。每个批次之间留足观察窗口这中间不做任何其他发版保持变量干净。回滚预案我建议做成“镜像级回滚”旧JDK8的镜像不删K8s的Deployment保留上一版本出问题时一条kubectl rollout undo回去。因为升级链路里既有JDK变化也有依赖升级一旦出问题很难在几十分钟内定位到具体代码变更回滚永远比在线止血快。我还建议在网关层做一个基于流量的降级开关万一下游服务升级后表现异常能快速把流量切到旧版实例。监控对比方面重点看三组数据一是JVM自身指标Heap使用率、GC次数、GC平均停顿、Full GC次数、线程数二是业务指标P99/P95延迟、吞吐QPS、错误率、4xx/5xx比例三是基础设施指标CPU使用率、内存占用、网络IO。尤其是线程数Java 8服务里动辄上千的平台线程在21上如果引入了虚拟线程线程数曲线会完全不一样。用jcmd Thread.print和JFR分别抓新旧两版的线程栈对比能发现很多隐性死锁和长时间阻塞。5. 生产环境兼容性避坑清单按报错分类的真实案例5.1 反射与模块化InaccessibleObjectException全家桶升级到Java 17以上的第一个高发异常就是java.lang.reflect.InaccessibleObjectException报错长这样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 ...我们线上有个老组件叫“动态字段映射器”靠反射遍历对象所有字段做通用日志脱敏在Java 8上跑得好好的升级后直接启动崩溃。定位思路很简单先看栈顶的反射目标是JDK内部类还是你业务自己的类。如果是业务自己的类比如你的DTO在默认包或未命名模块里反射一般没问题真正出事的是反射JDK内部类比如String、Thread、ClassLoader。解决分三层第一层看能不能改代码避免反射内部类比如脱敏逻辑改用java.lang.reflect.RecordComponent和getRecordComponents去读record字段第二层如果实在绕不开用--add-opens精准开放对应包比如-XX:AddOpens在Java 9里对应的是--add-opens java.base/java.langALL-UNNAMED但这是临时方案上了生产等于在封墙上开洞最好注明负责人和移除日期第三层升级到能兼容新JDK的库版本大多数情况下这才是正解。记住一条底线不要写一堆--add-opens java.base/sun.security.x509ALL-UNNAMED全网拷贝要理解每个开关对应的代码路径不然安全扫描一上来全得返工。5.2 Java EE模块移除JAXB、CORBA、Common Annotations去哪了第二个大坑是Java EE模块整体被移出JDK。Java 11之前JDK里还内置了JAXB、JAX-WS、CORBACORBA在11里已废、Common Annotations这些Java EE API很多老系统在没引任何依赖的情况下就能用javax.xml.bind做XML和Java对象互转升级后发现直接NoClassDefFoundError: javax/xml/bind/JAXBException。我们团队里有一个对接外部旧系统报文的服务就吃了这个亏XML解析用的全是JAXB升级后启动直接挂。解决办法是在pom里显式引入JAXB依赖dependency groupIdjakarta.xml.bind/groupId artifactIdjakarta.xml.bind-api/artifactId version4.0.2/version /dependency dependency groupIdorg.glassfish.jaxb/groupId artifactIdjaxb-runtime/artifactId version4.0.5/version /dependency如果你的项目还是javax.*命名空间版本像Spring Boot 2.x那就用com.sun.xml.bind:jaxb-impl:2.3.9加javax.xml.bind:jaxb-api:2.3.1。这里要注意命名空间迁移Spring Boot 3和Jakarta体系已经全面迁移到jakarta.*如果你升级JDK的同时也顺手升级了Spring Boot 3那所有javax.servlet要改成jakarta.servletjavax.annotation.PostConstruct要换成jakarta.annotation.PostConstruct这类全局替换要单独做一轮全量回归特别容易漏。还有一些冷门但真实存在的APIjavax.activationJavaBeans Activation Framework在Java 9就标记废弃了Java 11移除Nashorn JS引擎在Java 15移除如果老代码里用ScriptEngineManager跑过JS规则引擎得换GraalVM的js引擎或干脆用Java重写规则Applet API在Java 17移除但这个对后台服务基本没影响。5.3 字节码库版本错配ASM、CGLIB与ClassFormatError字节码相关的坑最隐蔽因为报错信息和你的业务代码完全没有直接关系。典型的报错有启动时抛java.lang.ClassFormatError: Unknown constant tag 67、java.lang.UnsupportedClassVersionError: ... has been compiled by a more recent version、运行期某个方法突然NoSuchMethodError。这些绝大多数时候都不是你的代码问题而是字节码工具库解析不了新版本class文件。举个例子我们的规则引擎里用了一个基于CGLIB的代理库在运行时生成类Java 21环境下启动就报IllegalAccessError: class X was not granted access to class Y。排查到最后是CGLIB旧版本生成子类时访问了父类的内部结构而JDK 17强封装之后这条路被堵死了。最终方案是把那个代理库替换成基于ByteBuddy的实现ByteBuddy对JDK版本的适配做得最好。这里给出我在生产环境验证过的字节码库版本底线可以直接抄作业字节码/代理库Java 17最低版本Java 21最低版本ASM9.29.5ByteBuddy1.12.01.14.9CGLIB3.3.0可能仍需改造不推荐使用Lombok1.18.221.18.30Mockitoinline4.5.05.5.0Spring含AOP5.3.206.0.x需Boot 3Javassist3.29.03.30.0为什么ASM和ByteBuddy的版本卡得这么死因为Java 21的class文件版本号是65ASM 9.2之前不认识65看到新class就直接抛异常而Lombok 1.18.30之前不支持JDK 21的某些内部编译路径注解处理后生成的代码会带不上必要的new权限。这类问题从报错上看根本猜不到是“注解处理器版本太老”所以遇到诡异编译错误时先怀疑字节码库版本别在业务代码里瞎找。5.4 行为暗变默认字符集、TLS、线程与GC的隐性差异最后这组坑是“不报错但行为变了”最难发现。首当其冲的是默认字符集。Java 18开始JVM默认字符集定为UTF-8之前是跟随操作系统环境的。Java 8在中文Windows服务器上new String(bytes)默认用的是GBK代码里那些没显式指定字符集的地方以前“碰巧”是对的升级到Java 18以上之后全部变成UTF-8解析乱码问题会集中爆发。我们的经验是全局搜索new String(、getBytes()、FileReader、InputStreamReader这些不带字符集参数的调用全部改成显式指定StandardCharsets.UTF_8。这一步建议在升级前就做因为改完的代码在Java 8上也能跑风险最低。第二个暗变是安全算法默认值的收紧。Java 8默认TLS 1.2Java 11开始TLS 1.3按优先级启用同时新版JDK对RSA密钥长度、Diffie-Hellman密钥大小、SHA-1证书链都有更严格的限制。如果你对外的服务要兼容一些特别老的客户端比如只支持TLS 1.0的旧手机升级后握手大概率失败。好在JSSE提供了jdk.tls.disabledAlgorithms、jdk.certpath.disabledAlgorithms这些安全属性可以按需放开但放开之前一定要想清楚你真的是要为了老客户端牺牲安全性吗更靠谱的做法是让老客户端升级。第三个暗变是线程相关API的废弃趋势。Thread.stop()、Thread.suspend()、Thread.resume()虽然在Java 8里调了抛异常但还有一堆代码在try-catch它们SecurityManager在Java 17标记废弃Java 24移除如果你在用自定义SecurityManager做权限隔离升级后要做好被移除的准备。另外-Xss线程栈大小在不同JDK上的默认值有变化如果升级后出现StackOverflowError增多不一定是递归写错也可能是线程栈默认值变了用-XX:ThreadStackSize显式控制即可。6. 常见问题与排查技巧实录上线前后的高频故障速查6.1 编译期与启动期报错速查表把最常出现的报错和一句话解法列成表排查时对着看能省不少时间报错信息根本原因处理方案warning: [options] source value 8 is obsolete编译目标还停留在8使用--release 17/21错误: release version 8 not supportedsource/target低于8JDK 21最低支持8如报错检查是否误用7程序包javax.xml.bind不存在Java EE模块移除显式引入JAXB依赖java.lang.reflect.InaccessibleObjectException反射访问JDK内部包被禁止修复代码或精准添加--add-opensjava.lang.NoClassDefFoundError: javax/annotation/...Common Annotations移除引入jakarta.annotation或javax.annotationjava.lang.NoSuchMethodError: X字节码库编译时期的API和运行期不匹配升级字节码库/依赖组件版本java.lang.ClassFormatError: Unknown constant tagASM等工具不认识class 65ASM升到9.5UnsupportedClassVersionErrorclass文件版本高于JVM支持范围确认JVM确实是21java_home别指错编译期还有一种常见情况是IDE里配了JDK 21但Maven默认用的还是系统Java 8结果编译报“错误: 不支持发行版本 21”。本质是javac是从JDK 8里调出来的却让--release传了21。解法是把Maven的JAVA_HOME或Gradle的toolchain明确指向JDK 21同时留意java_home环境变量的配置别让IDE和命令行各用各的JDK。启动期则最容易遇到“Unrecognized VM option”比如-XX:UseConcMarkSweepGC、-XX:UseParNewGC、-XX:PermSize。老运维脚本里的参数基本都要清一遍建议用java -XX:PrintFlagsFinal -version先把新JDK支持的参数列表拉出来看一遍凡是找不到的旧参数直接删不要尝试找替代品因为很多老参数的语义已经被新GC重构掉了。6.2 运行期内存与性能问题排查升级完最怕的不是报错而是明明跑起来了性能却不如从前。我遇到过三种典型情况第一种是堆内存占用猛涨排查后发现是String内部表示从char[]变byte[]后某些依赖用反射读String内部字段失败了被迫退化成拷贝大对象内存自然涨第二种是GC停顿变大通常是G1自动选的region大小不合理或者代码里大量大对象直接进Humongous区域这种要结合GC日志看第三种是CPU莫名其妙飙升多和--add-opens兜底方案有关因为每次反射访问JDK内部类都要经过一层额外的安全检查。遇到这类问题我的标准排查路径是先用jcmd pid GC.heap_info看堆分布再用jcmd pid Thread.print抓线程栈看有没有热点阻塞然后开JFR记录30分钟重点看jdk.GCPhasePause、jdk.ObjectAllocationSample、jdk.JavaMonitorWait这几类事件。Java 14开始JFR对生产几乎零开销直接在启动参数里加上-XX:StartFlightRecordingfilename/logs/app.jfr,disktrue,dumponexittrue,settingsprofile出了问题拿记录慢慢分析比上线后到处打日志强得多。还有一类隐蔽问题和服务启动参数无关而是代码里用了finalize()做资源清理。finalize在Java 9被标记废弃Java 18开始Object.finalize()被标为for removal并且在JVM里走的是独立清理线程升级后可能出现“object never finalized”导致连接泄漏。全局搜索finalize()方法统一改成Cleaner或者AutoCloseable加try-with-resources这也是处理“资源释放怎么保证一致性”的一个标准答案。6.3 一套可复用的排查工具链最后把工具链整理出来。jdeps是分析依赖和模块用得最多的工具升级前可以先跑jdeps --multi-release 21 -q --ignore-missing-deps target/app.jar它会告诉你哪些包依赖JDK内部API哪些类找不到兼容性风险一目了然。jcmd是综合管理工具jcmd pid help能看到所有子命令VM.native_memory查本地内存、GC.class_histogram看对象统计都很实用。jhsdb是老牌jmap/jhat的替代出crash时用jhsdb clhsdb或jhsdb hsdb做交互式分析。至于jmap -dump和jstack在JDK 8上还能用但在新版里我建议直接用JFR jmc替代因为JFR能记录时间线比快照式的堆转储更容易定位“什么时候、发生了什么”。还有一个特别推荐的组合升级期间在灰度环境给每个节点加一个自动诊断的-XX:ErrorFile/logs/hs_err_pid%p.log和-XX:OnError脚本JVM崩溃时自动抓取现场保存配合统一日志里的GC时间线绝大多数疑难杂症都能拼出完整故事。自从我把这套工具链固化成团队SOP后再遇到诡异问题都能在一个小时内给出初步结论。升级之后我的一些切身体会这次升级前后花了三个多月真正“切JDK版本”只用了两周剩下的时间全部耗在依赖升级、代码审查和灰度观察上。我的体感是升级本身不难难的是管理层对“为什么要花这个时间”的理解。如果你的团队也准备动手建议先从一条边缘链路做起把兼容性矩阵、监控对比模板、回滚预案这三件套打磨顺再横向推广。另外提醒一句升级期间新特性的引入要克制我们当时只允许每个迭代落地一个特性避免又在同一时间引入虚拟线程又大规模重构业务代码变量太多就说不清到底是哪个改动带来的问题。最后分享一个小技巧升级后把CI里的JDK设成21但留一条Java 8的老构建job跑兼容性冒烟测试坚持一个季度能逼出很多意想不到的兼容性问题。Java 21不是终点但这个LTS的寿命足够让你安稳很多年值得投入。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表