一、问题描述
JaCoCo是一款被广泛应用于公司内部的开源覆盖率工具,将其引用至测试环境后,机器启动正常,但在操作下单时出现异常,阻塞下单流程。
去除JaCoCo配置、重新编译和部署后下单功能恢复正常。堆栈信息显示,问题源于系统对请求字段进行加密时出现异常,因为无法完成类型转换抛出异常,“[Z cannot be cast to [Ljava.lang.Object”,从而阻塞下单流程。
以下为报错堆栈信息:
java.lang.ClassCastException: [Z cannot be cast to [Ljava.lang.Object;
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:93)
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:133)
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:90)
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:133)
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:90)
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:133)
at com.jd.**.TdeProxy.encryptObject(TdeProxy.java:133)
at com.jd.**.TdeProxy.$$FastClassBySpringCGLIB$$4fa3c52.invoke(<generated>)
at org.springframework.cglib.proxy.MethodProxy.invoke(MethodProxy.java:218)
at org.springframework.aop.framework.CglibAopProxy$CglibMethodInvocation.invokeJoinpoint(CglibAopProxy.java:769)
..省略
二、问题分析
1.报错代码
定位报错信息显示的代码位置,确认该部分代码并没有被修改过。报错提示指出属性应为数组类型,但在需要加密的类属性中并没有涉及数组类型的处理。那么“[Z”这个类型又是从何而来呢?这种情况下不禁让人怀疑,在某个时刻类可能被修改过。
报错信息中的"[Z"代表的是Java中的boolean类型数组。在Java中,基本数据类型的数组也会被表示为类似于"[Z"、"[B"、"[L"等形式的字符串,这可能是因为在程序运行过程中对类进行了动态修改或者反射操作导致的。
以下为报错处的代码片段,在将obj转换为Object[]时出现异常,既然已经识别出是数组,但是又无法完成类型转换,具体的原因需要进一步分析。
public void encryptObject(Object obj, String type) throws IllegalAccessException {
/***省略***/
if (Map.class.isAssignableFrom(clazz)) {
/***省略***/
} else if(Iterable.class.isAssignableFrom(clazz)) {
/***省略***/
} else if(clazz.isArray()) {
/**********************报错代码行****************/
for (Object o : (Object[]) obj) {
/**********************报错代码行****************/
this.encryptObject(o, type);
}
} else {
Boolean encryptFlag = null;
Field[] fields = this.getDeclaredFieldsAll(clazz);
for (Field field : fields) {
/***省略***/
}
/***省略***/
for (Field field : fields) {
Class fieldClazz = field.getType();
if (fieldClazz == String.class) {
/***省略***/
} else {
field.setAccessible(true);
Object fieldValue = field.get(obj);
this.encryptObject(fieldValue, type);
}
}
}
}
2.分析路径
阅读代码可以看出encryptObject方法是通过递归实现的,其主要功能是对有效集合进行遍历,所以问题的重点不是递归的过程,而是推进递归过程的元素集合,集合中的元素无法正常进行类型转换导致报错,这就需要检查getDeclaredFieldsAll方法,该方法在运行时返回的集合中可能包含意料之外的元素,以下为具体实现代码:
public Field[] getDeclaredFieldsAll(Class clazz) {
List<Field> fieldsList = new ArrayList<Field>();
while (clazz != null) {
Field[] declaredFields = clazz.getDeclaredFields();
fieldsList.addAll(Arrays.asList(declaredFields));
clazz = clazz.getSuperclass();
}
return fieldsList.toArray(new Field[fieldsList.size()]);
}
由于已确认引入JaCoCo后对类进行了修改,只需触发任一流程以获取类的所有属性,通过设置断点并观察集合中的元素,即可查看具体修改情况。
此时已经可以解释为什么引入JaCoCo会导致异常。报错中的类型“[Z”为合成的属性,引入JaCoCo会给类添加一个名为$jacocoData的bool数组类型属性,回到报错代码位置,出现报错是因为在识别到一个数组类型时进行了类型转换,在这里也找到了问题的答案。
涉及到合成属性/方法和JaCoCo的实现原理,下面进行简单的介绍。
3.追本溯源
(1)合成属性和方法
合成属性/方法是由Java编译器在编译过程中自动生成,并不是研发显示编写的,而是为了支持编译器内部的实现细节而生成的,下面针对合成方法进行一个举例说明。
public class Pack {
public static void main(String[] args) {
Pack.Goods goods = new Pack.Goods();
System.out.println(goods.name);
}
private static class Goods {
private String name = "手机";
}
}
将上面的代码编译一下,可以看到有三个文件,Pack$Goods.class、Pack.class、Pack$1.class,前两个一个是内部类,一个是外部类,但是最后一个类并没有被定义过,接下来分别将内部类和外部类进行反编译:
import com.jd.ryan.test.Pack.1;
class Pack$Goods {
private String name;
private Pack$Goods() {
this.name = "手机";
}
Pack$Goods(1 x0) {
this();
}
static String access$100(Pack$Goods x0) {
return x0.name;
}
}
内部类被反编译后,可以发现access$100的方法并没有被定义,但是分析来看name是内部类Goods的私有属性,但是外部类可以直接引用这个属性,从语法结构上讲这是被允许的,这就需要编译器在编译过程处理这种操作,在编译器看来,外部类和内部类是两个独立的类,如果外部类想要访问内部类的私有属性,其实是与封装原则相悖的。那接着看外部类的反编译结果:
public class com.jd.ryan.test.Pack {
public com.jd.ryan.test.Pack();
Code:
0: aload_0
1: invokespecial #1 //Method java/lang/Object."<init>":()V
public static void main(java.lang.String[]);
Code:
0: new #2 //class com/jd/ryan/test/Pack$Goods
3: dup
4: aconst_null
5: invokespecial #3 //Method com/jd/ryan/test/Pack$Goods."<init>":(Lcom/jd/ryan/test/Pack$1;V
8: astore_1
9: getstatic #4 //Field java/lang/System.out:Ljava/io/Printstream;
12: aload_1
13: invokestatic #5 //Method com/jd/ryan/test/Pack$Goods.access$100:(Lcom/jd/ryan/test/Pack$Goods.access$100:(Lcom/jd/ryan/test/Pack$Goods;)Ljava/lang/String;
16: invokevirtual #6//Method java/io/PrintStream.println:(Ljava/lang/String;)V
19: return
}
在代码实现中外部类直接打印内部类的name属性值,来看这行指令:
“Method com/jd/ryan/test/Pack$Goods.access$100:(Lcom/jd/ryan/test/Pack$Goods.access$100:(Lcom/jd/ryan/test/Pack$Goods;)Ljava/lang/String;”
从字节码中表明是通过调用了内部类的access$100方法,这个方法是一个静态方法,它可以返回内部类的name属性,是Goods的私有属性,所以access$100就是编译器用来做内部访问生成的一个合成方法。
编译器可以通过生成合成属性和方法来实现一些内部优化或者内部实现,所以在使用反射机制实现一些工具时,在运行时拿到的类属性信息还可能会有一些未知的属性或者方法,这就需要工具类的代码具备一定的健壮性,对获取到的类属性进行类型转换时应该考虑到非业务字段的情况,并且能够对运行时异常进行捕获,让工具聚焦在可以处理的范围,不能影响正常的业务流程。
(2)JaCoCo原理简述
JaCoCo利用ASM在字节码中插入探针指针(Probe指针),每个探针都是一个布尔变量(true表示执行,false表示未执行)。程序运行时通过修改这些指针来检测代码的执行情况,而不会改变原始代码的行为。提到的$jacocoData数组用于保存这些执行结果,JaCoCo根据控制流类型采用不同的探针插入策略,这些探针不会改变方法的行为,只是记录它们已经执行的事实。
本文不再深入介绍JaCoCo的工作原理,感兴趣的同学可以查阅资料。
三、解决办法
通过问题分析已经确定是$jacocoData导致的,那就需要在获取属性集合的的时对这类属性进行过滤,实现方法通过isSynthetic()方法区分field属性类型,isSynthetic是Java中的一个修饰符,用于标记一个类、方法或字段是否由编译器生成。
List<Field> fieldsList = Arrays.stream(declaredFields)
.filter(field -> !field.isSynthetic())
.collect(Collectors.toList());
代码修改后,测试环境添加JaCoCo相关配置,编译部署发布后可正常下单,从断点信息来看,$jacocoData已经被过滤掉了。