记一次 ARM64 + JDK8 上 fastjson2 的诡异数据错乱

0 阅读9分钟

记一次 ARM64 + JDK8 上 fastjson2 的诡异数据错乱

这个问题前后折腾了差不多一周。最开始以为是单点接口的问题,后来怀疑日志打错了,再后来发现链路里最不起眼的 JSON 库成了嫌疑人。

最后定位到:fastjson2 在 ARM64 + JDK8 环境下,被 JIT 编译之后解析会出错。换成 Hutool JSON 后问题消失。过程记录一下,给遇到类似现象的同学一个参考。

问题是怎么冒出来的

某政务移动办公中间层,负责把移动端授权码换成审批系统账号。某天开始,单点登录偶发失败,网关报:

240133 应用accessToken失效或不存在

同时日志里登录接口返回的 JSON 是这样的:

"ROLE_VALUE":"ROLELE_SINESS,ROLE_SP,",
"EMAIL":"aaaaXXXXbbbbbbbbcccccccc",
"ID":"1111XXXX222222223333333344444444",
"MOBILE":"5555XXXX66666666777777"

但同一个接口,浏览器直接访问后端,返回完全正常:

"ROLE_VALUE":"ROLE_BUSINESS,ROLE_SP,",
"EMAIL":"aaaaaaaabbbbbbbbcccccccc",
"ID":"11111111222222223333333344444444",
"MOBILE":"5555555566666666777777"

几个特征很迷惑:

  • 不是必现,重启应用能好一阵;
  • 我一开始以为只是日志打印串了,后来发现前端收到的 JSON 也是坏的;
  • 同一个 JVM 跑得越久,出错概率越高。

一开始的几个误判

1. 先怀疑 fastjson1 缺失

第三方网关 SDK 的适配器类引用了 com.alibaba.fastjson.JSON,而项目早就升级到 fastjson2 了,所以运行时报:

java.lang.NoClassDefFoundError: com/alibaba/fastjson/JSON

很自然地把官方的 fastjson1 兼容包加了回来:

<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 就炸了:

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 长这样:

{"expiresIn":7200,"accessToken":"["appCode"]xxxxxxxx..."}

token 值里带了没转义的双引号,本身就是非法 JSON。fastjson1 和 fastjson2 都解析不了,于是加了个“解析失败后按正则兜底提取”的逻辑。

处理完这几件事,登录链路“有时候”能通,但线上还是偶发失败。真正的转折点是发现返回给前端的 JSON 本身就是坏的——这就不是日志的问题了。

写个最小程序,当场复现

既然怀疑 JSON 库,那就别在业务代码里猜了。我让运维把浏览器返回的正确 JSON 存成文本文件,写了个很小的程序在服务器上跑:

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[] 三种输入方式的对比。

结果一跑出来,人有点懵:

[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:-TieredCompilationFAIL
-XX:-UseSuperWordFAIL
-XX:CompileCommand=exclude,com/alibaba/fastjson2/JSONReader::*FAIL
-XX:CompileThreshold=1000000FAIL
替换 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。主要就是这些替换:

fastjson2Hutool
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,把两个库的默认差异拉齐:

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,再彻底清掉。