Administrator
发布于 2021-06-19 / 4024 阅读
85

JDK 11 迁移记录:从 Java 8 升级踩过的坑

背景:一封邮件引发的升级

五月底基础架构组发了封邮件,说 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.wsjava.corbajava.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 不认这个版本号,会在解析类的第一步直接拒绝。

查下来要动的依赖比想象的多:

依赖升级前升级后说明
Lombok1.16.201.18.20低于 1.18.4 在 JDK 11 上编译直接报错
maven-compiler-plugin3.13.8.1老版本不支持 release 参数
Javassist3.20.03.27.0MyBatis 3.5.x 间接依赖
spring-boot2.3.122.5.1Spring 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 8u282JDK 11.0.11
平均 RT38 ms34 ms
P99 RT212 ms167 ms
Young GC 平均耗时21 ms17 ms
启动后稳定态堆占用2.6 GB2.1 GB
服务启动耗时28 s25 s

堆占用降了 500 MB,这块主要来自紧凑字符串(JDK 9 起 String 内部从 char[] 改成 byte[])和 G1 的改进。我们系统里缓存了大量商品名、地址这种纯拉丁/ASCII 文本,正好吃满这个优化。

就写到这。如果哪天你也被《JDK 11 迁移记录:从 Java 8 升级踩过的坑》里同一个坑绊住,回来翻这篇,能省半小时。

参考