- Nanoscope 不同于其他两类方案,它不仅在解释器中埋点,还对编译器做了修改,当函数被编译成机器码时,会在方法的出入口增加机器码逻辑,用来记录编译后的方法的执行。这样做的好处就是,无需限制程序为解释执行,性能得到了保障,但由于修改了编译器代码,所以需要刷定制后的 ROM 才能使用 Nanoscope,失去了通用性。
除了这三种方案外,直接通过字节码插桩对应用内方法加埋点也可以达到目的,但是插桩会导致性能损耗、占用内存以及包大小增加的问题,且无法支持 Framework 堆栈,这里不再详细讨论。
采样型
Debug.startMethodTracingSampling 和 Profilo 属于采样型,其原理是每隔一定的时间,抓取一次线程的堆栈,通过在程序运行时不断的抓取,最终组合形成 trace 数据。
-
Debug.startMethodTracingSampling 是通过创建一个独立线程,在循环内对所有线程进行抓栈并记录数据。该方案每次抓栈前都会 SuspendAll,等待所有线程抓栈完毕再 ResumeAll,并且在线程挂起时间内,还要进行堆栈的处理,以及文件的写入,导致每次采样所有线程都需要等待很长时间,性能损耗较大。
-
Profilo 的方案较为新颖,创建定时器不断的向目标线程发送 SIGPROF 信号,在注册的信号回调内执行抓栈操作。这样做的好处是性能损耗较低,可以直接在当前线程抓栈,损耗近似为抓栈耗时,缺点是方案复杂适配成本高,Profilo 的抓栈方案使用了大量的固定偏移,兼容性稳定性以及后续扩展性较差。
采样抓栈的限制也比较明显,采样间隔过大,trace 的精度会变低,而采样间隔过小,性能损耗就会增大。优点是可控性强,大部分情况下短耗时函数我们并不关心,而合适的采样间隔可以保证只监控长耗时函数。
| 方案 | 原理 | 场景 | 性能 | 主要缺点 |
| — | — | — | — | — |
| Systrace | 埋点型 | 线下 | 中 | 无法抓取应用函数栈 |
| Debug.startMethodTracing | 埋点型 | 线下 | 低 | 强制解释执行,性能差 |
| Nanoscope | 埋点型 | 线下 | 高 | 只能在定制 ROM 上运行,不够通用 |
| Debug.startMethodTracingSampling | 采样型 | 线下 | 低 | 需要挂起全部线程,性能差 |
| Profilo | 采样型 | 线上 | 高 | 稳定性难保证,适配成本高 |
我们的预期是通过 Method Trace 工具,达到解决线上性能问题的目的。通过对现场 trace 文件的解析来解决 ANR 和卡顿堆栈聚合及归因不准确的问题,进而提升此类问题发现和解决的效率。通过前面的分析,可以看到现有的工具或多或少都存在一些问题,而且大部分工具的设计就是面向线下的,存在不同程度的性能损耗与稳定性问题,无法上线使用。线上用户的设备环境复杂,遇到的很多性能问题是无法在本地模拟复现的,所以理想的 trace 工具需要能够在线上使用。
方案选型
满足上线标准的 trace 工具需要达到:高性能、高稳定、低侵入、兼容性强等要求,所以需要针对现有问题设计一套方案,解决掉现有方案的问题。
埋点型方案如果想监控到 Framework 方法,就需要深入到 art 虚拟机,通过一些 hook 手段去修改运行时逻辑,而且还要兼顾到机器码执行等等,难度与风险都是巨大的,以现有的手段来看,埋点型基本无法实现。而采样型方案主要存在的问题就是采样精度与性能损耗之间的矛盾,ANR 和卡顿监控主要是监控长耗时函数,采样精度可以放低,所以我们只要保证单次抓栈时间最短,就可以对性能影响最小,同时保证稳定性兼容性,即可达到目标。
实现原理
ART 抓栈原理
采样型 trace 方案的核心就是抓栈,大家最熟知的抓栈方法,应该是 Java Thread 类的 getStackTrace 方法,下面根据这个函数分析下抓栈原理。
getStackTrace 内调用了 VMStack.getThreadStackTrace,传入抓栈的目标线程。
该函数是一个 native 函数,里面的工作分为三步:一是创建回调,内部会调用 native 层的 Thread::CreateInternalStackTrace 函数;二是调用 GetThreadStack 函数,传入 env,Java Thread 以及回调函数指针;三是将 GetThreadStack 的返回结果通过 Thread::InternalStackTraceToStackTraceElementArray 处理,得到 Java 层最终的返回值 StackTraceElement[]。
GetThreadStack 函数内,会先判断抓栈的目标线程和执行抓栈的当前线程是否是同一个线程,如果是的话,则可以直接运行传入的回调 fn;而如果要抓其他的函数栈,则要先将目标线程 Suspend,