浅谈应用性能分析

前言

最轻松的性能分析与优化只需要针对一个简单方法:

Java 复制代码
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 方法耗时有点长了,仔细研究这两行代码,可以再优化一下:

Java 复制代码
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<char[]*, ConcurrentLinkedQueue<char[]>>,以线程号为索引存放一个方法名队列。线程号存放在一个 ThreadLocal<char[]>变量里。
*我挺想用基本类型(比如 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 信息以监控线程创建等信息,做了一些实在的优化。

当然,也有一些设定我持保留态度:

  1. 基于高版本的工具,低版本的系统没法用。

  2. 激进的减埋点做法。很多小方法往往被高频调用,而高频调用是性能要点的特征,存在的性能问题会被放大。

  3. 同步时钟以及 native hook 的做法不总是可靠,有可能引发问题。

  4. 重做一个 ftrace 不是很经济。

实践中的应用性能分析

AOP 埋点法是性能分析中重要的一部分,似乎也能直接接管整个性能分析流程,但实践中却不是这样。过度依靠 AOP 埋点不仅消耗人力多,也并不能达到最好的准确度。

AOP 埋点的问题:主次倒置

即使是最理想的情况,只给每个方法加个判断变量值的开销,也可以观测到应用的性能降低。分析要求的精度越高,埋点越多,开销越高,误差就越大,这是不可避免的。

不能排除性能问题发生在任何地方的可能性的时候,要找到性能问题就不能在精度上松懈。因此,误差也就成为了无法忽视的问题。实践中发现:虽然经过优化,带有 JNI 和写入开销的埋点所带来的误差不至于带来"质变",但仍然会造成"量变"。直到最后验证效果,才发现:治理的"主要问题"只是被误差放大的次要问题。

主次倒置问题有两种有效的解决方案:迭代埋点与定性分析。

迭代埋点

迭代埋点的思路很简单:既然主次倒置是误差引发的,那么降低误差就可以了。而大量埋点中实际上只有一小部分埋点是重要的,只留下这部分埋点就能大幅度削减误差,问题也就迎刃而解。当然,由于一开始并不知道哪些埋点重要,还是要做一次全面埋点,然后再枚举疑似有性能问题的埋点做精确埋点。

迭代埋点的思路虽然可行,但是其缺陷在于更改 AOP 埋点规则必须要重新编译。对于编译一次半小时的大型甚至超级应用来说,这个思路会使整个性能分析过程的人力消耗成倍增长。

一种取巧的变式是尝试预先排除没有性能问题的埋点。然而,能够被严格论证没有性能问题的地方实在是太少了,而任何更进一步的扩大尝试都是在赌博。实践中,一旦发生了排除有性能问题的代码的错误,分析者就会陷入尝试减少误差的陷阱,预设反而会成为思维上的盲区。

定性分析

定性分析的思路不太一样:不减少错误信息,而是找出正确信息。

还记得分段埋点吗?分段的信息效力很弱,分段埋点虽然不能指出方法的名称,但由于其埋点数少,误差低,对问题的定性也就可靠。假如在 AOP 埋点后,从数据中得到主要性能问题发生的范围在甲、乙、丙三段之一,而分段埋点数据显示丙段的性能问题最严重,那主要问题自然要往丙段里找,这样就排除掉了甲乙两段的干扰。

虽然从形式上说,定性分析也依赖于更少的埋点与"重新编译",但是定性分析的所需的埋点信息是固定、微量的。这意味着可以自动化测试获得这些信息,融于日常的性能监测中,这样就节省了人效。

实践中还会用到以下特征:

  1. 线程状态。对各个线程的线程状态进行量化统计。如果主线程的运行状态时长增加,那必然在主线程上存在运行状态时长增加的问题。如果主线程的睡眠状态时长增加,再继续统计子线程状态,如果子线程运行状态时长增加,那较为可能是在子线程上存在问题,否则,必然在主线程上存在睡眠状态时长增加的问题。

  2. 集中程度。如果问题发生在一个阶段中,可能是一个主线程上的单次调用;如果问题发生在相连的两个阶段中,可能是一个子线程上的单次调用;如果发生在多个阶段中,可能是一个小方法被多次调用。

  3. 改动时间。对于定期进行的自动化测试,性能问题只发生于最新一次。通过这一点,可以假设此前的代码没有性能问题,通过查看期间发生的集成来缩小问题寻找范围。需要注意一种特殊情况:如果是"解开封印"式的代码,其本体并不是性能问题,但它是导致性能问题发生的直接原因。


小结

比具体做法更重要的,是这个具体做法是怎么来的,正如人言:"授人以鱼不如授人以渔"。

就性能分析这项任务出发,以埋点为核心,进一步分解为三个做法:

  1. 通过埋点加到细节上,提高精度

  2. 通过埋点定制化,降低误差

  3. 通过分层埋点与整合,精准识别问题

这些做法似乎在所有本质上是"性能分析"的事务都有一定的可行性。也许,不仅在Android,甚至iOS,甚至在编程以外的什么地方,也能够这样做性能分析?

相关推荐
安卓理事人4 小时前
安卓LinkedBlockingQueue消息队列
android
万能的小裴同学5 小时前
Android M3U8视频播放器
android·音视频
q***57746 小时前
MySql的慢查询(慢日志)
android·mysql·adb
JavaNoober6 小时前
Android 前台服务 "Bad Notification" 崩溃机制分析文档
android
城东米粉儿7 小时前
关于ObjectAnimator
android
zhangphil8 小时前
Android渲染线程Render Thread的RenderNode与DisplayList,引用Bitmap及Open GL纹理上传GPU
android
火柴就是我9 小时前
从头写一个自己的app
android·前端·flutter
lichong95110 小时前
XLog debug 开启打印日志,release 关闭打印日志
android·java·前端
用户693717500138410 小时前
14.Kotlin 类:类的形态(一):抽象类 (Abstract Class)
android·后端·kotlin
火柴就是我10 小时前
NekoBoxForAndroid 编译libcore.aar
android