背景:一封邮件引发的升级
五月底基础架构组发了封邮件,说 Oracle JDK 8 从 8u211 起商业使用要收费,公司统一切 OpenJDK 11(下一个 LTS),老服务年底前迁完。我们组四个服务,清一色 Spring Boot 2.3.12 + JDK 8u282。我手上这个订单服务先动,QPS 峰值 3200,堆设的 4G,G1。
评估的时候我觉得一天能搞定,实际连踩四个坑,加上回归压测,前后五天。
坑一:javax.xml.bind 找不到了
编译一次通过,起服务直接炸:
Caused by: java.lang.ClassNotFoundException: javax.xml.bind.JAXBContext
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:581)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:522)
at com.xxx.order.infra.WechatPayClient.parseCallback(WechatPayClient.java:87)
JDK 11 把 Java EE 那批模块整个删掉了:java.xml.bind(JAXB)、java.xml.ws、java.corba、java.transaction,还有 JavaFX、Applet、Nashorn(17 才删,11 是标记废弃)。另外 javax.annotation 里的 @PostConstruct、@Resource 也一起没了,这个影响面更大,我们项目里有 23 处用到。
处理办法就是补依赖,别无他法:
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.3</version>
</dependency>
<!-- @PostConstruct / @Resource 靠这个 -->
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
顺手把 JAXB 那段 XML 解析换成了 dom4j,少一个依赖。真正躲不掉的是 @PostConstruct,只能加。
坑二:字节码增强工具版本太老
第二个错在 Spring 启动阶段就出来了:
Caused by: java.lang.IllegalArgumentException: Unsupported class file major version 55
at net.bytebuddy.jar.asm.ClassReader.<init>(ClassReader.java:184)
at net.bytebuddy.jar.asm.ClassReader.<init>(ClassReader.java:166)
major version 55 就是 Java 11。老版本的 ASM / Byte Buddy / CGLIB / Javassist 不认这个版本号,会在解析类的第一步直接拒绝。
查下来要动的依赖比想象的多:
| 依赖 | 升级前 | 升级后 | 说明 |
|---|---|---|---|
| Lombok | 1.16.20 | 1.18.20 | 低于 1.18.4 在 JDK 11 上编译直接报错 |
| maven-compiler-plugin | 3.1 | 3.8.1 | 老版本不支持 release 参数 |
| Javassist | 3.20.0 | 3.27.0 | MyBatis 3.5.x 间接依赖 |
| spring-boot | 2.3.12 | 2.5.1 | Spring 5.1 起官方支持 11 |
我的经验是别一个个试,直接跑:
$ mvn versions:display-plugin-updates versions:display-dependency-updates
把结果里标红的挨个过一遍,比等运行时报错快得多。
坑三:GC 日志参数全失效
启动参数里这行被 JVM 直接拒绝了:
Unrecognized VM option 'PrintGCDetails'
Error: Could not create the Java Virtual Machine.
JDK 9 引入了 Unified Logging,-XX:+PrintGCDetails、-XX:+PrintGCDateStamps、-Xloggc: 这一套全废了,改成 -Xlog。我们原来那坨参数:
# JDK 8 的写法,11 上直接启动失败
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/data/logs/gc.log
-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M
对应到 JDK 11:
-Xlog:gc*,gc+heap=debug,safepoint:file=/data/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
语法是 -Xlog:<标签>:<输出>:<装饰器>:<轮转>,写习惯了其实比老的清晰。轮转由 JVM 自己管,不用再挂 logrotate。
坑四:反射访问 JDK 内部类被拦
这个报错来自一个老的三方包,它反射设了 String 的某个私有字段:
WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by com.xxx.util.FastCopy (file:/.../xxx.jar) to field java.lang.String.value
WARNING: Please consider reporting this to the maintainers of com.xxx.util.FastCopy
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
JDK 9 起模块系统默认强封装,11 上还留了 --illegal-access=permit 这个开关(默认就是 permit,只警告不报错),所以只是看着难受,服务能跑。我没有加 --add-opens 去惯着它,而是把那个类换掉了。真躲不开的时候这么写:
--add-opens java.base/java.lang=ALL-UNNAMED
--add-exports java.base/sun.security.action=ALL-UNNAMED
注意这只是缓兵之计,JDK 16 起 --illegal-access 的默认值变成 deny,17 上默认就真拦了。留着这个坑不如现在改掉。
顺手换掉的 HttpClient
升级前我们用 Apache HttpClient 4.5.13,代码不复杂但线程池和超时配置容易写漏。JDK 11 把 java.net.http.HttpClient 转正了(9 里是孵化),我拿一个对外的回调推送接口试了试:
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.executor(Executors.newFixedThreadPool(32))
.version(HttpClient.Version.HTTP_2)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
public void push(String url, String body) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(3))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
HttpResponse<String> resp = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
log.warn("push failed, status={}, body={}", resp.statusCode(), resp.body());
}
}
// 异步版本,返回 CompletableFuture
public void pushAsync(String url, String body) {
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(3))
.POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8))
.build();
CLIENT.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenAccept(r -> log.info("push ok, {}", r.statusCode()))
.exceptionally(e -> { log.error("push error", e); return null; });
}
几个用下来觉得舒服的点:API 是 builder 链式不会漏配 timeout;sendAsync 直接返回 CompletableFuture,编排比 Future + 回调简单太多;原生支持 HTTP/2 和 WebSocket。坑也有一个——BodyPublishers.ofString 默认按 UTF-8 编码,但我还是显式传了 charset,免得有人改默认。
我只在新写的接口上用它,老接口没动。Apache HttpClient 的连接池监控更完善,换过去的收益不大,风险不小。
GC 这块的变化
JDK 9 开始 G1 就是默认收集器了,CMS 在 9 里标记废弃,14 删除。我们本来就用 G1,所以这块只改了两处:
- 删掉了
-XX:+UseConcMarkSweepGC相关的三个参数(另一个服务还在用 CMS,顺手换成 G1,-XX:MaxGCPauseMillis=200)。 -XX:+UseParallelOldGC在 JDK 8 是默认,11 上 G1 优先,显式指定更保险。
另外 11 上 G1 有个实打实的改进:Full GC 从 JDK 8 的单线程串行变成了并行(JEP 307 是 Parallel Full GC for G1,在 JDK 10 落地)。我们压测时故意把堆压满触发了一次 Full GC,8u282 上单次 12.7 秒,11 上 3.4 秒。
ZGC 在 JDK 11 是实验特性(JEP 333),要 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC 才能开。我在预发环境试过,暂停时间确实都在 10 ms 以内,但吞吐掉了约 12%,而且当时(11.0.11)还不支持类卸载,没敢上生产。真正能用得等 15/17。
升级前后对比
同样的压测脚本(JMeter,200 并发,跑 10 分钟),机器配置一致:
| 指标 | JDK 8u282 | JDK 11.0.11 |
|---|---|---|
| 平均 RT | 38 ms | 34 ms |
| P99 RT | 212 ms | 167 ms |
| Young GC 平均耗时 | 21 ms | 17 ms |
| 启动后稳定态堆占用 | 2.6 GB | 2.1 GB |
| 服务启动耗时 | 28 s | 25 s |
堆占用降了 500 MB,这块主要来自紧凑字符串(JDK 9 起 String 内部从 char[] 改成 byte[])和 G1 的改进。我们系统里缓存了大量商品名、地址这种纯拉丁/ASCII 文本,正好吃满这个优化。
就写到这。如果哪天你也被《JDK 11 迁移记录:从 Java 8 升级踩过的坑》里同一个坑绊住,回来翻这篇,能省半小时。