干货 | 携程 JDK25 升级踩坑记:一场由 G1GC “偷走”对象引发的数据静默损坏

张开发
2026/4/11 1:46:59 15 分钟阅读

分享文章

干货 | 携程 JDK25 升级踩坑记:一场由 G1GC “偷走”对象引发的数据静默损坏
作者简介cxzl25携程高级软件技术专家关注数据领域生态建设对分布式计算和存储、调度等方面有浓厚兴趣Apache kyuubi/ORC/Auron (P)PMC MemberApache Celeborn Committer。lsm携程高级开发工程师关注分布式计算和优化Apache Kyuubi Committer。导读携程大数据平台运行着大规模的 Spark、Flink 计算集群为充分发挥 JDK25 LTS 版本在内存效率与运行性能上的优势我们启动了 JDK 升级计划并完成了 多个引擎 对 JDK25 的适配改造开始向生产环境灰度推进。然而就在灰度期间我们遭遇了一个极为罕见的问题Spark、Flink 写入的 Parquet、ORC 文件出现部分损坏——写入过程无任何报错CRC 校验也完全通过损坏只在下游读取时才会暴露。本文记录了我们如何从一个Zstd 解压报错出发借助多款 AI 工具辅助分析历经代码排查、JDK 版本二分、自建编译环境等多个阶段最终将问题根因锁定到 JDK25 G1GC 的一个内部优化 Bug并推动 OpenJDK 社区完成 backport 修复。文章还将深度解析 G1 GC 的 Optional Evacuation 机制与 JNI Pinning 原理以及 AI 工具在整个排查链路中的具体作用。一、背景二、影响面为何这个 Bug 格外危险三、数据读取损坏的报错四、损坏文件分析五、问题怀疑方向六、问题复现七、代码的各种尝试八、JDK 的各种尝试九、数据损坏的根因分析十、后续复盘十一、AI 辅助排障全程回顾十二、总结一、背景生产环境中 Spark 已基于 JDK21 运行为使用 JDK25 LTS 版本的新特性紧凑对象头Compact Object HeadersJEP 519开启参数-XX:UseCompactObjectHeaders实现内存占用节省、GC 效率提升与性能优化计划将运行环境升级至 JDK25并完成了 Spark 对 JDK25 的适配工作支持灰度切换。但在灰度推进阶段用户反馈出现数据读取异常问题实时任务中 Flink 写入 Paimon 的 Parquet 文件部分读取失败离线任务中 Spark 写入的 ORC、Parquet 文件也存在部分读取失败的情况。JEP 519: Compact Object Headershttps://openjdk.org/jeps/519二、影响面为何这个 Bug 格外危险该 Bug 影响 JDK 25.0.0、25.0.1、25.0.2 全部 JDK25 已发布版本预计 2026 年 4 月 21 日发布的 25.0.3 版本修复。任何在上述版本上开启 G1GCJDK25 默认 GC的 Java 应用均存在风险。可以用 -XX:UseParallelGC 或 -XX:UseZGC 避免此问题。在未来发布的 JDK 25.0.3 可以启用 G1。该 Bug 的根本原因是 G1 GC 在 Optional Evacuation 阶段错误移动了被 JNI 临界区锁定的对象。因此凡是通过 GetPrimitiveArrayCritical / ReleasePrimitiveArrayCritical 这对 JNI 接口直接操作 Java 数组内存的场景均存在触发该 Bug 的风险。该 Bug 最大的危险在于写入过程完全无异常抛出静默损坏。数据已经损坏地写入存储只有在后续读取/解压时才会报错数据不可恢复。受影响的组件/库包括但不限于zstd-jniZstd 压缩ORC、Parquet、Kafka 消息体等大量使用JDK 内置 Zip/Deflate 库java.util.zip.Deflater / Inflater同样基于 JNI 实现已验证可复现其他调用 Native 压缩/加密/数学运算库等各种场景。三、数据读取损坏的报错作业写入流程均正常下游作业读取特定列或某段数据时触发报错核心报错类型集中为 Zstd 解压相关异常具体如下Src size is incorrectCaused by: com.github.luben.zstd.ZstdException: Src size is incorrect at com.github.luben.zstd.ZstdDecompressCtx.decompressByteArray(ZstdDecompressCtx.java:205) at com.github.luben.zstd.Zstd.decompressByteArray(Zstd.java:439) at org.apache.orc.impl.ZstdCodec.decompress(ZstdCodec.java:218) at org.apache.orc.impl.InStream$CompressedStream.readHeader(InStream.java:495) at org.apache.orc.impl.InStream$CompressedStream.ensureUncompressed(InStream.java:522)Decompression error: Destination buffer is too smallCaused by: java.io.IOException: Decompression error: Destination buffer is too small at com.github.luben.zstd.ZstdInputStreamNoFinalizer.readInternal(ZstdInputStreamNoFinalizer.java:171) at com.github.luben.zstd.ZstdInputStreamNoFinalizer.read(ZstdInputStreamNoFinalizer.java:123) at com.github.luben.zstd.ZstdInputStream.read(ZstdInputStream.java:88) at org.apache.paimon.shade.org.apache.parquet.hadoop.codec.ZstdDecompressorStream.read(ZstdDecompressorStream.java:43)Decompression error: Corrupted block detectedCaused by: java.io.IOException: Decompression error: Corrupted block detected at com.github.luben.zstd.ZstdInputStreamNoFinalizer.readInternal(ZstdInputStreamNoFinalizer.java:171) at com.github.luben.zstd.ZstdInputStreamNoFinalizer.read(ZstdInputStreamNoFinalizer.java:123) at com.github.luben.zstd.ZstdInputStream.read(ZstdInputStream.java:87) at org.apache.parquet.hadoop.codec.ZstdDecompressorStream.read(ZstdDecompressorStream.java:43) at java.io.DataInputStream.readFully(DataInputStream.java:195)四、损坏文件分析4.1 定位损坏列从报错日志锁定异常表及对应文件利用 Parquet、ORC 的列式存储特性通过select sum(hash(struct(colX)))语句定位具体损坏的列未读取损坏列时不会触发报错。4.2 排除存储介质问题Parquet 默认开启parquet.page.write-checksum.enabledtrue列 Page 压缩后的数据会通过 CRC32 计算校验值并写入 Page header。开启读取校验参数parquet.page.verify-checksum.enabledtrue通过 Parquet CLI 执行校验./bin/hadoop jar parquet-cli-1.13.0-runtime.jar org.apache.parquet.cli.Main -Dparquet.page.verify-checksum.enabledtrue cat data.parquet校验结果无报错说明压缩后写入文件的字节流与读取时一致排除 HDFS 等存储介质导致的数据损坏。4.3 验证数据损坏特征对 Zstd 解压后的字节流本地落地使用zstd -d工具解压提示Decoding error (36) : Data corruption detected使用crc32 命令校验此文件CRC32 校验结果与 Page Header 中的校验值一致改造 Parquet 代码实现损坏 Page 跳过逻辑验证仅少量列的部分 Page 存在数据损坏其余数据可正常读取。4.4 尝试数据恢复与原因分析编译开启 DEBUGLEVEL5 的 zstd 工具通过多个 AI 对 debug 日志分析解压失败原因未获有效结果借助 Cursor AI 分析本地落地的二进制数据确认其符合 Zstd 文件格式规范并生成 Python 恢复脚本基于 Zstd 的按 Block 分割特性恢复出部分数据。五、问题怀疑方向结合作业运行环境的多样性梳理出以下核心怀疑点JDK25 紧凑对象头特性是否改变对象内存布局进而影响压缩流程压缩库如 zstd-jni 或者列格式 Parquet、ORC又或者计算引擎 Spark、Flink 没有完全适配 JDK25导致压缩数据损坏Linux 操作系统或内核版本是否存在兼容性问题其他未明确的环境或组件交互问题...六、问题复现在少量失败作业中筛选出运行时长短、可偶现的 Spark 任务开展复现测试核心发现如下ORC 2.0 以上的版本 Zstd 压缩支持两种实现airlift aircompressor 的纯 Java 实现、zstd-jni 的 C 代码实现默认使用性能更优且支持更多压缩参数通过-Dorc.compression.zstd.impljava切换为 Java 实现后多次运行未复现问题怀疑 zstd-jni 存在适配问题物理机集群中该任务基本无法复现Docker 构造的集群Spark Executor 运行在 Docker 中可偶现且两类集群的 OS、内核版本不一致。七、代码的各种尝试在 ORC Zstd 压缩实现中开启 Zstd checksum (zstdCompressCtx.setCheck sum(true)解压仍无报错该方案无效实现压缩 - 解压校验逻辑对原始数据 a 压缩得到 b1 并立即解压若解压失败则重新压缩 a 得到 b2 并解压对比 b1 和 b2 的十六进制差异并日志输出多次复现发现压缩长度一致但字节偏移量和损坏位置无规律该方案无法定位根因。八、JDK 的各种尝试关闭 JDK25 的紧凑对象头特性后问题仍可复现排除该特性的影响。使用多个 JDK25 release 版本均25.0.0、25.0.1 、25.0.2 均能复现问题。使用 JDK21 到 JDK25 之间的几个 JDK 版本如 JDK23 和 JDK24 最后一个版本没有重现此问题。JDK26、27 因 Spark 兼容性问题未完成测试。说明问题可能出在 JDK24 到 JDK25 的改动。由于损坏的列的数据基本上是大文本字符串观察到 Spark GC 耗时较为异常尝试更换 GC 算法。生产环境运行的 Spark 任务原先基于 JDK8 并使用 ParallelGC 垃圾收集器。切换至 JDK21 后初期仍沿用 ParallelGC但运行过程中发现 Spark Executor 因物理内存使用超限被 YARN killed日志中有Container killed by YARN for exceeding physical memory limit。经排查该问题与 JDK 8328744: Parallel: Parallel GC throws OOM before heap is fully expanded 相关因此在 JDK21 环境下将垃圾收集器调整为 G1GC 后上线。在 JDK25 沿用了 JDK21 的配置上线的时候使用默认的 G1GC但是在 JDK25 使用 ParallelGCZGC 都未能重现数据损坏问题。说明问题有可能出现在 JDK25 环境开启 G1GC 导致的数据损坏。下载各种 JDK25 的 各开发版本测试最终在 tag jdk-259 一切正常 在 jdk-2510 可偶现问题。https://github.com/adoptium/temurin25-binaries/releases为定位具体引入问题的 Commit此时需要一个支持指定 Commit ID 的 JDK25 编译环境并适用于生产环境运行的 JDK25 版本。借助 GitHub Agents 的能力根据需求vibe 了一个 build JDK25 的 Workflow方便编译多个版本的 JDK。由于生产环境 Docker 的镜像的 glibc 版本比较低导致在 Workflow 编译的 JDK25 并不能这个环境使用让 GitHub Copilot 根据报错version GLIBC_2.34 not found直接修复它虽然使用了 container 的方式但是生成的 Workflow 不能运行Copilot 多次尝试始终没有搞定。后续提示 Copilot在 Workflow 使用 Dockerfile 的方式Dockerfile base 基于 Centos7 编译最终编译的 JDK25 可以在生产环境中运行。并且因为需要在多个 Commit 二分查找具体哪个 Commit 引入导致的 Bug编译的 JDK 需要带上 Commit id 方便跟踪接着提了个需求GitHub Copilot 快速了实现此功能。GitHub Agents实现的编译 JDK 的 Workflow支持 fork 的 JDK repo方便修改 JDK 的代码进行测试支持指定 commit id方便定位具体哪个 commit 导致的问题。Build JDKjava -version有对应的 commit id$ ./bin/java -versionopenjdk version 25-internal 2025-09-16OpenJDK Runtime Environment (build 25-internal-86cec4e)OpenJDK 64-Bit Server VM (build 25-internal-86cec4e, mixed mode, sharing)通过上述方案编译不同的 commit id 的 JDK最终锁定是如下的 feature 引入的 bug8343782: G1: Use one G1CardSet instance for multiple old gen regionshttps://bugs.openjdk.org/browse/JDK-8343782Fix Version/s: 25Resolved In Build:b10JDK-8343782 对应的 commit id 是 86cec4ea基于此 commit id 编译的 JDK25 可以偶尔复现此问题而它的前一个 commit id 为 006ed5c0 无问题。汇总排查结论如下ConfigurationData Corruption?NotesJDK 25 -XX:UseG1GCYES ✗Compressed data is corruptedJDK 25 -XX:UseParallelGCNo ✓Works correctlyJDK 25 -XX:UseZGCNo ✓Works correctlyJDK 21 -XX:UseG1GCNo ✓Works correctlyJDK 25 (with 86cec4ea commit) G1GCYES ✗Confirms this commit is the root causeJDK 25 (with 006ed5c0 commit) G1GCNo ✓Works correctly一开始以为是 zstd-jni 在 JDK25 特定的问题所以先在 zstd-jni GitHub 报告了此问题。https://github.com/luben/zstd-jni/issues/377报告问题后OpenJDK 社区建议在高版本 JDK 再进行一些测试。因为在 JDK26 移除了jdk.internal.ref.Cleaner类而 Spark 在初始化的时候需要加载这个类这导致 Spark 不能在 JDK26 运行。先简单的修改了 Spark 代码让它支持在 JDK26 可以运行。当 Spark 在 JDK26JDK27 运行多次发现并没有出现损坏的文件说明该问题有可能在高版本被修复了。基于上述的编译 JDK 的 workflow接着编译 JDK26 版本最后定位到是如下的 feature 修复了这个问题虽然标题看着是代码重构但是有一行关键的改动r-has_pinned_objects()修复了核心问题。8370807: G1: Improve region attribute table method naminghttps://bugs.openjdk.org/browse/JDK-8370807Fix Version/s: 26Resolved In Build:b22报告此问题之后得到多方关注与 OpenJDK 社区多次讨论验证怀疑的关键改动是否能修复此问题基于 fork 的 JDK 代码编译并线上测试提供多次验证结果和相应的 GC log最终社区在 JDK25 实现了 backport JDK-8377811 。在 2026 年 4 月 21 日左右预计发布 25.0.3 版本 可以修复在 JDK25 开启 G1GC 导致压缩数据损坏的问题。JDK-8377811 G1: Optional Evacuations may evacuate pinned objectshttps://bugs.openjdk.org/browse/JDK-8377811https://github.com/openjdk/jdk25u-dev/pull/272九、数据损坏的根因分析9.1 G1 GC 回收阶段本次问题出现在 G1 Mixed GC 的 Optional Evacuations 阶段。G1Garbage-First是 JDK9 之后的默认垃圾回收器其核心设计理念是将 Java 堆内存划分为大量等大小的 Region区域。G1 的运行过程包含几种不同的回收模式Young GC当年轻代 Eden 区满时触发只回收年轻代 Region。Mixed GC当老年代占用率达到阈值后触发。它会回收所有年轻代 Region 以及部分收益最高的老年代 Region即垃圾最多的区域。可选回收 Optional Evacuations 是一项自 Java 12 JEP 344 引入的关键优化技术。其核心目的是通过将混合回收Mixed GC拆分为“必须”和“可选”两部分来更精准地控制 GC 停顿时间。在 Java 12 之前一旦 G1 选定了回收集合Collection Set CSet它必须在单次停顿中回收集合内的所有区域Region。如果 G1 错误地预测了回收时间导致选入的老年代区域过多就会造成停顿时间严重超过用户设定的预期目标。引入该特性后G1 将混合回收集合分为两个部分必需部分 Mandatory Part包含所有年轻代区域和一部分为了保证回收进度的老年代区域。这部分必须在本次停顿中完成回收。可选部分 Optional Part包含剩下的老年代回收候选区域。9.2 为什么 JDK-8343782 会导致数据损坏该 Commit 的初衷是为了优化 G1 GC 的内存占用然而这个优化引入了一个致命的 Bug它在进行可选回收Optional Evacuations时可能会错误地移动evacuate那些被“固定”pinned的对象。对 zstd-jni的影响链路如下zstd-jni 是通过 JNI 调用底层的 C 代码 zstd 库来进行数据压缩的。使用 GetPrimitiveArrayCritical / ReleasePrimitiveArrayCritical 这对 JNI 函数来直接获取 Java 数组的内存指针进行压缩操作并写入到目标的 Java 数组。当一个数组被 GetPrimitiveArrayCritical 锁定时它所在的 heap region 会被标记为 has_pinned_objects。Pin 的目的是告诉垃圾回收器“Native 代码正在直接往这块内存地址读写数据不能在 GC 时移动它的物理内存位置。”. 但是这个优化之后的 G1 GC 忽略了这些对象的 Pinned 状态。当 Native 的压缩代码正在向指定的内存地址写入压缩后的数据时G1 GC 把这个 Java 数组的内存地址给转移了。结果C 层的压缩逻辑把正确的数据写到了旧的地址上或者由于内存地址被强行移动导致写入错乱。因为是直接操作内存这个过程没有任何异常抛出即写入不报错。但最终落盘的 Java 字节数组里的数据已经完全错乱了静默数据损坏。当后续尝试解压这串损坏的字节时由于不符合 Zstd 格式在解压时报错。简单总结pinned 标志丢失 → GC 移动了不该移动的对象 → JNI 裸指针指向了废弃内存 → 数据静默损坏。9.3 为什么 JDK-8370807 修复了这个问题虽然 Commit 的标题是 Improve region attribute table method naming看起来像是一个纯粹的代码重构或清理但它在重构的过程中修正了底层状态判断的遗漏。在这个改动中对 G1 GC 注册老年代区域进行回收的代码进行了梳理。修复了在处理 Optional Evacuation可选回收阶段时对被固定对象属性has_pinned_objects()的判断逻辑。它确保了如果一个内存区域中存在被 JNI 固定的对象比如 zstd-jni 正在使用的缓冲区该区域相关的 Pinned 属性会被正确设置并被 G1 GC 遵守从而阻止 GC 移动该区域的对象。对象不会再被错误移动Native 层 C 代码的内存读写地址和 JVM 中的对象地址保持一致写入和解压的数据也就保证了正确。十、后续复盘10.1 为何在特定的集群没有复现不同集群的 Spark Executor 资源申请能力不同资源充足的 YARN 集群中Executor 复用率低GC 次数少问题难以复现小集群中 Executor 资源紧张复用率高多次 GC 后易触发问题。基于对此 Bug 的理解Spark executor 在计算过程中可以复用同一个 JVM 多次 GC 有可能复现此问题通过在大集群中设置spark.dynamicAllocation.maxExecutors限制 Executor 较少的数量成功复现问题验证了上述结论。10.2 问题的影响面该 Bug 并非仅影响 zstd-jni所有基于 JNI “临界区” 获取 Java 数组指针的场景均可能受影响且读写过程无报错属于静默损坏。例如 JDK 自带的 Zip 压缩库同样基于 JNI 实现也可复现该问题报错如下Caused by: java.io.IOException: Bad compression dataat org.apache.orc.impl.ZlibCodec.decompress(ZlibCodec.java:173) at org.apache.orc.impl.InStream$CompressedStream.readHeader(InStream.java:495) at org.apache.orc.impl.InStream$CompressedStream.ensureUncompressed(InStream.java:522) at org.apache.orc.impl.InStream$CompressedStream.read(InStream.java:509) at org.apache.orc.impl.TreeReaderFactory$BytesColumnVectorUtil.commonReadByteArrays(TreeReaderFactory.java:1600) at org.apache.orc.impl.TreeReaderFactory$BytesColumnVectorUtil.readOrcByteArrays(TreeReaderFactory.java:1618) at org.apache.orc.impl.TreeReaderFactory$StringDirectTreeReader.nextVector(TreeReaderFactory.java:1714) at org.apache.orc.impl.TreeReaderFactory$StringTreeReader.nextVector(TreeReaderFactory.java:1548) at org.apache.orc.impl.TreeReaderFactory$StructTreeReader.nextBatch(TreeReaderFactory.java:2122) at org.apache.orc.impl.RecordReaderImpl.nextBatch(RecordReaderImpl.java:1220) ... 22 moreCaused by: java.util.zip.DataFormatException: invalid stored block lengthsat java.base/java.util.zip.Inflater.inflateBytesBytes(Native Method) at java.base/java.util.zip.Inflater.inflate(Inflater.java:355) at org.apache.orc.impl.ZlibCodec.decompress(ZlibCodec.java:168) ... 31 more十一、AI 辅助排障全程回顾AI 在本次排查中的角色定位排查阶段AI 角色具体贡献Zstd 日志分析数据分析助手从数万行日志中提取关键信息收窄排查方向Zstd 二进制数据分析格式解析专家解析 Zstd 帧结构生成恢复脚本JDK 编译环境代码生成加速器生成 Workflow、修复兼容问题JDK Commit 搜索代码仓库搜索引擎缩小候选 Commit 范围根因理解技术翻译器辅助理解 G1 内部实现撰写 Bug 报告整个排查过程中Cursor、GitHub Copilot、Kiro、Gemini、Claude 等多款 AI 工具贯穿始终各自在不同节点发挥了关键作用Cursor AI 分析损坏的二进制数据并生成恢复脚本GitHub Agents 以vibe coding方式生成了 JDK 编译 Workflow解决了跨环境 glibc 兼容问题在锁定JDK25 G1GC这一方向后又让多款 AI 工具在体量庞大的 JDK 代码仓库中协助检索 G1 相关 commit快速缩小了二分查找的范围。十二、总结排查初期没有怀疑是 JDK 本身的 Bug——确实很难想到是 GC 层面的缺陷导致数据静默损坏。特别感谢网易、eBay、B 站等公司的同学协助排查积极提供思路、排除无效变量为问题定位提供了极大帮助。【推荐阅读】执行个 systemd 命令竟然让容器CPU“打架”一文看懂绑核错乱的根因携程IT桌面全链路工具研发运营实践携程光网络抵御光缆中断实践携程分布式图数据库Nebula Graph运维治理实践“携程技术”公众号分享交流成长

更多文章