前言
最轻松的性能分析与优化只需要针对一个简单方法:
void profile() {
long startTimestamp = SystemClock.elapsedRealtime(); // 获取当前时间戳
greet();
long endTimestamp = SystemClock.elapsedRealtime();
long time = endTimestamp - startTimestamp;
System.out.println("Greeting 1000 times cost " + time + " ms.");
}
void greet() {
for (int i = 0; i < 1000; i++)
System.out.println("Hello world!");
}
就是这样。如果觉得 greet 方法耗时有点长了,仔细研究这两行代码,可以再优化一下:
void fasterGreet() {
String helloWorld = "Hello world!";
String helloWorldDouble =
new StringBuilder(helloWorld).append("n").append(helloWorld);
for (int i = 0; i < 500; i++)
System.out.println(helloWorldDouble);
}
在 IntelliJ IDEA 上 Java 1.8 观察到的优化效果是 1ms/8ms,说明这段代码有优化空间,但上限不是很高。毕竟减少一半的 println(String;)调用也只有 1/8 的优化,再怎么减少调用,也不可能超过 2ms。
只是,分析单个方法从来就不是做优化遇到的实际场景。
直到用户开始吐槽以前,应用性能一直都只是隐含价值,甚至不成为开发团队的共识。所以,被用户推动着去优化特定场景(比如启动)的性能的情况也就经常发生。这时候,这些环节早就堆满了大量的代码,还不断地有新增代码掺和进来,逐条去看是不可行的。
一种显见的思路是“分治”,也就是做埋点,把用户场景分成很多段,然后挑时长增长的小环节去针对优化。问题在于,分治要求各个子问题之间相互独立,但现实中这一点往往不成立。
当我刚接触启动优化的时候,整个流程就是埋点驱动的:团队埋了二三十个点,每一段儿过程都有个负责人,哪一段性能不好了就找负责人。结果,我从负责人那儿收到最多的反馈是:我们这段儿没怎么改啊,怎么耗时变长了呢?这个问题一度是性能优化的痛点。
简单的埋点方案存在局限性:
-
埋点的个数很有限,每一段过程内部仍然复杂,还存在多线程带来的不确定性。
-
在存量代码优化时埋点提供的信息几乎没有价值。
-
埋点无法识别无害的性能事件,一个单例懒加载的实际加载点转移就能搞得鸡飞狗跳。
这说明基于埋点的分析方法在精细度与信息价值上有待改进。下面介绍一种埋点方案,能够将精细度提升到方法级,也能结合常用的性能分析工具使用,提供能指导优化工作的信息。在此之前,先来同步一些“黑话”。
一些概念
面向切面编程(下称 AOP)
以下释义源自云文档词典。
面向切面的程序设计。
AOP 使得开发人员能够将与代码核心业务逻辑关系不那么密切的功能(如日志功能)添加至程序中,同时又不降低业务代码的可读性。AOP 思想只需要关注所有模块中的'横切关注点'。日志功能即是横切关注点的一个典型案例,因为日志功能往往横跨系统中的每个业务模块,即“横切”所有有日志需求的类及方法体。
实现 AOP 的方法多种多样,Java 中常见的 AOP 工具有:AspectJ,ASM 等,通过插装 hook 的方法实现 AOP。
在字节系 Android App 中,应用最广的 AOP 工具是 ByteX 插装平台,详情参见:github.com/bytedance/B…
线程状态
在不同领域内线程状态有很多套标准,每套标准都有很多种状态。为了简单理解,本文根据是否占用 CPU 为准,统一简化为两种状态:在某个 CPU 上工作,称为“运行”状态;以及不占用 CPU,称为“睡眠”状态。
性能分析工具
简要介绍下它们的职能:android.os.Trace(下称 Trace)是官方提供的栈事件埋点 API。atrace 接入来自系统 CPU 调度事件以及来自应用的 Trace 事件,通过 ftrace 做记录。systrace 根据要求调用 atrace 并获取 ftrace 日志。Perfetto Trace Processor(下称 Processor)分析日志,判断性能状态、定位性能问题。
ftrace
参考资料
ftrace 是 Linux 提供的基于 ring buffer 的性能分享工具与日志工具。相较于一般的 ring buffer,ftrace 增加了写提交(这使得 ftrace 能够高效完成高并发记录)与页面管理两种特性。除此以外,ftrace 还自带时间戳输出。
atrace
atrace 是 Android 团队提供的 ftrace 封装,接入了大量数据源,着重介绍以下两种:
android.os.Trace:Android 系统使用的埋点方案,应用也可以使用。本质是对 ftrace 写日志的封装。
/proc:系统级的优化分享工具,最常用的是 CPU 调度信息以及线程状态信息。
systrace
一个 PC 端脚本,对 atrace 进行调用并拉取结果,以可视化形式呈现。
其升级版 Perfetto 只支持高版本的 Android 系统,但性能更好,容量更大,数据源更全……总之各方面体验都更好。
Perfetto Trace Processor
参考链接
对于埋点产生的大量数据的处理与分析,数据库当仁不让。Perfetto Trace Processor 是 Android 官方提供的性能分析数据处理工具。它把 systrace 拉取的数据导入 Sqlite,通过 SQL 进行处理。
性能状态类型与特征
性能状态的分析虽然不是必须,但是在实践中能够指导各类优化方案的优先级,因此很有价值。通过 Processor 可以结合 CPU 调度事件以及 Trace 事件,定量分析全局性能状态与 Trace 事件的性能状态,从而解释 Trace 事件性能变化对全局性能产生的影响。这种结合有一个前提条件:两种事件的时序是正确的,这个条件限制会影响下述方案的具体实现。
从 CPU 使用效率的角度,性能状态可分为三种类型:负载过重、等待子线程、CPU 竞争。
负载过重
主线程负载过重是最常见的性能状态:进程中主线程绝大多数时间处于运行状态,而且其他线程只有少量的运行状态。
这种状态的 CPU 使用效率不高,提高并行是最应当采用的优化方法。注意保证多线程安全与时序正确,避免产生下面提到的等待子线程问题。
特殊地存在其他线程的负载过重问题,例如 JavaScript 使用与单线程 Unity。技术选型限制导致了不能直接使用多线程,甚至不能使用主线程。一般来说,如果需要提高 CPU 利用率,往往需要使用称为“桥”的通信机制把部分逻辑转到 Android 中。
等待子线程
多线程的时序中往往涉及用锁。用锁(或者带锁实现)不当,就会导致主线程被子线程锁上,产生性能问题。表现为主线程长时间处于睡眠状态,并且与特定子线程运行状态结束的时间点耦合。极端情况下,会发生死锁导致的 ANR 问题。
这种状态的 CPU 使用效率也不高,但往往是个别问题引发。解决方案包括但不限于:提早子线程释放锁的时间;解除一边的锁关系;把主线程的锁转到其他线程;细化锁粒度;先把锁分配给主线程。
CPU 竞争
当进程中其他线程有大量的运行状态,轮转时间片算法就会把更多的 CPU 时间分配给其他线程,导致主线程的 CPU 时间有所降低。这时,CPU 使用效率已达最高,优化只能从优化具体的方法入手。
AOP 埋点
只有在足够的精度范围内至少有一个埋点,才能满足进程分析的需要。对于“精度”有两种理解,其中一种是以方法计量(另一种精度标准是时间,后面会详细讨论)。借助 AOP,可以为每个方法的开始与结束加上埋点。
Android 的 AOP 埋点
参考链接-Transform
Android Studio Gradle Plugin 提供了 Transform 类,用于控制编译流程。通过 Transform 可以接入可以处理 Java 字节码的 ASM(一种 AOP 框架)。ASM 提供了多层次的方法处理类 Visitor,其中 MethodVisitor 的 visitCode 可以处理整个方法的代码,visitInsn 可以处理单条字节码指令。
参考资料中的 3.2.4 节中有个示例,正好介绍了方法开始与结束的埋点方法:通过 visitCode 插入方法开始的埋点;通过检测返回指令与抛出异常的指令,在这些指令前插入方法结束的埋点。
这个方案并不完美,因为任何地方都可能抛出异常(例如除数为 0),一旦方法结束但未经过结束埋点,就会损坏方法调用栈。检测出这类异常,需要在方法结束的时候也传递方法名,这意味着埋点开销加倍,带来的误差不可接受。目前的做法是在发现调用栈发生错误的地方屏蔽相关方法。
为了能通过 systrace 获取数据,埋点使用了 android.os.Trace 的 beginSection(String)与 endSection()方法。最终,通过设计好的 SQL,可以计算每个方法的耗时以及处于各种线程状态时长。重点关注与全局性能问题类型最匹配的一些方法,这时,这段进程的性能问题就简化为少数关键可枚举方法的性能问题。
回避误差
用 AOP 埋好了点,收集的数据显示:最耗时的方法是大量被调用的 getter、setter 以及字符串处理。通过取耗时最高的方法做埋点,就可以验证这个结果是错误的。既然方法本体开销很小,通过排除法就可以得出 Trace 埋点本身的开销是问题根源所在,也就是观测的行为改变了被观测应用的表现。
有办法能解决这个问题吗?答案是肯定的。虽然 Trace 埋点的耗时是必须的,但可以以内存消耗为代价把部分观测开销移出观测过程,降低观察行为的影响。
Trace 埋点性能分析
调用 Trace 埋点的过程很长。长话短说:经过层层调用以后,通过 JNI 调用了 atrace,再经过层层调用,最终调用了 ftrace 的写日志功能,与系统 CPU 调度信息整合在一起。对 JNI 调用、ftrace 执行写入以及整个流程耗时进行比较,发现 JNI 调用最耗时,然后是 ftrace 执行写入,这两项加起来与整个流程耗时几乎相等。
Java 的方法使用非常廉价。编译器与运行时会做方法内联优化(有点像扁平化管理)。所以不用吝惜任何的代码复用机会,也无须顾虑把一个大方法拆成数十个小方法的性能开销。
而 JNI 的开销又可以分为调用本身以及参数从 Java 转为 native 的开销。对比有参数 JNI 与无参数 JNI 调用的时间,确认两者的开销大致相当(JNI 调用略多)。
值得注意的是,Trace 埋点的 endSection()方法没有参数 API,所以也没有参数转换耗时。
启动场景:“托运”方案
当时有位大哥提了一个方案:把方法埋点日志做暂存,CPU 调度、线程状态这些埋点仍然由 ftrace 做。这样,在埋点代码中就避免了 JNI 的调用。结束抓取埋点之后,把方法日志写到文件里,把它与 ftrace 合并起来。这个方案是“托运”的原型。但是,它的直接实现完全失败了。
问题出在了时间戳上。
两个秒表带来的麻烦
100 米跑比赛上,一大群选手(埋点)从不同的跑道(线程)上跑来。裁判(ftrace)坐在终点拿着秒表一个个按,并记录选手的名字和成绩。
我们派了个同学给 ftrace 帮忙:穿蓝衣服的选手(自己的埋点)就不劳您费心了,我来给他们计时吧。两个裁判按完秒表,把时间写在一块儿。结果,根据时间排序的名次对不上实际的名次了。
实践中,我们尝试过做一个埋点然后记录相对时间的做法。但是,在精度需求量级达 1μs(-6 次方秒)的情形下,相对时间的修正仍然失效了。
相对时间修正的失效作为一个现象很难解释。一种可能的原因,是系统时间本质上来源于对元器件的观察,如果器件本身就存在误差,就会导致“相对时间”没有意义。
于是,能够记录时间的就只有 ftrace(因为系统数据应用获取不到)。这时候应该怎么做呢?
改良方案:无参数的开始埋点
带来另一个启发的,是前面提到的 endSection()。这个省下来的参数有一个条件:栈。我们能直接用 Trace 做方法埋点,是因为在同一个线程中,Java 的方法是按栈调用的。在大量调用的条件下,以这个限制换取一半的参数转换开销物有所值。
如果不立刻写入方法名称,而是把它存入一个线程安全的结构中,那么开始埋点就只有一个信息点,即方法开始。这样,就可以把开始埋点也改成无参数的。
向 ftrace 写入日志的时候,ftrace 会记录写入发生的线程号与时间戳。所以,每个线程上的顺序正确就足够准确还原整个日志。实践中,用了一个 ConcurrentHashMap,以线程号为索引存放一个方法名队列。线程号存放在一个 ThreadLocal变量里。
*我挺想用基本类型(比如 int)代替 char[]的,这样能减少对象创建开销。问题出在开发开销上:既要保持线程安全还要避免装箱的 SparseArray 实现与队列实现,以及生成方法编号与编号还原。当时,这个方案的准确度已经够用了,开发这些东西产出很有限。
流畅度场景:分段、丢弃与后台写入
用户交互与启动关注点不同,埋点思路也就不同。启动时应用时满载运行,但交互时页面大多数时候是空闲的,偶尔才有一帧卡顿。如果照用启动的埋点方案,在卡顿问题出现以前,未处理的日志信息就会塞满内存,结果就是页面划了几下就动不了了。
由于在一帧结束以后,就可以判断这帧的信息是否需要,丢弃大量的无用帧信息来降低内存占用是可行的。另外,卡顿现象的本质是主线程负载重导致未能及时响应。转为后台写入可以释放占用的内存,虽然会增加 CPU 开销从而带来误差,但并不像启动场景那样不可接受。这两项构成了流畅度场景的埋点优化方案。
在判断卡顿的地方同样用 AOP 加上回调逻辑:如果卡顿,则保留上次判断卡顿至今的日志,否则转为后台写入。ftrace 侧的日志不能直接舍弃,可以给日志分段标号,通过标号来对齐两侧的日志。为了避免读写同一个缓存发生冲突,将单个缓存扩充为一组可动态扩充的缓存。
目前这个方案在调用栈上偶尔会发生对齐错误,原因未知。
Let's go further
时间精度
除了以方法为精度单位以外,还有一种以时间为精度单位的“埋点”思路:定时记录方法调用栈。时间精度的好处在于:误差能通过控制记录间隔自由决定。泛用性很高,还不像方法精度的做法那么复杂。
但是,有一个关键缺陷使时间精度很难成为主流方案:记录间隔过长时,会出现类似 CAS 的 ABA(看似未变实则变化两次)错误。误差无法控制与预测,就会导致结果可能根本不可靠,而且分析者完全没有感知。相比之下,方法精度很“实诚”,其误差可解释、可估算、可控制的特性就令人感到安心很多。
Android Studio Profiler 是最好的时间精度工具,内置于 Android Studio 而且开 debuggable 就可以使用,而且调用栈还带有系统方法。它对于性能优化项目百废待兴(是的,百废待兴)的工程来说,治理初级性能问题是最合用的。
btrace
参考链接-字节 btrace 2.0
btrace 方案更注重易用性、灵活性,2.0 还加了些 native hook 信息以监控线程创建等信息,做了一些实在的优化。
当然,也有一些设定我持保留态度:
基于高版本的工具,低版本的系统没法用。
激进的减埋点做法。很多小方法往往被高频调用,而高频调用是性能要点的特征,存在的性能问题会被放大。
同步时钟以及 native hook 的做法不总是可靠,有可能引发问题。
重做一个 ftrace 不是很经济。
实践中的应用性能分析
AOP 埋点法是性能分析中重要的一部分,似乎也能直接接管整个性能分析流程,但实践中却不是这样。过度依靠 AOP 埋点不仅消耗人力多,也并不能达到最好的准确度。
AOP 埋点的问题:主次倒置
即使是最理想的情况,只给每个方法加个判断变量值的开销,也可以观测到应用的性能降低。分析要求的精度越高,埋点越多,开销越高,误差就越大,这是不可避免的。
不能排除性能问题发生在任何地方的可能性的时候,要找到性能问题就不能在精度上松懈。因此,误差也就成为了无法忽视的问题。实践中发现:虽然经过优化,带有 JNI 和写入开销的埋点所带来的误差不至于带来“质变”,但仍然会造成“量变”。直到最后验证效果,才发现:治理的“主要问题”只是被误差放大的次要问题。
主次倒置问题有两种有效的解决方案:迭代埋点与定性分析。
迭代埋点
迭代埋点的思路很简单:既然主次倒置是误差引发的,那么降低误差就可以了。而大量埋点中实际上只有一小部分埋点是重要的,只留下这部分埋点就能大幅度削减误差,问题也就迎刃而解。当然,由于一开始并不知道哪些埋点重要,还是要做一次全面埋点,然后再枚举疑似有性能问题的埋点做精确埋点。
迭代埋点的思路虽然可行,但是其缺陷在于更改 AOP 埋点规则必须要重新编译。对于编译一次半小时的大型甚至超级应用来说,这个思路会使整个性能分析过程的人力消耗成倍增长。
一种取巧的变式是尝试预先排除没有性能问题的埋点。然而,能够被严格论证没有性能问题的地方实在是太少了,而任何更进一步的扩大尝试都是在赌博。实践中,一旦发生了排除有性能问题的代码的错误,分析者就会陷入尝试减少误差的陷阱,预设反而会成为思维上的盲区。
定性分析
定性分析的思路不太一样:不减少错误信息,而是找出正确信息。
还记得分段埋点吗?分段的信息效力很弱,分段埋点虽然不能指出方法的名称,但由于其埋点数少,误差低,对问题的定性也就可靠。假如在 AOP 埋点后,从数据中得到主要性能问题发生的范围在甲、乙、丙三段之一,而分段埋点数据显示丙段的性能问题最严重,那主要问题自然要往丙段里找,这样就排除掉了甲乙两段的干扰。
虽然从形式上说,定性分析也依赖于更少的埋点与“重新编译”,但是定性分析的所需的埋点信息是固定、微量的。这意味着可以自动化测试获得这些信息,融于日常的性能监测中,这样就节省了人效。
实践中还会用到以下特征:
线程状态。对各个线程的线程状态进行量化统计。如果主线程的运行状态时长增加,那必然在主线程上存在运行状态时长增加的问题。如果主线程的睡眠状态时长增加,再继续统计子线程状态,如果子线程运行状态时长增加,那较为可能是在子线程上存在问题,否则,必然在主线程上存在睡眠状态时长增加的问题。
集中程度。如果问题发生在一个阶段中,可能是一个主线程上的单次调用;如果问题发生在相连的两个阶段中,可能是一个子线程上的单次调用;如果发生在多个阶段中,可能是一个小方法被多次调用。
改动时间。对于定期进行的自动化测试,性能问题只发生于最新一次。通过这一点,可以假设此前的代码没有性能问题,通过查看期间发生的集成来缩小问题寻找范围。需要注意一种特殊情况:如果是“解开封印”式的代码,其本体并不是性能问题,但它是导致性能问题发生的直接原因。
小结
比具体做法更重要的,是这个具体做法是怎么来的,正如人言:“授人以鱼不如授人以渔”。
就性能分析这项任务出发,以埋点为核心,进一步分解为三个做法:
通过埋点加到细节上,提高精度
通过埋点定制化,降低误差
通过分层埋点与整合,精准识别问题
这些做法似乎在所有本质上是“性能分析”的事务都有一定的可行性。也许,不仅在Android,甚至iOS,甚至在编程以外的什么地方,也能够这样做性能分析?