对用户来说,内存占用高、耗费电量、耗费流量 可能不容易被发现,但是用户对 卡顿 特别敏感,很容易直观感受到。
注意:如果出现图片不显示问题请移步 图片不显示问题
本文主要记载
- 1 如何定义卡顿?
- 2 如何排查卡顿?
- 2.1 Linux 命令组合排查
- 2.2 使用 top 命令查看进程信息
- 3 有没有方便的 Android 卡顿排查工具?
- 3.1 TraceView
- 3.2 Nanoscope
- 3.3 systrace
- 3.4 Simpleperf
- 3.5 可视化方法(Android Studio Profiler)
- 3.6 Android Performance Montitor(BlockCanary)
- 3.7 Profilo
- 4 卡顿现场
- 4.1 获取 java 线程状态
- 4.2 获得所有线程堆栈
- 4.3 SIGQUIT 信号实现
- 4.4 Hook 实现
- 5 相关 Demo 学习
- 5.1 抓取 CPU 数据
- 5.2 PLTHook 监控 Thread 的创建
- 5.3 Loop 监控卡顿
- 总结
1 如何定义卡顿?
60帧 每秒是目前最合适的图像显示速度,也是绝大部分 Android 设备设置的调试频率,如果在 16ms 内顺利完成界面刷新操作可以展示出流畅的画面,而由于任何原因导致接收到 VSYNC 信号的时候无法完成本次刷新操作,就会产生掉帧的现象,刷新帧率自然也就跟着下降(假定刷新帧率由正常的 60fps 降到 30fps ,用户就会明显感知到卡顿)。
卡顿的原因可以定义为 UI 复杂度问题 ,没有提前或异步初始化问题 , 内存泄漏频繁 GC 的问题 等,卡顿的解决思路是集结了 启动优化、内存优化、UI 优化 的所有知识点,所以这篇也是对之前的扩展补充。
造成卡顿的原因可能有千百种,不过最终都会反映到 CPU 时间上。
我们可以把 CPU 时间分为两种:
- 用户时间:执行用户态应用程序代码所消耗的时间
- 系统时间:执行内核态系统调用所消耗的时间,包括 I/O、锁、中断以及其他系统调用的时间。
2 如何排查卡顿
2.1 Linux 命令组合排查
- 获取
CPU核心数
1 | cat /sys/devices/system/cpu/possible |
- 获取某个
CPU的频率
1 | cat /sys/devices/system/cpu/cpu0/cpufreq/cpuinfo_max_freq |

我们需要根据设备 CPU 性能来 “看菜下饭” ,例如线程池使用线程数根据 CPU 的核心数,一些高级的 AI 功能只在主频比较高或者带有 NPU 的设备开启。
- 查看
CPU的使用率
整个系统的 CPU 使用情况
1 | cat /proc/stat |
结果:
1 | >adb shell |
第一行的数据表示的是 CPU 总的使用情况
这些数值的单位都是
jiffies。jiffies是内核中的一个全局变量,用来记录系统启动以来产生的节拍数,在Linux中,一个节拍大致可以理解为操作系统进程调度的最小时间片,不同的Linux系统内核这个值可能不同,通常在1ms到10ms之间。cpu 166492 36473 149538 7550789 34852 0 9110 0 0 0
user(166492) 从系统启动开始累积到当前时刻,处于用户态的运行时间,不包含 nice 值为负的进程。nice(36473) 从系统启动开始累积到当前时刻,nice 值为负的进程所占用的 CPU 时间。system(149538) 从系统启动开始累积到当前时刻,处于核心态的运行时间。idle(7550789) 从系统启动开始累积到当前时刻,除 IO 等待时间以外的其他等待时间。iowait(34852) 从系统启动开始累积到当前时刻,IO 等待时间。(since 2.5.41)irq(0) 从系统启动开始累积到当前时刻,硬中断时间。(since 2.6.0-test4)softirq(9110) 从系统启动开始累积到当前时刻,软中断时间。(since 2.6.0-test4)stealstolen(0) 这是在虚拟环境中运行时在其他操作系统中花费的时间。(since 2.6.11)guest(0) 这是运行 Linux 内核控制下的来宾操作系统的虚拟 CPU 所花费的时间。(since 2.6.24)guest_nice(0) 运行 niced 客户端的时间( Linux 内核控制下的客户操作系统的虚拟CPU)。(since Linux 2.6.33)
具体使用率计算请查看 《Linux环境下进程的CPU占用率》 还是有点麻烦的 ~
2.2 使用 top 命令查看进程信息
直接输入
top可查看所有进程的cpu使用情况几个常用的参数:
-d: 后面接秒数,就是整个进程画面更新的频率。默认是 5 秒。-b: 以批处理的方式执行 top,还有更多的参数可用。通常会搭配数据流重导向,将批处理的结果输出为文件。-n: 与 -b 搭配,意义是,需要进行几次 top 的输出结果。-p: 指定某个 PID 来进行观察监测。- 在 top 执行过程中可以使用的按键命令:
?: 显示在 top 中可以输入的按键命令。P: 按照 CPU 的使用资源排序显示。M: 按内存(Memory)的使用资源排序显示。N: 按 PID 来排序。T: 按该进程使用的 CPU 时间积累(TIME+)排序。k: 给某个 PID 一个信号(signal)。r: 给某个 PID 重新确定一个值。1: 显示所有 CPU 占用信息。
监测进程 13620
1 | top -d 2 -p 13620 |
会一直输出进程 13620 的信息
1 | top - 16:27:35 up 4 days, 7:43, 2 users, load average: 0.35, 0.47, 0.44 |
3 有没有方便的 Android 卡顿排查工具?
日常开发中比较熟悉的工具分为两个流派:
instrument 流派
获取一段时间内所有函数的调用过程,可以通过分析这段时间内的函数调用流程,再进一步分析待优化的点。
sample 流派
有选择性或者采用抽样的方式观察某些函数调用过程,可以通过这些有限的信息推测出流程中的可疑点,然后再继续细化分析。
3.1 Traceview
我在 《App 初体验-启动优化 2.2.1 TraceView》 中讲过 TraceView 的基本使用方法。Traceview 属于 instrument 类型,它利用 Android Runtime 函数调用的 event 事件,将函数运行的耗时和调用关系写入 trace 文件中,它可以用来查看整个过程有哪些函数调用。
工具本身带来的性能开销过大,有时无法反映真实的情况。在 Android 5.0 之后,新增了startMethodTracingSampling 方法,可以使用基于样本的方式进行分析,以减少分析对运行时的性能影响。新增了 sample 类型后,就需要我们在开销和信息丰富度之间做好权衡。
3.2 Nanoscope
《App 初体验-启动优化 2.2.4 Nanoscope》 查看原理即项目地址。它是在 instrument 类型的性能分析工具中性能损耗比较小的。
3.3 systrace
《App 初体验-启动优化 2.2.2 systrace》 查看具体使用方法。其中还讲到了 systrace + 函数插桩 AOP
systrace 工具只能监控特定系统调用的耗时情况,所以它是属于 sample 类型,而且性能开销非常低。但是它不支持应用程序代码的耗时分析,所以在使用时有一些局限性。
3.4 Simpleperf
分析 Native 函数时使用的工具,在 Android Studio 3.2 也在 Profiler 中直接支持 Simpleperf。
Simpleperf 属于 sample 类型,它的性能开销非常低,使用火焰图展示分析结果。
总结:
选择哪种工具,需要看具体的场景。如果需要分析 Native 代码的耗时,可以选择 Simpleperf;如果想分析系统调用,可以选择 systrace;如果想分析整个程序执行流程的耗时,可以选择 Traceview 或者插桩版本的 systrace。systrace 利用了 Linux 的 ftrace 调试工具,相当于在系统各个关键位置都添加了一些性能探针,也就是在代码里加了一些性能监控的埋点。Android 在 ftrace 的基础上封装了atrace,并增加了更多特有的探针,例如 Graphics、Activity Manager、Dalvik VM、System Server 等。
3.5 可视化方法(Android Studio Profiler)
在 Android Studio 3.2 的 CPU Profiler 中直接集成了几种性能分析工具
- Sample Java Methods 的功能类似于
Traceview的sample类型。 - Trace Java Methods 的功能类似于
Traceview的instrument类型。 - Trace System Calls 的功能类似于
systrace。 - SampleNative (API Level 26+) 的功能类似于
Simpleperf。
这些分析工具都支持了 Call Chart 和 Flame Chart 两种展示方式。
Call Chart
Call Chart是Traceview和systrace默认使用的展示方式。它按照应用程序的函数执行顺序来展示,适合用于分析整个流程的调用。举一个最简单的例子,A 函数调用 B 函数,B 函数调用 C 函数,循环三次,就得到了下面的Call Chart。
Call Chart就像给应用程序做一个心电图,我们可以看到在这一段时间内,各个线程的具体工作,比如是否存在线程间的锁、主线程是否存在长时间的I/O操作、是否存在空闲等。Flame Chart
Flame Chart 也就是火焰图。它跟 Call Chart 不同的是,Flame Chart 以一个全局的视野来看待一段时间的调用分布,它就像给应用程序拍 X 光片,可以很自然地把时间和空间两个维度上的信息融合在一张图上。
上面函数调用的例子,换成火焰图的展示结果如下。

当我们不想知道应用程序的整个调用流程,只想直观看出哪些代码路径花费的 CPU 时间较多时,火焰图就是一个非常好的选择。
火焰图还可以使用在各种各样的维度,例如内存、I/O 的分析。有些内存可能非常缓慢地泄漏,通过一个内存的火焰图,我们就知道哪些路径申请的内存最多,有了火焰图我们根本不需要分析源代码,也不需要分析整个流程。
3.6 Android Performance Monitor(BlockCanary)
AndroidPerformanceMonitor 是一个Android平台的一个非侵入式的性能监控组件,应用只需要实现一个抽象类,提供一些该组件需要的上下文环境,就可以在平时使用应用的时候检测主线程上的各种卡慢问题,并通过组件提供的各种信息分析出原因并进行修复。
3.7 Profilo
2018 年 3 月,Facebook 开源了一个叫 Profilo 的库, 它收集了各大方案的优点。
集成 atrace 功能。
这样所有
systrace的探针我们都可以拿到,例如四大组件生命周期、锁等待时间、类校验、GC时间等。快速获取 Java 堆栈
这里有一个误区,大家都觉得在某个线程不断地获取主线程堆栈是不耗时的。但是事实上获取堆栈的代价是巨大的,它要暂停主线程的运行。
而
profilo巧妙的解决的这个问题,可以实现线程一边继续跑步,我们还可以帮它做检查,而且耗时基本忽略不计。
不用插桩、性能基本没有影响、捕捉信息还全,那 Profilo 不就是完美的化身吗?当然由于它利用了大量的黑科技,兼容性是需要注意的问题。它内部实现有大量函数的 Hook,unwind 也需要强依赖 Android Runtime 实现。Facebook 已经将 Profilo 投入到线上使用,但由于目前 Profilo 快速获取堆栈功能依然不支持 Android 8.0 和 Android 9.0,鉴于稳定性问题,建议采取抽样部分用户的方式来开启该功能。
帮助理解
每个工具都可以生成不同的展示方式,我们需要根据不同的使用场景选择合适的方式。
4 卡顿现场
4.1 获取 java 线程状态
通过 Thread 的 getState 方法可以获取线程状态,WAITING、TIME_WAITING 和 BLOCKED 都是需要特别注意的状态。
BLOCKED: 是指线程正在等待获取锁,对应的是下面代码中的情况一;
WAITING: 是指线程正在等待其他线程的“唤醒动作”,对应的是代码中的情况二。
1 | synchronized (object) { // 情况一:在这里卡住 --> BLOCKED |
不过当一个线程进入 WAITING 状态时,它不仅会释放 CPU 资源,还会将持有的 object 锁也同时释放。
更多相关资料 《Java 线程 Dump 分析》
4.2 获得所有线程堆栈
当我们发现有个线程导致主线程 BLOCKED ,需要通过 Thread.getAllStackTraces() 拿所有线程的堆栈,需要注意的是在 Android 7.0,getAllStackTraces是不会返回主线程的堆栈的。
4.4 SIGQUIT 信号实现
注:需要 root
Android 应用发生 ANR 时,系统会发出 SIGQUIT 信号给发生 ANR 进程。系统信号捕捉线程触发输出/data/anr/traces.txt 文件,记录问题产生虚拟机、线程堆栈相关信息。这个 trace 文件中包含了线程信息和锁的信息,借助这个 trace 文件可以分析卡死的原因。
由此,如果利用这个系统原有的机制,自己在线程卡死时候触发traces文件的形成进行上报,便可以把线程卡死的关键进行进行上报。本监控方案便是利用系统机制进行卡死信息的抓取:
当监控线程发现被监控线程卡死时,主动向系统发送
SIGQUIT信号。等待
/data/anr/traces.txt文件生成。文件生成以后进行上报。
分析:
1 | // 线程名称; 优先级; 线程id; 线程状态 |
其中 utm 代表 utime,HZ 代表 CPU 的时钟频率,将 utime 转换为毫秒的公式是 time * 1000/HZ。例子中 utm=218,也就是 218*1000/100=2180 毫秒。
4.5 Hook 实现
用 SIGQUIT 信号量获取 ANR 日志,从而拿到所有线程的各种信息,这套方案看起来很美好。但事实上,它存在这几个问题:
- 可行性。 高版本系统已经没有权限读取
/data/anr/traces.txt文件。需要root手机 - 性能。获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。
Android trace文件抓取原理 Android 平台 Native 代码的崩溃捕获机制及实现
能力有限,还在研究中。。
思路: hook libart.so 。通过hook ThreadList 和 Thread 的函数,获得跟 ANR 一样的堆栈。为了稳定性,我们会在 fork 子进程执行。
优点:信息很全,基本跟 ANR 的日志一样,有 native 线程状态,锁信息等等。
缺点:黑科技的兼容性问题,失败时可以用 Thread.getAllStackTraces() 兜底
获取Java堆栈的方法还可以用在卡顿时,因为使用fork进程,所以可以做到完全不卡主进程。
Breakpad 使用了 fork 子进程甚至孙进程的方式去收集崩溃现场,即便出现二次崩溃,也只是这部分信息丢失。
5 Demo 相关 Demo 学习
5.1 抓取 CPU 数据
模仿 ProcessCpuTracker.java 拿到一段时间内各个线程的耗时占比
示例的日志数据:
1 | usage: CPU usage 5000ms(from 23:23:33.000 to 23:23:38.000): |
- 在
System Total部分user占用不多,CPU idle很高,消耗多在kernel和iowait。 CPU是 8 核的,Load Average大约也是 8,表示CPU并不处于高负载情况。- 在
Process里展示了这段时间内sample app的CPU使用情况:user低,kernel高,并且有4965次page faults。page faluts分为三种:minor page fault、major page fault和invalid page fault
- 在
Threads里展示了每个线程的usage情况,当前只有singleThread处于R状态,并且当前线程产生了 3096 次page faults,其他的线程包括主线程(Sample日志里可见的)都是处于S状态。R:代表线程处于Running或者Runnable状态。Running状态说明线程当前被某个Core执行,Runnable状态说明线程当前正在处于等待队列中等待某个Core空闲下来去执行。S:TASK_INTERRUPTIBLE(可中断)发生这种状态是线程主动让出了CPU,如果线程调用了sleep或者其他情况导致了自愿式的上下文切换就会处于S状态。
Demo 中执行 Test 抓取数据如下:
1 | CPU usage from 5137ms to 81ms ago (2020-05-13 21:57:32.764 to 2020-05-13 21:57:37.819): |
这里不太清楚为什么后面的数据都是 0 ,最后应该输出 iowait 才对。
极客时间原文例子
1 | CPU usage from 5187ms to 121ms ago (2018-12-28 08:28:27.186 to 2018-12-28 08:28:32.252): |
如果有大佬知道原因,还请告知。
如果产生大量的 faults 其实是不太正常的,或者 iowait 过高就需要关注是否有很密集的 I/O 操作。
5.2 PLTHook 监控 Thread 的创建
Chapter06-plus 这个 Demo 与 Android线程的创建过程 结合,了解 PLTHook 的使用,以及 Thread 状态知识、如何创建的。
5.3 Loop 监控卡顿
在 Android UI 线程中有个 Looper,在其 loop 方法中会不断取出 Message,调用其绑定的 Handler 在 UI 线程进行执行。
部分代码如下:
1 | public static void loop() { |
可以看到在执行此代码前后,如果设置了 logging,会分别打印出 >>>>> Dispatching to和 <<<<< Finished to 这样的log 。
我们可以通过计算两次 log 之间的时间差值
1 | Looper.getMainLooper().setMessageLogging(new Printer() { |
假设我们的阈值是 1000ms,当我在匹配到 >>>>> Dispatching 时,我会在 1000ms 毫秒后执行一个任务(打印出 UI 线程的堆栈信息,会在非 UI 线程中进行);
正常情况下,肯定是低于 1000ms 执行完成的,所以当我匹配到 <<<<< Finished ,会移除该任务。
1 | public class LogMonitor { |
我们利用了 HandlerThread 这个类,同样利用了 Looper 机制,只不过在非UI线程中,如果执行耗时达到我们设置的阈值,则会执行 mLogRunnable ,打印出 UI 线程当前的堆栈信息;如果你阈值时间之内完成,则会 remove 掉该 runnable。
代码修改自于 Android UI性能优化 检测应用中的UI卡顿 修改了 hasCallbacks 调用方式
这个方法的缺点: 大量字符串拼接导致性能损耗严重,快速滑动时会降低帧数。
- 消息队列
可以通过一个监控线程,每隔 1 秒向主线程消息队列的头部插入一条空消息。假设 1 秒后这个消息并没有被主线程消费掉,说明阻塞消息运行的时间在 0~1 秒之间。换句话说,如果我们需要监控 3 秒卡顿,那在第 4 次轮询中头部消息依然没有被消费的话,就可以确定主线程出现了一次 3 秒以上的卡顿。

这个方案也存在一定的误差,那就是发送空消息的间隔时间。但这个间隔时间也不能太小,因为监控线程和主线程处理空消息都会带来一些性能损耗,但基本影响不大。
这个方法的缺点: 基于消息队列的卡顿监控并不准确,正在运行的函数有可能并不是真正耗时的函数。
- 插桩
避免方法数暴增。在函数的入口和出口应该插入相同的函数,在编译时提前给代码中每个方法分配一个独立的 ID 作为参数。
过滤简单的函数。过滤一些类似直接 return、i++ 这样的简单函数,并且支持黑名单配置。对一些调用非常频繁的函数,需要添加到黑名单中来降低整个方案对性能的损耗。
基于性能的考虑,线上只会监控主线程的耗时。最终安装包体积只增大 1~2%,平均帧率下降也在 2 帧以内。
插桩方案看起来美好,它也有自己的短板,那就是只能监控应用内自身的函数耗时,无法监控系统的函数调用,整个堆栈看起来好像 “缺失了” 一部分。
感谢
以及上文中的链接







