送测结束,开心上线,结果线上突然报错,发现代码走到了一个理论上不可能走到的分支里,又遇到鬼故事了_(:з」∠)_ GG。
问题背景
一段神奇的代码:
两个枚举类,分别定义如下: EnumA ↓
1 |
|
FakeEnumA ↓
1 |
|
代码里进行了一段神奇的操作:
1 |
|
代码正常编译,理论上来说,不同类型比较,应该进入else,而结果确实进入了else。
但是。。这个版本之前,代码执行,输出了“I’m A_1”。。
而在新版本发布后,代码执行, 输出了“else”。。
可以确定的是这段代码相关内容没有任何改动,那么为什么会出现两种不同的结果呢?
异常现象
两个不同的类对象比较,理论上比较肯定会不同,但是原代码比较判定为相等
问题分析
咋办。。
既然代码没有变过,那么项目有没有其他变更呢?
有的,我们把Kotlin升级了,从1.2升级到了1.3,因为1.3提供了协程。。贼开心。。
抱着算一把命的想法,把变更回滚,Kotlin改回1.2,发现果然复现了原来异常的情况,两个不同的类对象,比较判定为了相等,输出了”I’m A_1”,那么基本可以确定是由于Kotlin升级导致的结果。
那么为什么旧版本Kotlin会产生这种现象呢?
Kotlin代码最终也是编译生成字节码跑在JVM上的,那么来看看字节码吧~
Kotlin1.2的字节码实现
先看看Kotlin 1.2的时候,这段代码的字节码是怎样的 ↓
1 |
|
从上面的字节码看,好像并没有啥问题,生成映射、取值、获得映射结果,比较。。
比较。。。比。。。较。。。tableswitch。。oridinal。。。oridinal。。。
oridinal不是返回的枚举中类型序号吗。。。
所以这个比较只是在比较序号的吗。。。
EnumA和FakeEnumA中枚举类型声明的顺序确实是一样的,那如果我把FakeEnumA中的定义顺序换一下,不就正常了吗。。
然后我测试了一把,发现并没有用,结果依然是判定相等,那这是为什么呢。。。
Kotlin 1.2 中,when的实现
继续看字节码,发现通过iaload加载了EnumTestMainKt$WhenMappings.$EnumSwitchMapping$中index为0(A.A_1.oridnal)的元素,进入tableswitch进行跳转,那么这个EnumSwitchMapping又是什么呢?
继续看字节码:
1 |
|
该Mapping中,基于EnumA中的元素个数,创建了一个数组,数组中的对应关系是怎样的呢,我们按照字节码一步步来看:
9 -> getstatic,获取到Mapping中的数组元素 12~15 -> EnumA.A_1.ordinal(),获取到0 18 -> iconst_1,得到整数1 19 -> iastore,存入数组
在iastore前,操作数栈中的元素如下:
1 ordinal $EnumSwitchMapping$0
而iastore命令调用的栈描述如下:
value index arrayref
可以得出其等同于语句$EnumSwitchMapping$0[ordinal]=index
则该Mapping中数组的对应关系为:mapping[0]=1, mapping[1]=2,可以看出,该Mapping实际保存了枚举类型ordinal到tableswitch的映射关系
那么再看回之前的iaload,通过EnumA.A_1的ordinal,从Mapping中加载出的值为1,对应到tableswitch,跳转到40,最终进入”I’m A_1”分支
所以总结下来,跳转过程如下:
那么整个过程看来,和FakeEnumA没有一点关系,那么真的是这样吗?
EnumSwitchMapping
在Kotlin 1.2编译生成的字节码中,EnumSwitchMapping主要保存了ordinal到tableswitch的映射关系,字节码如下:
1 |
|
可以看出整个Mapping的生成似乎和FakeEnumA没有任何关系,但是实际上是这样的吗?
我们尝试改动FakeEnumA和when的代码,进行如下测试:
1.FakeEnumA添加一个新的类型A_3,代码如下:
1 |
|
2.when跳转中,把FakeEnumA.A_1改为FakeEnumA.A_3,代码如下:
1 |
|
以上代码均正常编译通过,如果整个过程和FakeEnumA没有关系的话,那么应该会正常运行,并输出”I’m A_1”。
但是实际执行却抛出异常:
1 |
|
在运行时抛出了NoSuchFieldError,说明在编译的时候时候,编译器校验正常通过,但在运行的时候,发现A_3找不到了,抛出异常。
此时的EnumSwitchMapping字节码如下:
1 |
|
可以看到,是在类的静态初始化域中,对类内的数组对象进行了初始化,并赋值,其中12: getstatic尝试获取EnumA中的A_3时,发现找不到对应的枚举类型。
由此可以推断,该Mapping的在编译时依赖于when中的条件分支(FakeEnumA.A_3和FakeEnumA.A_2)进行生成,而生成时,只取了枚举类型的name,并没有判断是否是同一个枚举类型,最终导致了这个异常。。
Kotlin 1.3 中,when的实现
综上所述,已经找到了Kotlin 1.2中会进入错误分支的原因,那么为什么Kotlin 1.3中会恢复正常,进入else分支呢?
我们看一看使用Kotlin 1.3编译后生成的字节码:
1 |
|
首先,生成的字节码中已经没有了EnumSwitchMapping这个类,那么再看看字节码,字节码中也没有了tableswitch,而是使用了if_acmpne进行比较判断。
那么if_acmpne是怎么比较的呢 ↓
1 |
|
可以看到,if_acmpne是对两个对象的引用进行比较,如果是两个对象的不相等,则进行跳转。
由此可见if_acmpne进行的是对象引用的比较,而EnumA.A_1与FakeEnumA.A_1属于不同的对象,那么72: if_acmpne比较EnumA.A_1与FakeEnumA.A_1,发现两者不相等,跳转至88: aload_2,加载FakeNumA.A_2,与EnumA.A_1进行比较,仍然不相等,最终跳转到108进入else。
因此,最终输出了”else”。
问题原因
(1)Kotlin 1.2 编译实现when语句时,在value为枚举类型的情况下,未进行类型校验,并且使用的Mapping关系映射+tableswitch实现when条件判断和分支跳转;
(2)Kotlin 1.2 在编译生成Mapping关系映射生成代码时,仅判断枚举类型name,而两个枚举类中的命名一致,导致Mapping关系映射正常生成,但是错误映射到不相等的分支,最终输出”I’m A_1”;
(3)Kotlin 1.3 中,对when的编译实现使用了if_acmpne,进行对象引用比较,代码执行按正常逻辑走入else分支。
解决方案
调整when比较的条件,使用相同对象进行比较,解决了该问题。