【问题排查】【⭐⭐】简约而不简单的StringIndexOutOfBoundsException

诱因

近期测试反应某项目生产环境通过某种操作流程可以使接口报错。但部分接口可用部分接口不可用

排查

以下的排查过程如实地记录了当时排查问题的所思所想,可能走了不少弯路。但本着大胆假设,小心求证的态度,一步步逼近问题根源。虽然问题得以解决,但问题的源头尚未明确。此文意在记录排查过程和排查思路。

由于是生产环境,原谅我使用浓厚的马赛克抹去敏感信息。

是字符串越界问题?

遇到问题不要慌,首先先上环境看下日志里都发生了什么:

java.lang.StringIndexOutOfBoundsException: String index out of range: -2089467602
	at java.lang.String.getChars(String.java:824)
	at com.alibaba.fastjson.serializer.SerializeWriter.writeFieldValueStringWithDoubleQuoteCheck(SerializeWriter.java:1906)
	at com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO.write(Unknown Source)
	at com.alibaba.fastjson.serializer.ASMSerializer_10_UserCacheDTO.write(Unknown Source)
	at com.alibaba.fastjson.serializer.JSONSerializer.write(JSONSerializer.java:285)
	at com.alibaba.fastjson.JSON.toJSONString(JSON.java:745)
	at com.alibaba.fastjson.JSON.toJSONString(JSON.java:683)
	at com.alibaba.fastjson.JSON.toJSONString(JSON.java:648)

复制代码

好嘛,这不就是个序列化成Json字符串时报错嘛。

但仔细一看,发现事情没有这么简单,为什么越界的是个负数-2089467602

来看下java.lang.String.getChars(String.java:824) (注:该环境为JDK8):

public void getChars(int srcBegin, int srcEnd, char dst[], int dstBegin) {
        if (srcBegin < 0) {
            throw new StringIndexOutOfBoundsException(srcBegin);
        }
        if (srcEnd > value.length) {
            throw new StringIndexOutOfBoundsException(srcEnd);
        }
        if (srcBegin > srcEnd) {
            throw new StringIndexOutOfBoundsException(srcEnd - srcBegin); // 824行
        }
        System.arraycopy(value, srcBegin, dst, dstBegin, srcEnd - srcBegin);
    }
复制代码

该错由824行throw new StringIndexOutOfBoundsException(srcEnd - srcBegin)抛出,说明srcEnd - srcBegin = -2089467602。大的减小的能减成负数,要么srcEnd就是负数;要么srcBegin是个非常小的负数,srcEnd是非常大的正数,两个一减,溢出成负数。这就要看下调用该方法时传入的是什么值。

看上一级在调用这个方法时,com.alibaba.fastjson.serializer.SerializeWriter.writeFieldValueStringWithDoubleQuoteCheck(SerializeWriter.java:1906)都传了什么进来(注:fastjson版本为1.2.67,不同版本有差异):

    public void writeFieldValueStringWithDoubleQuoteCheck(char seperator, String name, String value) {
        int nameLen = name.length();
        int valueLen;

        int newcount = count;

        if (value == null) {
            valueLen = 4;
            newcount += nameLen + 8;
        } else {
            valueLen = value.length();
            newcount += nameLen + valueLen + 6;
        }
        // 省略无关代码
        value.getChars(0, valueLen, buf, valueStart); // 1906行
        // 省略无关代码
    }
复制代码

看到text.getChars时有点诧异,srcBegin=0,srcEnd=valueLen=value的字符串长度。这…这是怎么算出负数的????
这不科学!我要看看这text里对象长什么样!
但…但这是生产环境,不像本地调试那样方便。

这时候,要请出我们的好兄弟:阿尔萨斯!
timg

不好意思,不是这个阿尔萨斯。

是这个:
arthas

阿里巴巴开源的Java诊断工具,地址:github.com/alibaba/art… 。 它可以:查看运行状态,看JVM参数,看方法调用链,看观察方法的入参和返回结果等等。具体使用方法可以看看他们的文档:arthas.aliyun.com/doc/

下面的排查过程将会持续使用该工具进行辅助验证猜想。并不会对命令做过多介绍,因为官方有文档示例也有在线教程。

下载arthas,并拷贝到生产环境运行。

Jar包老规矩:java -jar arthas-boot.jar

QQ图片20201128200329

然后选择进程后回车,arthas完成增强。此时就可以使用arthas的功能了。

arthas原理: www.jianshu.com/p/4e34d0ab4…

回到刚刚的排查过程,现在我们要看writeFieldValueStringWithDoubleQuoteCheck(char seperator, String name, String value)的入参value到底长啥样。

使用命令watch com.alibaba.fastjson.serialize.SerializeWriter writeStringWithSingleQuote "{params,throwExp}"查看方法入参:

image-20201128205558497

可以看到@Object[]那行开始,从上至下依次是入参的顺序。类分别是Character、String、UserImVO。

等等……为什么String的入参可以传一个其他对象进来啊??

这时候我开始怀疑是不是fastjson的bug,导致生成的ASMSerializer_18_UserLoginDTO类有问题,将错误的对象传入了方法内。

为了验证猜想,看看ASMSerializer_18_UserLoginDTO.write的对象是什么:

image-20201128230804654

这……write的对象里的id就是UserImVO类型,和我们期望的String类型大相径庭。

是FastJson问题?

	at com.alibaba.fastjson.serializer.SerializeWriter.writeFieldValueStringWithDoubleQuoteCheck(SerializeWriter.java:1906)
	at com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO.write(Unknown Source)
	at com.alibaba.fastjson.serializer.ASMSerializer_10_UserCacheDTO.write(Unknown Source)
	at com.alibaba.fastjson.serializer.JSONSerializer.write(JSONSerializer.java:285)

复制代码

这时候,思路回到问题的起点。目光聚焦在中间两个由fastjson生成的class上。com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTOcom.alibaba.fastjson.serializer.ASMSerializer_10_UserCacheDTO

插播:简单说下fastjson(以1.2.67为例)是怎么根据对象生成json字符串的。

  1. 我们调用JSON.toJSONString(Object)
  2. 生成JSONSerializer并初始化SerializeConfig
  3. SerializeConfig内持有ASMSerializerFactory并创建SerializeBeanInfo,前者生成对象的Serializer,后者遍历对象后得到对象信息
  4. ASMSerializerFactory.createJavaBeanSerializer根据SerializeBeanInfo,创建名为ASMSerializer_序号_简短类名的class,使用ASM生成二进制的字节码。
  5. 生成字节码后使用类加载器ASMClassLoader.defineClassPublic加载该类到jvm
  6. 使用该类输出json字符串

那我们要怎么看到生产环境的运行时class信息呢?没错,又是arthas。

使用命令dump com.alibaba.fastjson.serializer.ASMSerializer_18_UserLoginDTO生成一份class到硬盘。我这里直接把两个class都保存下来。

QQ截图20201128141914
打开ASMSerializer_18_UserLoginDTO,并找到有关打印id字段的代码:

var6 = "id";  // field 名称
var13 = var10.getId(); // 这里var10是UserLoginDTO
if (var13 == null) { // 不走这里
    if (var9.isEnabled(132)) {
        var9.write(var12);
        var9.writeFieldNameDirect(var6);
        var9.writeNull(0, 128);
        var12 = ',';
    }
} else {
    // 不为null,走这里。这里的var12是字符串逗号,var6是name,var13是value
    var9.writeFieldValueStringWithDoubleQuoteCheck(var12, var6, var13);
    var12 = ',';
}
复制代码

该代码节选自ASMSerializer_18_UserLoginDTO.class生成id字段的代码。可以看出fastjson如实地获取了userLoginDTO内的id对象,没有做什么骚操作。那么很显然,在传入fastjson之前,这个对象内的id就已经不是string类型了。

接着我沿着业务代码(这里就不做展示了)顺藤摸瓜,找到了UserLoginDTO产生源头——来自redis。

该项目使用了jetcache封装了对redis数据的操作。

这里再插播下jetcache(github.com/alibaba/jet… ):JetCache is a Java cache abstraction which provides consistent use for various caching solutions.

QQ截图20201128235415
一样的方法,使用watch查看jetcache查数据返回的对象,发现果然id的类为UserImVO。看来数据从redis取出来时就已经不对了。

于是我翻看了相关jetcache的配置,发现此时配置序列化反序列化器为kryo,那么问题难道是……

kryo(github.com/EsotericSof… ):Kryo is a fast and efficient binary object graph serialization framework for Java.

是Kryo问题?

我们再观察一下jetcache提供的kryo在反序列化时返回的值,便可知一二:

image-20201129122105876

这里我们查看的是jetcache在从redis取到byte[]后,将byte[]通过封装好的kryo反序列化类反序列化成想要的对象。图中可以明显看出从redis中获取数据后在反序列化成对象时,数据就已经不正常。

是kryo的锅?

我从可以正确生成json字符串的日志里取出相关数据,并通过相同版本的kryo序列化和反序列化,发现并不能复现该问题。

于是整理了以上被我怀疑过的库及版本信息。

报错的接口 不报错的接口
fastjson 1.2.67 1.2.70
jetcache 2.6.0.M2 2.6.0.M2
kryo 4.0.2 4.0.2
业务代码 相差3个月 相差3个月
  1. 针对fastjson,我翻看了68、69、70的changelog,发现并未有相关问题修复。但相关方法诸如writeFieldValueStringWithDoubleQuoteCheck改动挺大。
  2. jetcache和kryo均为一模一样的版本,且相同的对象在我本地序列化反序列化时未出现问题。

那么查到最后是业务代码的问题?

因为报错接口与不报错的接口共同都会调用该段代码,不报错的接口更新时间比报错的晚3个月。于是我查看了期间代码更新记录,发现UserLoginDTO新增了若干个字段。难道新增若干个字段就会影响到kryo的反序列化结果吗?

结论

后续将业务代码更新后,该问题得以解决。虽然解决的方法有点简单粗暴,但这问题依然尚未明确。我的猜想依旧停留在kryo的问题上。

参考

  1. arthas文档:arthas.aliyun.com/doc/en/
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享