Skip to content

[BUG] aarch64 + JDK8 写路径数字丢失:IOUtils.writeInt3 的非对齐 UNSAFE.putLong 被 C2 误编译 #7828

Description

@Jiandev

问题描述

aarch64 + JDK 8 上,JSON.toJSONString / JSON.toJSONBytes 序列化出的 JSON 结构、key、字符串、中文全部正常,只有数字位是错的 —— 被替换成复用缓冲区里的残留内容或 \u0000

这是 #7732写方向#7732 和 PR #7755 定位的是读路径(JSONReaderUTF16.readFieldNameHashCode 的 SWAR),本 issue 报的是写路径(IOUtils.writeInt3),两者根因同类但代码路径不同,#7755 合并后也不会修复本问题

典型输出(<hhhh> 是把非可打印字符转义后的形式):

期望: {"materialId":132454,"pointIn":0,"pointOut":0,"trackOffset":0}
实际: {"materialId":<0000><0000>2454,"pointIn":0,"pointOut":0,"trackOffset":<0000>}

注意两个特征:

  1. 每个数字占的字符数完全正确 —— 6 位数还是 6 个字符,1 位数还是 1 个字符
  2. 132454 的后 4 位 2454 是对的,前 2 位丢了

环境

OS / CPU Linux aarch64(LITTLE_ENDIAN)
出问题的 JDK Oracle JDK 1.8.0_431-b10(HotSpot 25.431-b10)
fastjson2 2.0.60(2.0.64 同样问题,相关代码逐字节相同)

注意:是否触发与 JDK 构建相关,不能只按 os.arch 或版本号判断。 见下方矩阵 —— 同为 aarch64,
8u342 正常、8u431 全错、8u462 正常、8u503 全错,并非版本越高越好

最小复现

import com.alibaba.fastjson2.JSON;

public class Repro {
    public static void main(String[] args) {
        int[] v = {1, 12, 123, 1234, 12345678};
        String expect = JSON.toJSONString(v);   // 解释执行阶段,正确
        int bad = 0;
        for (int i = 0; i < 200000; i++) {      // 触发 C2 编译
            if (!JSON.toJSONString(v).equals(expect)) bad++;
        }
        System.out.println(System.getProperty("java.version") + " / " + System.getProperty("os.arch"));
        System.out.println("expect   = " + expect);
        System.out.println("after    = " + JSON.toJSONString(v));
        System.out.println("unstable = " + bad + "/200000");
    }
}

aarch64 + JDK 8u431 实测输出:

1.8.0_431 / aarch64
unstable = 2249/200000

同一个 jar 在 x86-64 + JDK 8u421 上 unstable = 0/200000

多线程压测下几乎必现(8 线程 / 10 秒):

机器 JDK 发行版 序列化次数 结果
A 1.8.0_431-b10 Oracle JDK 27,783,974 写路径 27,783,828 次出错
A 1.8.0_462 OpenJDK 33,278,331 ✅ 0
A 17.0.9 Oracle JDK 40,959,297 ✅ 0
B 1.8.0_342 OpenJDK 6,369,498 ✅ 0
B 1.8.0_502-b07 OpenJDK (Temurin) 6,803,093 ✅ 0
B 1.8.0_503-b01 Oracle JDK 326 读路径invalid escape character EOI
C 1.8.0_421(x86-64) 99,026,960 ✅ 0

机器 A、B 均为 aarch64,各自是同一台物理机、同一个 fastjson2-2.0.60.jar,只换 JDK
发行版按 java -version 第二行区分:Java(TM) SE Runtime Environment = Oracle JDK,
OpenJDK Runtime Environment = OpenJDK 系。

三个值得注意的点:

  1. 不是"版本越新越好"。 测过的 JDK 8 里版本最高的 8u503 反而坏,更老的 8u342 完全正常。
  2. 能对上全部样本的判别特征是发行版,不是版本号:两个 Oracle JDK 8 的 aarch64 构建
    8u4318u503)全部出问题,三个 OpenJDK 8 构建(8u3428u462、Temurin 8u502
    全部正常;Oracle JDK 17 不受影响,所以不是"Oracle 都有问题",而是 Oracle JDK 8 的 aarch64 构建
    一个可能的解释是 OpenJDK 8u 的 aarch64 后端来自 aarch64-port 项目,而 Oracle JDK 8 的 ARM64
    是另一条构建线,两者的 C2 后端补丁集不同 —— 这点仅供参考,没有进一步验证。
  3. 读写两条路都会中招。 机器 B 上 8u503 复现的是 [BUG]fastjson2的JSON.parseObject, x86系统上跑没有问题,部署到了麒麟ARM报错 #7732 的读路径症状
    JSONReaderUTF16.readStringinvalid escape character EOI,C2 编译完 10 秒内 8 个线程全挂,
    total 只跑到 326);机器 A 上 8u431 复现的是本 issue 的写路径症状(静默把数字写坏)。
    两者根因同类,但 PR fix: bypass UNSAFE SWAR in readFieldNameHashCode on aarch64+JDK8 (#7732, #3763) #7755 只覆盖读路径。

解释执行 / C1 正确,C2 编译后才错,所以在业务里表现为"偶发"。

根因分析

IOUtilschar[] 的三个数字写入方法,只有 writeInt3 会错,writeInt4 / writeInt8 正常。这一点从 132454 的输出可以直接读出来 —— writeInt32 把它拆成 writeInt3(13) + writeInt4(2454),而输出正好是 <0000><0000> + 2454

// IOUtils.java:1233  ← 出错
private static int writeInt3(char[] buf, int off, int val) {
    long v = DIGITS_K_64[val & 0x3ff];
    UNSAFE.putLong(buf, ARRAY_CHAR_BASE_OFFSET + ((long) off << 1), v >> ((((short) v) + 1) << 4));
    return off + 3 - (byte) v;
}

// IOUtils.java:1213  ← 正常,mergeInt64 里有 BIG_ENDIAN 分支
private static int writeInt4(char[] buf, int off, int v) {
    int v1 = (int) (v * 1374389535L >> 37);
    putLongUnaligned(buf, off, mergeInt64(v - v1 * 100, v1));
    return off + 4;
}

三点:

1. 写的是非对齐地址。 ARRAY_CHAR_BASE_OFFSET + (off << 1) 只保证 2 字节对齐,却用 putLong 写 8 字节。JDK 8 没有 Unsafe.putLongUnaligned(JDK 9 才有),这属于未定义行为 —— x86 硬件容忍,aarch64 上被 C2 编译后就不对了。

2. 输出的 \u0000 与"字节序反转"完全吻合。writeInt3(buf, off, 13) 为例:

DIGITS_K_64[13] = 0x0033_0031_0030_0001    // c0=1(跳过位数), '0','1','3'
shift           = ((short)v + 1) << 4 = 32
v >> 32         = 0x0000_0000_0033_0031

小端 putLong -> bytes 31 00 33 00 00 00 00 00 -> chars '1','3',NUL,NUL   ✅ "13"
字节反转     -> bytes 00 00 00 00 00 33 00 31 -> chars NUL,NUL,'3','1'   ❌ "<0000><0000>"

off 只前进 3 - (byte)v = 2,所以正好 2 个 NUL —— 与实测输出一致。writeInt3(buf, off, 0) 同理算出 1 个 NUL,对应 "trackOffset":<0000>。(少数样本里的是缓冲区旧内容而非 NUL,说明该次的 store 可能整个没落,两种形态都见过。)

3. 位数为什么总是对的。 return off + 3 - (byte) v 是纯算术 + 数组读,不依赖那条 UNSAFE.putLong 是否生效。所以 off 照常精确前进,只是内容没写对 —— 这就是"结构完好、只有数字坏"的原因。

影响面

  • 这个写法是 2.0.56 引入的。2.0.55 的 writeInt32(char[]) 虽然也用 DIGITS_K_64,但写入走的是 putIntLE(4 字节,且带 convEndian)和 putChar(2 字节,对 char[] 天然对齐),没有 8 字节的 putLong
  • byte[] 版本(writeInt3(byte[])writeInt8(byte[]))用的是同样的非对齐 UNSAFE.putInt/putLongJSON.toJSONBytesJSONWriterUTF8 同样暴露
  • 与读路径([BUG]fastjson2的JSON.parseObject, x86系统上跑没有问题,部署到了麒麟ARM报错 #7732)叠加后,aarch64 + JDK8 上 fastjson2 的读写两个方向都不可靠

已验证的规避方式

方式 结果
OpenJDK 8u462(同机同 jar) ✅ 3327 万次 0 错,最终采用
JDK 17(同机同 jar) ✅ 4096 万次 0 错
升级 fastjson2 到 2.0.64 writeInt3/4/8(char[]) 与 2.0.60 逐字节相同
-XX:CompileCommand=exclude,com/alibaba/fastjson2/util/IOUtils.writeInt3 未实测,理论可行
-XX:TieredStopAtLevel=1 #7732 中有用户验证对读路径有效

但如上表所示,换 JDK 属于绕开而非修复,且换哪个版本有效无法事先判断(我们这边 8u342 可以、
8u431 不行、8u502 可以),每台机器都得实测。#7732 里"升级到 1.8.0.472 / 1.8.0.482 就好了"的反馈
同理 —— 那些也只验证了读路径。

建议

可以复用 PR #7755 已经引入的 JDKUtils.AARCH64_JDK8 开关,在该平台下让 IOUtils 的数字写入回退到普通数组赋值(不走 UNSAFE),覆盖 char[]byte[] 两组 writeInt3 / writeInt4 / writeInt8

更彻底一点的话,putLongUnaligned / putIntUnaligned 在 JDK 9+ 上应该调用真正的 Unsafe.putLongUnaligned / putIntUnaligned,而不是像现在这样直接委托给 putLong / putInt

// 当前实现,JDK 9+ 上也没有用到合法的 unaligned API
public static void putLongUnaligned(char[] buf, int pos, long v) {
    UNSAFE.putLong(buf, ARRAY_CHAR_BASE_OFFSET + ((long) pos << 1), v);
}

相关:#7732#7755#3763

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions