JDK 27 在 GA 前不到一个月,被人发现 C2 会把字符串的前 4 个字节写坏,最后在 9 月 15 日发布的 GA 构建 27+35 里修掉了。我把 27 的几个旧 EA 版本拉下来,用 Spring Boot 实测了一遍:27-ea+24 和 27-ea+34 都稳定复现,27-ea+23、27 GA 和 28-ea+20 都没问题。如果你的 CI 或者镜像里还钉着某个 27 的 EA 版本,现在就该换掉。
这件事是 Inside Java Newscast 第 72 期(Episode 72 “OpenJDK Bug Fixing, JavaOne, and JDK 28”,10 月 17 日)讲的主线。节目把整个过程的原始链接都列出来了,下面的时间线都来自这些 JBS 单子和邮件。
事情是怎么发生的
8 月 18 日,Lorenzo Dematte 在 hotspot-compiler-dev 邮件列表上报告:他们团队开始用 JDK 27 EA 跑集成测试,有些在 JDK 26 上好好的测试挂了,序列化再反序列化一个字符串,前 4 个字节对不上。他们一路查到一段很普通的代码:把 char[] 编码成 UTF-8,再 Arrays.copyOfRange 拷出来。
当天这个问题就被登记成 JDK-8390546,标题是 “C2: EncodeISOArray intrinsic uses incorrect destination memory alias”。报告里写得很清楚:27-ea+24 引入,27-ea+23 没问题;只在 x64 Linux 上复现,Apple M2 上没复现;外层方法要被 C2 编译并且把 copyOfRange 内联进去才会出问题。加 -XX:-UseCompactObjectHeaders 关掉压缩对象头,或者加 -XX:-ReduceBulkZeroing,问题都会消失。
Chris Hegarty 随后在 jdk-dev 上发了一封邮件,把它当成 P1 拉响警报,还客气地说如果定高了请往下调。Mark Reinhold 第二天回复:P1 是对的,引入问题的改动直接回退。
罪魁是 JDK-8373591,“C2: Fix the memory around some intrinsics nodes”,本来是修 C2 里几个字符串 intrinsic 节点内存依赖的问题。Tobias Hartmann 在单子里给的解释是:inline_encodeISOArray 把数组的 oop 类型当成内存地址类型传下去。这种类型的偏移是 0,别名落在数组头上,没落在数组内容上。C2 因此没把这个 intrinsic 和 BYTES 内存切片连上,后面的读就可能看到旧数据。影响 _encodeAsciiArray、_encodeISOArray 和 _encodeByteISOArray 三个 intrinsic。
处理分了三步:主线上用 JDK-8390590 回退,JDK 27 分支上用 JDK-8390663 回退,Resolved In Build 写的是 b35,也就是后来的 GA 构建 27+35。原来的改动修好之后,以 JDK-8390617 重新进了 JDK 28 主线,8 月 27 日解决。
把旧 EA 版本找回来
jdk.java.net 上已经下不到旧 EA 包了,不过 Docker Hub 的官方 openjdk 镜像还留着历史标签。我从 27-ea-23-jdk-slim、27-ea-24-jdk-slim、27-ea-34-jdk-slim 三个镜像里把 JDK 取出来,再加上本机的 27 GA 和 28-ea+20,一共五个版本。测试机是一台 8 核 x86_64 Linux。
用 Spring Boot 复现
项目是 Spring Boot 4.1.1,只依赖 spring-boot-starter-webmvc,端口 18086。核心是一个很像业务代码的工具方法:把 char[] 形式的密钥转成 UTF-8 字节,转完把中间缓冲区清零,跟报告里那段代码几乎一样。
public static byte[] toUtf8Bytes(char[] chars) {
ByteBuffer buf = StandardCharsets.UTF_8.encode(CharBuffer.wrap(chars));
byte[] bytes = Arrays.copyOfRange(buf.array(), buf.position(), buf.limit());
Arrays.fill(buf.array(), (byte) 0);
return bytes;
}
再写一个接口,循环调 20 万次,跟 String.getBytes 的结果比对,记下失败次数和第一次出错的字节:
@GetMapping("/check")
public Map<String, Object> check(@RequestParam(defaultValue = "api_key_xy") String secret,
@RequestParam(defaultValue = "200000") int rounds) {
char[] input = secret.toCharArray();
byte[] expected = secret.getBytes(StandardCharsets.UTF_8);
int failures = 0;
int firstBadRound = -1;
String firstBadHex = null;
for (int i = 0; i < rounds; i++) {
byte[] got = SecretCodec.toUtf8Bytes(input);
if (!Arrays.equals(got, expected)) {
if (failures++ == 0) {
firstBadRound = i;
firstBadHex = HexFormat.ofDelimiter(" ").formatHex(got);
}
}
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("jdk", Runtime.version().toString());
out.put("rounds", rounds);
out.put("failures", failures);
out.put("firstBadRound", firstBadRound);
out.put("firstBadHex", firstBadHex);
out.put("expectedHex", HexFormat.ofDelimiter(" ").formatHex(expected));
return out;
}
jar 用 JDK 27 GA 编译,然后每个 JDK 各启动一次,每次连续请求三遍。
先看 27-ea+23,三遍都是 0 失败:
OpenJDK Runtime Environment (build 27-ea+23-2078)
2026-10-18T08:11:59.141+08:00 INFO 69649 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.271 seconds (process running for 1.611)
$ curl -s localhost:18086/check
{"jdk":"27-ea+23-2078","rounds":200000,"failures":0,"firstBadRound":-1,"firstBadHex":null,"expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
换成 27-ea+24,问题马上出来了:
OpenJDK Runtime Environment (build 27-ea+24-2138)
2026-10-18T08:12:01.414+08:00 INFO 69785 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.251 seconds (process running for 1.61)
$ curl -s localhost:18086/check
{"jdk":"27-ea+24-2138","rounds":200000,"failures":71655,"firstBadRound":128345,"firstBadHex":"00 00 00 00 6b 65 79 5f 78 79","expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
$ curl -s localhost:18086/check
{"jdk":"27-ea+24-2138","rounds":200000,"failures":135483,"firstBadRound":64517,"firstBadHex":"00 00 00 00 6b 65 79 5f 78 79","expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
$ curl -s localhost:18086/check
{"jdk":"27-ea+24-2138","rounds":200000,"failures":200000,"firstBadRound":0,"firstBadHex":"00 00 00 00 6b 65 79 5f 78 79","expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
api_key_xy 变成了前 4 个字节全是 0,后面 key_xy 完好。第一遍前 12 万多次都是对的,等 C2 编译完才开始错。到第三遍,从第 0 次就开始错,20 万次全错。单元测试跑几百次根本碰不到它,应用热起来之后才稳定出错。
我这里坏掉的 4 个字节是 0,报告里的独立复现程序看到的多数是 0xA0 0x00 0x00 0x00。这 4 个字节本来就没被写入,留下什么值取决于那块内存原来的内容。
27-ea+34 是 GA 之前的最后一个 EA 构建,结果一样:
OpenJDK Runtime Environment (build 27-ea+34-2321)
2026-10-18T08:12:03.673+08:00 INFO 69896 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.216 seconds (process running for 1.56)
$ curl -s localhost:18086/check
{"jdk":"27-ea+34-2321","rounds":200000,"failures":26208,"firstBadRound":173792,"firstBadHex":"00 00 00 00 6b 65 79 5f 78 79","expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
$ curl -s localhost:18086/check
{"jdk":"27-ea+34-2321","rounds":200000,"failures":123670,"firstBadRound":76330,"firstBadHex":"00 00 00 00 6b 65 79 5f 78 79","expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
$ curl -s localhost:18086/check
{"jdk":"27-ea+34-2321","rounds":200000,"failures":200000,"firstBadRound":0,"firstBadHex":"00 00 00 00 6b 65 79 5f 78 79","expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
27 GA 和 28-ea+20 都是三遍 0 失败:
OpenJDK Runtime Environment (build 27+35-2325)
2026-10-18T08:12:06.067+08:00 INFO 70006 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.352 seconds (process running for 1.701)
$ curl -s localhost:18086/check
{"jdk":"27+35-2325","rounds":200000,"failures":0,"firstBadRound":-1,"firstBadHex":null,"expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
OpenJDK Runtime Environment (build 28-ea+20-1588)
2026-10-18T08:12:08.391+08:00 INFO 70189 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.325 seconds (process running for 1.734)
$ curl -s localhost:18086/check
{"jdk":"28-ea+20-1588","rounds":200000,"failures":0,"firstBadRound":-1,"firstBadHex":null,"expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
为什么跟压缩对象头有关
JDK 27 里 UseCompactObjectHeaders 已经默认打开了,-XX:+PrintFlagsFinal 能看到它是 true 而且是 {default}。之前写过一篇 Java 27 默认开启 Compact Object Headers 的文章,专门讲这个变化。对象头变小之后,byte 数组的数据从第 12 个字节开始;关掉压缩对象头时是第 16 个字节。报告里的汇编显示,出错时第 12 到 15 字节这一段没被正确写入,拷贝是从第 16 字节开始的,正好对应字符串开头那 4 个字节。
我在 27-ea+24 上关掉压缩对象头,三遍都是 0 失败:
### 27-ea+24 -XX:-UseCompactObjectHeaders
OpenJDK Runtime Environment (build 27-ea+24-2138)
2026-10-18T08:12:10.513+08:00 INFO 70313 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.244 seconds (process running for 1.572)
$ curl -s localhost:18086/check
{"jdk":"27-ea+24-2138","rounds":200000,"failures":0,"firstBadRound":-1,"firstBadHex":null,"expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
单子里 Tobias Hartmann 给的规避办法是关掉 SpecialEncodeISOArray,这是个诊断参数,要先解锁。实测也有效:
### 27-ea+24 -XX:+UnlockDiagnosticVMOptions -XX:-SpecialEncodeISOArray
OpenJDK Runtime Environment (build 27-ea+24-2138)
2026-10-18T08:12:12.776+08:00 INFO 70414 --- [ main] com.spring4all.c2bug.DemoApplication : Started DemoApplication in 1.203 seconds (process running for 1.536)
$ curl -s localhost:18086/check
{"jdk":"27-ea+24-2138","rounds":200000,"failures":0,"firstBadRound":-1,"firstBadHex":null,"expectedHex":"61 70 69 5f 6b 65 79 5f 78 79"}
这两个参数只是用来验证原因的。真要处理,直接升到 27 GA 就行,没必要在生产上加这种开关。
还在用 27 EA 的要换掉
先查镜像标签。Docker Hub 上 27-ea-24 到 27-ea-34 的镜像现在还能拉,Dockerfile 或 CI 里钉过这些标签的,跑的就是一个会悄悄改坏数据的 JIT。用 java -version 看一眼,build 号低于 27+35 的都换掉。
这个 bug 最早是一个用户团队拿 EA 跑自己的集成测试撞出来的,从报告到主线回退不到一天。OpenJDK 的 Quality Outreach 鼓励开源项目做的就是这种 EA 测试。JDK 28 的 EA 目前到了 b20,10 月 15 日发布。
一起交流
分享你的思考,让讨论更进一步。