alibaba / alibaba/fastjson2

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

Open
#7,828 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Java
Stars
4.4k
Forks
613
Avg merge
1d 22h
Merged PRs (30d)
6

Description

## 问题描述

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

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

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

```
期望: {"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` 全错,**并非版本越高越好**。

## 最小复现

```java
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 构建
(`8u431`、`8u503`)全部出问题,三个 OpenJDK 8 构建(`8u342`、`8u462`、Temurin `8u502`)
全部正常;Oracle JDK 17 不受影响,所以不是"Oracle 都有问题",而是 **Oracle JDK 8 的 aarch64 构建**。
一个可能的解释是 OpenJDK 8u 的 aarch64 后端来自 `aarch64-port` 项目,而 Oracle JDK 8 的 ARM64
是另一条构建线,两者的 C2 后端补丁集不同 —— 这点仅供参考,没有进一步验证。
3. **读写两条路都会中招。** 机器 B 上 `8u503` 复现的是 #7732 的读路径症状
(`JSONReaderUTF16.readString` 抛 `invalid escape character EOI`,C2 编译完 10 秒内 8 个线程全挂,
`total` 只跑到 326);机器 A 上 `8u431` 复现的是本 issue 的写路径症状(静默把数字写坏)。
两者根因同类,但 PR #7755 只覆盖读路径。

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

## 根因分析

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

```java
// 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/putLong`,`JSON.toJSONBytes` 及 `JSONWriterUTF8` 同样暴露
- 与读路径(#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`:

```java
// 当前实现,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

Contributor guide

Open the contributing guide

Research direction

Start with IOUtils.writeInt3, writeInt4, and writeInt8 for both char[] and byte[], then review JDKUtils.AARCH64_JDK8 and the existing putLongUnaligned implementation. Run the supplied serialization loop on aarch64 with Oracle JDK 8 and a known-good JDK, then verify that numeric JSON output remains correct for both String and byte-array serialization without changing unaffected platforms.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend, performance
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Mostly clear
Newbie friendliness
55/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.