记一次 ARM64 + JDK8 上 fastjson2 的诡异数据错乱
这个问题前后折腾了差不多一周。最开始以为是单点接口的问题,后来怀疑日志打错了,再后来发现链路里最不起眼的 JSON 库成了嫌疑人。
最后定位到:fastjson2 在 ARM64 + JDK8 环境下,被 JIT 编译之后解析会出错。换成 Hutool JSON 后问题消失。过程记录一下,给遇到类似现象的同学一个参考。
问题是怎么冒出来的
某政务移动办公中间层,负责把移动端授权码换成审批系统账号。某天开始,单点登录偶发失败,网关报:
text
240133 应用accessToken失效或不存在
同时日志里登录接口返回的 JSON 是这样的:
json
"ROLE_VALUE":"ROLELE_SINESS,ROLE_SP,",
"EMAIL":"aaaaXXXXbbbbbbbbcccccccc",
"ID":"1111XXXX222222223333333344444444",
"MOBILE":"5555XXXX66666666777777"
但同一个接口,浏览器直接访问后端,返回完全正常:
json
"ROLE_VALUE":"ROLE_BUSINESS,ROLE_SP,",
"EMAIL":"aaaaaaaabbbbbbbbcccccccc",
"ID":"11111111222222223333333344444444",
"MOBILE":"5555555566666666777777"
几个特征很迷惑:
- 不是必现,重启应用能好一阵;
- 我一开始以为只是日志打印串了,后来发现前端收到的 JSON 也是坏的;
- 同一个 JVM 跑得越久,出错概率越高。
一开始的几个误判
1. 先怀疑 fastjson1 缺失
第三方网关 SDK 的适配器类引用了 com.alibaba.fastjson.JSON,而项目早就升级到 fastjson2 了,所以运行时报:
text
java.lang.NoClassDefFoundError: com/alibaba/fastjson/JSON
很自然地把官方的 fastjson1 兼容包加了回来:
xml
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.64</version>
</dependency>
这个包从 2.0.x 起底层是 fastjson2,不是老 fastjson1 的代码。类缺失确实解决了,但新的问题马上来了。
2. 又撞上兼容包的 JSON.toJSON
SDK 的 RequestAdapter 里会调用 JSON.toJSON(request)。fastjson1 的 toJSON 是直接构建 JSONObject,而兼容包改成了"先序列化再 parse 回来"。
SDK 的请求对象里有个 getResponseClass() 返回 Class,序列化出来是一大坨反射信息,再 parse 就炸了:
text
fastjson2.JSONException: expect ':', but 99, offset 3
at com.alibaba.fastjson.JSON.toJSON(JSON.java:2313)
at ...RequestAdapter.parseOapiRequest(RequestAdapter.java:78)
这个好办,干脆不走 SDK 的 IntelligentGetClient/IntelligentPostClient,改用底层的 GetClient/PostClient 自己传参,响应也自己解析。签名逻辑还是交给 SDK。
3. token 本身也有一段时间不合法
中间有一阵,网关返回的 accessToken 长这样:
json
{"expiresIn":7200,"accessToken":"["appCode"]xxxxxxxx..."}
token 值里带了没转义的双引号,本身就是非法 JSON。fastjson1 和 fastjson2 都解析不了,于是加了个"解析失败后按正则兜底提取"的逻辑。
处理完这几件事,登录链路"有时候"能通,但线上还是偶发失败。真正的转折点是发现返回给前端的 JSON 本身就是坏的------这就不是日志的问题了。
写个最小程序,当场复现
既然怀疑 JSON 库,那就别在业务代码里猜了。我让运维把浏览器返回的正确 JSON 存成文本文件,写了个很小的程序在服务器上跑:
java
JSONObject obj = JSON.parseObject(text);
String baseline = JSON.toJSONString(obj);
for (int i = 0; i < 20000; i++) {
JSONObject o = JSON.parseObject(text);
String s = JSON.toJSONString(o);
if (!baseline.equals(s)) {
mismatch++;
}
}
同时加上 32 线程并发、String/byte\[\]/char\[\] 三种输入方式的对比。
结果一跑出来,人有点懵:
text
[same-string] MISMATCH at 121
[same-string] MISMATCH at 122
[same-string] MISMATCH at 123
...
[same-string] ERROR at 853: JSONException: offset 12, character 0, line 1, column 12
[same-string] ERROR at 854: ...
[same-string] loops=20000, mismatch=643, error=19147
前一百多次完全正常,大概从第 120 到 150 次开始,解析出来的字段值就乱了。再往后到七八百次,几乎每次都抛异常,报的是 character 0------解析器读到了 NUL 字符。
但输入字符串的 hashCode 前后没变,说明输入本身没被改。乱掉的值也不是随机的,而是"正确值中间一段还在,前后被相邻位置的字符盖掉了"。
这种形态不太像业务数据问题,更像是编译后的代码读错了位置。
一组对照实验
为了搞清楚到底是环境、JDK 还是 fastjson2,我把能试的参数和版本都试了一遍:
| 实验 | 结果 |
|---|---|
-Xint(纯解释执行) |
全部 OK,2 万次循环、并发都是 0 错误 |
| 默认分层编译 | FAIL,约 130 次后开始错乱 |
-XX:-TieredCompilation |
FAIL |
-XX:-UseSuperWord |
FAIL |
-XX:CompileCommand=exclude,com/alibaba/fastjson2/JSONReader::* |
FAIL |
-XX:CompileThreshold=1000000 |
FAIL |
| 替换 fastjson2 的 JDK8 共享字符串构造器 | FAIL |
| fastjson2 2.0.63 / 2.0.64 / 2.0.66 | 全部 FAIL |
| Oracle JDK 8u301 / 8u491 | 全部 FAIL |
| 同样的数据换 Jackson 解析 | OK |
| 同样的数据换 Hutool JSON 解析 | OK |
-Xint 这一条基本把方向定死了:不走 JIT 就没事,一走 JIT 就出错。换 JDK 小版本、换 fastjson2 版本都没用;而同一台机器上,Jackson 和 Hutool 跑 2 万次加并发全都稳稳的。
所以不是机器坏了,也不是数据有问题,是 fastjson2 在 JDK8 + ARM64 这个组合下,JIT 之后的行为不对。
我对根因的理解
fastjson2 为了性能做了很多底层优化,比如通过 sun.misc.Unsafe 直接读 String 内部的 char[],在 JDK8 上用私有构造器 String(char[], boolean share) 共享字符数组避免拷贝,reader 里也大量用了数组下标直读和批量拷贝。
这些优化在 x86 上跑了很久都没事,但在这次的 ARM64 + JDK8 环境里,JIT 编译之后就出现了读取/构造字符串时的偏移错误。表现出来就是解析出来的字符串混进了相邻位置的字符,继续解析时读到 NUL。
至于到底是 JDK 的 C2 编译问题,还是 fastjson2 的写法触发了某种未定义行为,我没法下定论,得 JDK 或 fastjson2 官方来确认。我能确认的只是:这份代码在这个环境里,被 JIT 编译后就会出错。
最后的处理
能升级 JDK 的话,优先升级
JDK 9 以后 String 内部结构变成了 byte[] + coder,fastjson2 会走完全不同的字符串处理路径,大概率就绕开了。这是最省事的方案,代码一行不用改。可惜这次环境不允许,只能走下面这条路。
换 JSON 库
既然 Jackson 和 Hutool 在这台机器上都验证稳定,那就换库。选 Hutool 是因为它的 JSONObject/JSONArray 跟原来 fastjson2 的用法最接近,改动量最小。
整个工程 33 个文件、51 处 import。主要就是这些替换:
| fastjson2 | Hutool |
|---|---|
JSON.parseObject(s) |
JsonUtil.parseObj(s) |
JSONObject.parseObject(s) |
JsonUtil.parseObj(s) |
JSON.parseArray(s) |
JsonUtil.parseArray(s) |
JSON.toJSONString(o) |
JsonUtil.toJsonStr(o) |
getString(k) |
getStr(k) |
getBoolean(k) |
getBool(k) |
getIntValue(k) |
getInt(k, 0) |
getInteger(k) |
getInt(k) |
fluentPut(k, v) |
set(k, v) |
new JSONObject() |
new JSONObject(JsonUtil.config()) |
另外封装了一个 JsonUtil,把两个库的默认差异拉齐:
java
public final class JsonUtil {
public static JSONConfig config() {
return JSONConfig.create()
.setIgnoreNullValue(true)
.setDateFormat("yyyy-MM-dd HH:mm:ss");
}
public static JSONObject parseObj(String text) {
if (isBlankJson(text)) {
return null;
}
return JSONUtil.parseObj(text, config());
}
public static JSONArray parseArray(String text) {
if (isBlankJson(text)) {
return null;
}
return JSONUtil.parseArray(text, config());
}
public static String toJsonStr(Object value) {
return JSONUtil.toJsonStr(value, config());
}
public static <T> T toBean(String text, Class<T> clazz) {
if (isBlankJson(text)) {
return null;
}
return JSONUtil.toBean(text, clazz);
}
private static boolean isBlankJson(String text) {
if (text == null) {
return true;
}
String trimmed = text.trim();
return trimmed.isEmpty() || "null".equalsIgnoreCase(trimmed);
}
}
迁移时踩到的几个坑
Hutool 默认把 Date 序列化成时间戳,得手动 setDateFormat,不然接口里所有时间字段都会变样。
JSONObject.toString() 用的是对象自己的 config。所以光在 toJsonStr 里传配置没用,所有 new JSONObject() 和解析入口都得带上统一的 config。
Hutool 的 parseObj(null) 返回空对象,parseObj("") 直接抛异常,和 fastjson2 的"返回 null"不一样。业务代码里很多地方对空响应是有判断的,这个必须统一掉。
RestTemplate 直接反序列化成 JSONObject.class 的地方也要改,改成先拿 String 再 JsonUtil.parseObj,免得 Jackson 和 Hutool 的类型混在一起。
还有字段顺序。Hutool 按声明顺序输出,fastjson2 偏字母序,接口字段顺序会变。只要前端是按 key 取值就没事,但这个上线前最好确认一下。
事后复盘
这次最耗时间的不是修,而是前面一直在错误的方向上打转。回头看,有几个点值得记一下:
日志错乱和数据错乱要尽早分开。我们前面花了很久研究日志,其实只要把前端响应和原始 HTTP 响应都打出来,很快就能发现数据本身就是坏的。
-Xint 真的是排查 JIT 问题的神器。解释执行正常、编译执行异常,基本就不用再怀疑网络、内存、数据了。
最小复现程序要拆维度。输入类型、单线程/并发、每次新 JVM、不同的 JIT 参数,分开跑,结论才站得住。我们一开始把五个场景放在同一个 JVM 里跑,前面一个场景把 JIT 状态搞坏之后,后面全在报错,差点得出错误结论。
拿另一个成熟库做对照非常有用。同一台机器、同一份数据,Jackson 和 Hutool 都正常,一下就把范围缩到 fastjson2 自己身上了。
JSON 库迁移不是改个 import 就完事。null、日期、数字类型、空串解析、字段顺序,每一样都可能影响接口返回,得逐项对齐再回归。
最后,第三方 SDK 的"fastjson 兼容包"不等于 fastjson2。JSON.toJSON(JavaBean) 这种 API 的语义差异,足够让人查很久。
结果
应用代码完成了 fastjson2 到 Hutool JSON 的迁移,构建打包正常,上线后跑了一段时间,token 失效和登录 JSON 错乱都没再出现。
fastjson2 和 fastjson1 兼容包暂时还留在依赖里------SDK 和另一个本地 jar 的类仍然引用它们。不过应用代码已经不再调用,运行期不会执行到。等以后厂商出了不依赖 fastjson 的 SDK,再彻底清掉。