二十万分之一几率:if语句变do-while卡死问题分析|得物技术
itomcoil 2025-03-13 16:34 19 浏览
一、背景
某次灰度发布之后没多久就收到线上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之类稳定性兜底的功能,其危害可想而知。
往期回顾
1.盘点这些年搭建器在用户体验优化的实践|得物技术
2.Java性能测试利器:JMH入门与实践|得物技术
3.解析Go切片:为何按值传递时会发生改变?|得物技术
4.基于IM场景下的Wasm初探:提升Web应用性能|得物技术
5.增长在流量规则巡检的探索实践|得物技术
文 / Jordas
关注得物技术,每周一、三更新技术干货
要是觉得文章对你有帮助的话,欢迎评论转发点赞~
未经得物技术许可严禁转载,否则依法追究法律责任。
相关推荐
-
- Python编程实现求解高次方程_python求次幂
-
#头条创作挑战赛#编程求解一元多次方程,一般情况下对于高次方程我们只求出近似解,较少的情况可以得到精确解。这里给出两种经典的方法,一种是牛顿迭代法,它是求解方程根的有效方法,通过若干次迭代(重复执行部分代码,每次使变量的当前值被计算出的新值...
-
2025-10-23 03:58 itomcoil
- python常用得内置函数解析——sorted()函数
-
接下来我们详细解析Python中非常重要的内置函数sorted()1.函数定义sorted()函数用于对任何可迭代对象进行排序,并返回一个新的排序后的列表。语法:sorted(iterabl...
- Python入门学习教程:第 6 章 列表
-
6.1什么是列表?在Python中,列表(List)是一种用于存储多个元素的有序集合,它是最常用的数据结构之一。列表中的元素可以是不同的数据类型,如整数、字符串、浮点数,甚至可以是另一个列表。列...
- Python之函数进阶-函数加强(上)_python怎么用函数
-
一.递归函数递归是一种编程技术,其中函数调用自身以解决问题。递归函数需要有一个或多个终止条件,以防止无限递归。递归可以用于解决许多问题,例如排序、搜索、解析语法等。递归的优点是代码简洁、易于理解,并...
- Python内置函数range_python内置函数int的作用
-
range类型表示不可变的数字序列,通常用于在for循环中循环指定的次数。range(stop)range(start,stop[,step])range构造器的参数必须为整数(可以是内...
- python常用得内置函数解析——abs()函数
-
大家号这两天主要是几个常用得内置函数详解详细解析一下Python中非常常用的内置函数abs()。1.函数定义abs(x)是Python的一个内置函数,用于返回一个数的绝对值。参数:x...
- 如何在Python中获取数字的绝对值?
-
Python有两种获取数字绝对值的方法:内置abs()函数返回绝对值。math.fabs()函数还返回浮点绝对值。abs()函数获取绝对值内置abs()函数返回绝对值,要使用该函数,只需直接调用:a...
- 贪心算法变种及Python模板_贪心算法几个经典例子python
-
贪心算法是一种在每一步选择中都采取当前状态下最优的选择,从而希望导致结果是全局最优的算法策略。以下是贪心算法的主要变种、对应的模板和解决的问题特点。1.区间调度问题问题特点需要从一组区间中选择最大数...
- Python倒车请注意!负步长range的10个高能用法,让代码效率翻倍
-
你是否曾遇到过需要倒着处理数据的情况?面对时间序列、日志文件或者矩阵操作,传统的遍历方式往往捉襟见肘。今天我们就来揭秘Python中那个被低估的功能——range的负步长操作,让你的代码优雅反转!一、...
- Python中while循环详解_python怎么while循环
-
Python中的`while`循环是一种基于条件判断的重复执行结构,适用于不确定循环次数但明确终止条件的场景。以下是详细解析:---###一、基本语法```pythonwhile条件表达式:循环体...
- 简单的python-核心篇-面向对象编程
-
在Python中,类本身也是对象,这被称为"元类"。这种设计让Python的面向对象编程具有极大的灵活性。classMyClass:"""一个简单的...
- 简单的python-python3中的不变的元组
-
golang中没有内置的元组类型,但是多值返回的处理结果模拟了元组的味道。因此,在golang中"元组”只是一个将多个值(可能是同类型的,也可能是不同类型的)绑定在一起的一种便利方法,通常,也...
- python中必须掌握的20个核心函数——sorted()函数
-
sorted()是Python的内置函数,用于对可迭代对象进行排序,返回一个新的排序后的列表,不修改原始对象。一、sorted()的基本用法1.1方法签名sorted(iterable,*,ke...
- 12 个 Python 高级技巧,让你的代码瞬间清晰、高效
-
在日常的编程工作中,我们常常追求代码的精简、优雅和高效。你可能已经熟练掌握了列表推导式(listcomprehensions)、f-string和枚举(enumerate)等常用技巧,但有时仍会觉...
- Python的10个进阶技巧:写出更快、更省内存、更优雅的代码
-
在Python的世界里,我们总是在追求效率和可读性的完美平衡。你不需要一个数百行的新框架来让你的代码变得优雅而快速。事实上,真正能带来巨大提升的,往往是那些看似微小、却拥有高杠杆作用的技巧。这些技巧能...
- 一周热门
- 最近发表
- 标签列表
-
- ps图案在哪里 (33)
- super().__init__ (33)
- python 获取日期 (34)
- 0xa (36)
- super().__init__()详解 (33)
- python安装包在哪里找 (33)
- linux查看python版本信息 (35)
- python怎么改成中文 (35)
- php文件怎么在浏览器运行 (33)
- eval在python中的意思 (33)
- python安装opencv库 (35)
- python div (34)
- sticky css (33)
- python中random.randint()函数 (34)
- python去掉字符串中的指定字符 (33)
- python入门经典100题 (34)
- anaconda安装路径 (34)
- yield和return的区别 (33)
- 1到10的阶乘之和是多少 (35)
- python安装sklearn库 (33)
- dom和bom区别 (33)
- js 替换指定位置的字符 (33)
- python判断元素是否存在 (33)
- sorted key (33)
- shutil.copy() (33)