春节快乐,干货来袭。QAPM团队已服务于公司内外包括国有大行的50 产品,声音大呀,但是之前的卡顿与启动个例是真心不好用,也让不少团队憋着对我们的吐槽来推广他们的新方案。何苦呢?我们怎么能站着茅坑不XX!新方案其实一直在路上,一直在路上,现在终于来了。本文,我们会描述两个“在路上”的曲折与思考,也透视出新方案的技术核心与优势。
切肤之痛,个例单堆栈根本无法定位准确
例一:堆栈显示正常创建一个对象,这个地方怎么会卡呢
例二:堆栈显示的是个空方法
类似的反馈我们收到了太多了,卡顿方案的缺陷也一直是我们的痛点。一个事件下存在大量的执行函数,而方案是基于阈值满足的前提下才执行的堆栈抓取,这样会产生堆栈偏移,有可能真真实实捕获到了卡顿所在的函数,但更多的是一些不卡的函数,只是刚好被捕获到了~
一直在路上之一,方法插桩,稳定 可靠 性能好
通过在方法的开始和结束点插入方法,统计并记录方法名与方法耗时。微信和闪现社区用的都是这个方案。经过微信的打磨,在其开源的Matrix中已经非常完善,稳定可靠,性能好。这么好,是不是直接用微信的方案吧,但是我们很犹豫。而这犹豫源自于一个我们执迷不悟的坚持。
我们团队都是专项测试出身,跨产品做性能分析与优化是家常便饭,也因此对于我们来说,可以泛化的分析方法,价值更大。系统方法调用栈就是构成这个分析方法的核心,例如文件与数据库的主线程I/O,IPC/RPC的调用导致的卡顿/ANR,其实都有可以总结的优化方法和思路。而这个美好的插桩方案让我们犹豫的核心原因就是,没有系统方法调用。然后我们就一直犹豫,不想放弃原来的方案。
一直在路上之二,一个“无效”的突破
正当我们犹豫之际,我们的nicky同学在受到闪现社区的冲击(技术人的冲击就是这么地简单纯粹),又一个团队选择了插桩。这时,nicky开始深入研究JVMTI的黑科技,希望可以有个完美方案。最终我们成功绕过了debug包的限制,在不侵入的前提下,在release包下可以拿到各个方法的进出及耗时。原本以为胜利就在前方,但却被现实泼了冷水。在JVMTI开启的情况下,性能消耗是原来的100%。。。一番考虑下,APM这边暂时不将该方案合入,但JVMTI的突破,的的确确给了我们更多的发展空间,如线程监控、内存分配监控等等。
终于来了,谁说采堆栈不能优化
撞了南墙也要破个洞穿过去!采集堆栈的方法真的无解吗?在这之前,我们来总结下,目前主流的实时获取耗时信息的方案:插桩法、定时堆栈法、jvmti法。其优缺点和典型的代表如下:
JVMTI法:由于性能问题,APM暂时不做考虑;定时堆栈法:精准度低,性能消耗大;插桩法:无系统和应用层的调用关系,侵入性强且后续有包体优化的成本。这样看来,好像没有一个方法是合适的。但我们的内心是喜欢定时堆栈的,无他,为了那个具备问题解决方案泛化价值的系统调用栈。
"方法总比问题多",执迷不悟的我们还是尝试从问题着手,去尝试优化那个别人抛弃的定时堆栈。而核心就是要破除这个看似无解的问题,性能差-精度低。精准度低:事件内多函数执行,达到阈值才去抓取堆栈只能靠运气的抓到真耗时函数,大部分抓到的可能是耗时短的函数;性能消耗大:堆栈转换成字符串时容易造成太多GC,而为了提升精度,还会要增加捕获的堆栈,也增大了性能的消耗。下面来看看,针对这两个问题,我们是如何突破的。
解决精准度低的问题:
在事件进入时,开启一个延时的定时任务,如果任务在规定时间内完成则取消掉,否则开始间隔30ms抓取堆栈对象,最多抓取3秒数据的堆栈,即100个堆栈对象。
解决性能消耗大的问题:
一个小小的细节:
一个堆栈对象,真的没有可以利用的空间了吗?一个堆栈由包名、文件名、方法名和行号组成,但其实我们还忽略了一个比较重要的信息,是该栈距栈底的距离。而通过行号及栈深的计算,基本可以确定栈的唯一性
抓取到的堆栈对象中的每一行栈,转成字符串做map存储计数,就能大概分析出卡顿的点了,但如果使用字符串去匹配,那内存的消耗则会大大的上升,那如何不使用字符串匹配又能知道出现次数最多的栈呢?
整型的计算,远远会比字符串的操作消耗要低。通过行号及栈深来记录该栈的出现次数,应该就能初步解决消耗大的问题。
为什么说是初步?试想一下,极端情况下,我们抓到了100个堆栈数组,且每个堆栈数组的深度有50行 ,在每个堆栈都需要计算存储的前提下,我们至少得需要5000 次的计算任务。这里其实也是有不小的消耗的。那能不能减少这里的计算和存储成本呢?其实是可以的。APM对于一个堆栈数组的处理是这样的,从找到第一个非系统栈开始,保留业务栈的上层系统栈,从当前栈开始,往下追五层,如果连续超过5层还有业务栈,则不再处理新的业务栈,且当再次碰到系统栈或者遇到handleCallback等方法时,结束处理当前堆栈数组。
将堆栈归类后,现在就要找到大头卡顿的栈,怎样算大头卡顿的栈呢?这个其实也没有一个太好的标准。目前卡顿阈值是200ms,30ms取栈一次,大于3次则认为是卡顿的源头了。在寻找大头卡点时,又碰到了另一个问题,就是怎么去除重复的栈。举个例子:如果连续出现多个同样的堆栈数组且堆栈数组内有多个业务栈,就会发现会有多个key指向同一个堆栈,如下图
这种情况多出现于该函数发生长卡顿的,对于这种情况,我们采用的是有序的map来进行存储,栈存储时是由上往下遍历存储,所以读取时我们从下往上遍历。如上图的情况,我们会先找到了行号为410的栈,发现他的儿子栈为70,于是往上追,再发现他的儿子栈为613,以此类推,最终找到了618的栈顶类,这个时候再来做处理,就可以避开另外三个的栈处理。
最后找到最大头的卡点进行上报
最终我们借助真实的App测试了性能消耗
这里特别感谢手Q和视频团队提供的测试环境。
通过WeTest和PerfDog的性能测试工具,分别对带有新卡顿和旧卡顿的包进行了多场景下的性能测试,在获取更多堆栈,更多逻辑处理的基础上,大部分数据与旧卡顿相差无几。而且普通的系统方法获取堆栈,绝对没有任何兼容性问题,安全可靠,而且还有那个我们梦寐以求的系统调用在其中。
最后
小小的细节,大大的改变。如对方案有异议或有更好的建议,欢迎批评指正(QAPM是个很开放的团队哦~)
--