摘要:某次灰度发布之后没多久就收到线上ANR告警,经排查定位到是某个页面onCreate方法执行太久导致,而火焰图中的耗时堆栈指向了我们用于监控页面启动速度的一段插桩代码,反编译Apk之后发现本该是if语句的代码竟变成了一个do-while语句,形成了死循环最终导致
目录
一、背景
二、问题分析
1. 初步分析
2. 分析R8
3. 分析weaver
4. 揭露谜底
三、修复方案
四、总结
一
背景
某次灰度发布之后没多久就收到线上ANR告警,经排查定位到是某个页面onCreate方法执行太久导致,而火焰图中的耗时堆栈指向了我们用于监控页面启动速度的一段插桩代码,反编译Apk之后发现本该是if语句的代码竟变成了一个do-while语句,形成了死循环最终导致主线程卡死。
此后每构建二、三十次都会复现一次该问题,且每次的异常页面,异常方法完全随机。
二
问题分析
if和do-while两个完全不相干的语句为什么出现互相转化的情况?在jadx反编译而来的smali代码中不难看出,if语句对应的标签正常情况下应该指向的是return语句,和Java源码中if语句块后面紧跟着return语句对应。而异常情况下标签跑到了整个函数的开头,故被jadx翻译成了do-while,因此问题的关键就在这个label上面。
初步分析
出现此问题的这段插桩代码出自我们的APM页面启动监控,原本是插桩在Activity和Fragment的onCreate等关键生命周期中用于耗时统计。其所在的类是由我们自定义的插桩plugin weaver所生成(基于byteX开发的一个plugin,支持插入,代理和替换等自定义的插桩行为)。
因此我们要对从该plugin所在的byteX transform开始,直到最终产出dex文件的R8 transform结束这期间的所有transform挨个分析。
由于问题偶现,且每次异常的类和方法完全随机,说明大概率是一个多线程并发读写的问题,因此我们在分析过程中会需要重点关注涉及并发读写的逻辑。
分析R8
我们在输入给R8的jar包中找到了这个异常类的class文件,这里可以看到jadx反编译这个方法会失败,看class字节码中if语句跳转指向的标签L29,但是函数中并没有定义L29指向的是哪里,并且smali视图下查看可以看到if语句指向的标签在整个函数体中也没有声明,但是前面反编译DEX文件得到的结论是标签有声明但是在函数体的第一行,两者不一致,说明R8可能在执行过程中编辑了字节码导致异常。
(这里我们早期误以为标签丢失并不会导致语句变化这种程度的错误,因此直接将范围锁定在了R8,虽然后续证明了此问题与R8无关,但这段分析也为最终解开谜底提供了关键线索)
环境准备
R8目前已经不再单独提供jar包,而是一同打包在AGP中,且开启了混淆,因此想要调试/修改代码就需要自行clone源码,切到自己项目AGP版本对应的git tag来构建R8.jar并指定,具体操作可以参考R8的git仓库中描述:https://r8.googlesource.com/r8
阶段产物分析
目前的R8是由早期的D8融合了一系列的包体/性能优化的操作而来,dx负责将jar包整合压缩成DEX文件,它相对于后来新增的编译优化操作来说出现问题的概率更低,因此我们优先关注R8中涉及对字节码进行编辑的优化功能。
由于R8在输入了jar包之后一直在内存中进行操作,并无中间产物,因此我们需要在相关功能的开始结束点手动将内存中所有由自定义weaver plugin生成的class(有统一的后缀名)写到文件并保存。
在多次打包复现问题之后,对阶段产物进行分析并未发现异常方法的字节码有任何变动,直到dx这一步,我们发现if语句在class字节码中跳转到指定标签的行为,在dex文件的smali字节码中被编译成了跳转到指定的函数偏移量。
而之前class字节码中if语句指向的label找不到声明的问题,在smali中表现为直接将函数偏移量设为默认值0X00,正好是函数体的第一行,和一开始反编译apk得到的结果吻合,这也就解释了为什么if语句最终会变成一个do-while语句。
小结
至此,我们已经知晓为什么if语句会变成毫不相干的do-while语句,同时也排除了R8的嫌疑,接下来就是要继续回溯transform,排查为什么class字节码中if语句指向的标签的声明会丢失。
分析weaver
在回溯排查完所有途径transform的产物之后确认这个异常的方法在一开始weaver生成他时就已经是异常状态,因此问题范围锁定到此plugin。
在继续分析问题之前我们来了解下weaver的插桩原理:
weaver插桩原理
weaver基于byteX实现了一些自定义的插桩行为,这次出问题的是insert行为,也就是在目标函数开头插入代码的模式,其实现原理是预先写好要插桩的代码,在plugin执行期间会用ASM的classNode读取这个类,并将其中的方法复制到一个新建的内部类中,这个内部类会被添加到在注解中指定的目标类中,再在目标类的生命周期函数中调用这个内部类对应的方法即可完成对生命周期的插桩。
走码分析
虽然我们已经确认是weaver在生成内部类中方法时出现异常,但是生成的过程是从0到1,此时再去加日志打印class字节码分析中间产物已经没有意义,并且由于其极低的复现概率,我们也无法在本地做调试分析。
遂走码分析,最后发现在从旧方法中复制方法提供给内部类的过程中,出现了ASM版本不一致的问题,由于整个byteX组件全局指定了ASM的版本是9,但是weaver中使用了ASM9的methodNode去clone出一个指定为ASM5的methodNode,但是很遗憾这并不是根因,在修正版本后依旧会复现问题。
我们目前已知的只有class字节码中if语句指向的label没有声明,遂猜测是methodNode的指令链表中丢失了labelNode,但添加了相应的检测逻辑之后并未命中,故排除labelNode丢失的可能。
关键线索缺失
前文中提到过推测这个问题和多线程有关,因此理论上在本地固定输入输出,并用大量线程并发死循环跑是能够复现问题从而debug找到根因的,但是苦于没有明确的检测逻辑,即不知道这个methodNode在什么状态下才算异常,哪怕问题复现了也无法断点。
逆向分析异常字节码
当务之急是找到合适的异常字节码检测手段,但是在常规思路都碰壁时,不妨用逆向思维试试,于是把异常的class文件直接用ASM的classNode类读取到内存,仔细观察异常方法和正常方法的指令链表中labelNode是否有什么不一样。最终发现异常methodNode的指令链表中,jumpNode持有的labelNode和链表中的labelNode不是同一个对象(正常情况下是)。
带着这个逆向得到的结论,再正向去验证他,即编码实现主动将某个方法的labelNode给替换成新的对象,再输出为class文件,发现和前面得到的异常class完全一致,至此我们就得到了一个准确的异常检测逻辑。
带着前面得到的精准检测逻辑,我们在本地写demo开16线程并发,瞬间就复现了此问题,随后顺着这个线索走码也找到了问题根因。
这里使用我们正常运行时使用的forkjoinpool,并发死循环执行前面提到的methodNode复制过程,模拟正常构建过程的并发度,最终得出结果是大约每执行20w次可以复现一次问题,除以我们App中相关方法的量级,正好和之前约每20次~30次构建复现一次的频率吻合。
小结
至此我们已经定位到了引起问题的代码,也通过多种手段验证了根因就是多线程复制methodNode,但稳妥起见还是要刨根问底弄明白并发复制到底是怎么引起的labelNode对象被替换,防止还有更深层次的问题被掩盖。
揭露谜底
ASM方法复制原理
methodNode复制流程图如下:
ASM的methodNode类,通过其accept方法可以将这个方法复制给一个methodVIsitor,通常情况下只会使用一次,如果有1次以上的复制行为,就会在复制之前将指令链表中的labelNode中记录跳转地址的label对象置为null。
(clone方法理应是创建一个全新的对象,不应该和旧对象有任何共用的数据,ASM这里的处理没问题,但是没有适配多线程的情况)
随后在指令复制的过程中,在遍历到jump指令(通过持有labelNode来形成指向关系)时,会通过getLabel方法将刚刚被置null的label对象重新new出来,同时再从新的label对象中new一个新的LabelNode交给新的JumpNode。
等遍历到对应的LabelNode时,此时getLabel拿到的是刚刚new出来的新Label,同样的链路再走一遍,此时无需再new新的,并且新方法中的JumpNode持有的labelNode也和当前是一个对象。
多线程问题根因
至此我们能得知在复制methodNode的过程中,针对labelNode有多次读写操作。而weaver为了加快执行速度,对每一个class都单独安排了一个task,全都提交给一个forkJoinPool来执行,并且按照前面介绍的weaver插桩原理,提前写好的这个类里的方法,总计会复制成千上万次,提供给每一个Activity的内部类。因此在多线程高并发执行时就会出现以下顺序:
这样最终就会出现jumpNode持有的LabelNode和指令链表中的LabelNode不一致的问题。
三
修复方案
ASM为了规避同一个methodNode在多次复制时,复制出来的新methodNode的labelNode全都指向同一个对象的问题,加了这个resetLabel的标签重置逻辑,但是并没有考虑到多线程并发执行的场景,因此该问题最终加一个类锁即可解决,放那已上线验证有效。
四
总结
这类多线程引起的字节码异常问题潜伏期可达到数年之久,例如本文遇到的问题在App的页面量级较低时几乎不会触发,但随着App的业务规模增长,又或是打包机器的一次升级换代,问题就会悄然出现,而他极低的复现概率和随机性又很容易使其被忽视。
字节码异常问题在互联网鲜有参考资料,倘若字节码损坏直接崩溃还则罢了,遇到这种恰巧能被当成其他语句继续执行的情况分析起来着实麻烦。因此开发插桩这类涉及代码编辑操作的plugin,针对"写”操作务必要慎重开发,重点测试下极端并发的场景。这类问题如果是发生在定时大量推送的活动页或者热修sdk之类稳定性兜底的功能,其危害可想而知。
来源:奕奕课堂