写点什么

二十万分之一几率:if 语句变 do-while 卡死问题分析|得物技术

作者:得物技术
  • 2024-12-10
    上海
  • 本文字数:3618 字

    阅读完需:约 12 分钟

二十万分之一几率:if语句变do-while卡死问题分析|得物技术

一、背景

某次灰度发布之后没多久就收到线上 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 之类稳定性兜底的功能,其危害可想而知。


文 / Jordas

发布于: 刚刚阅读数: 4
用户头像

得物技术

关注

得物APP技术部 2019-11-13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
二十万分之一几率:if语句变do-while卡死问题分析|得物技术_android_得物技术_InfoQ写作社区