gitbook/Android开发高手课/docs/73277.md
2022-09-03 22:05:03 +08:00

16 KiB
Raw Permalink Blame History

06补充篇 | 卡顿优化:卡顿现场与卡顿分析

我们使用上一期所讲的插桩或者Profilo的方案可以得到卡顿过程所有运行函数的耗时。在大部分情况下这几种方案的确非常好用可以让我们更加明确真正的卡顿点在哪里。

但是你肯定还遇到过很多莫名其妙的卡顿比如读取1KB的文件、读取很小的asset资源或者只是简单的创建一个目录。

为什么看起来这么简单的操作也会耗费那么长的时间呢?那我们如何通过收集更加丰富的卡顿现场信息,进一步定位并排查问题呢?

卡顿现场

我先来举一个线上曾经发现的卡顿例子,下面是它的具体耗时信息。

从图上看Activity的onCreate函数耗时达到3秒而其中Lottie动画中openNonAsset函数耗时竟然将近2秒。尽管是读取一个30KB的资源文件但是它的耗时真的会有那么长吗

今天我们就一起来分析这个问题吧。

1. Java实现

进一步分析openNonAsset相关源码的时候发现AssetManager内部有大量的synchronized锁。首先我怀疑还是锁的问题接下来需要把卡顿时各个线程的状态以及堆栈收集起来做进一步分析。

步骤一获得Java线程状态

通过Thread的getState方法可以获取线程状态当时主线程果然是BLOCKED状态。

什么是BLOCKED状态呢当线程无法获取下面代码中的object对象锁的时候线程就会进入BLOCKED状态。

// 线程等待获取object对象锁
synchronized (object)  {
    dosomething();
}  

**WAITING、TIME_WAITING和BLOCKED都是需要特别注意的状态。**很多同学可能对BLOCKED和WAITING这两种状态感到比较困惑BLOCKED是指线程正在等待获取锁对应的是下面代码中的情况一WAITING是指线程正在等待其他线程的“唤醒动作”对应的是代码中的情况二。

synchronized (object)  {     // 情况一:在这里卡住 --> BLOCKED
    object.wait();           // 情况二:在这里卡住 --> WAITING
}  

不过当一个线程进入WAITING状态时它不仅会释放CPU资源还会将持有的object锁也同时释放。对Java各个线程状态的定义以及转换等更多介绍你可以参考Thread.State《Java线程Dump分析》

步骤二:获得所有线程堆栈

接着我们在Java层通过Thread.getAllStackTraces()进一步拿所有线程的堆栈希望知道具体是因为哪个线程导致主线程的BLOCKED。

需要注意的是在Android 7.0getAllStackTraces是不会返回主线程的堆栈的。通过分析收集上来的卡顿日志我们发现跟AssetManager相关的线程有下面这个。

"BackgroundHandler"  RUNNABLE
  at  android.content.res.AssetManager.list
  at  com.sample.business.init.listZipFiles

通过查看AssetManager.list的确发现是使用了同一个synchronized锁而list函数需要遍历整个目录耗时会比较久。

public String[] list(String path) throws IOException {
  synchronized (this) {
    ensureValidLocked();
    return nativeList(mObject, path);
  }
}

另外一方面“BackgroundHandler”线程属于低优先级后台线程这也是我们前面文章提到的不良现象也就是主线程等待低优先级的后台线程。

2. SIGQUIT信号实现

Java实现的方案看起来非常不错也帮助我们发现了卡顿的原因。不过在我们印象中似乎ANR日志的信息更加丰富那我们能不能直接用ANR日志呢

比如下面的例子它的信息的确非常全所有线程的状态、CPU时间片、优先级、堆栈和锁的信息应有尽有。其中utm代表utimeHZ代表CPU的时钟频率将utime转换为毫秒的公式是“time * 1000/HZ”。例子中utm=218也就是218*1000/100=2180毫秒。

// 线程名称; 优先级; 线程id; 线程状态
"main" prio=5 tid=1 Suspended
  // 线程组;  线程suspend计数; 线程debug suspend计数; 
  | group="main" sCount=1 dsCount=0 obj=0x74746000 self=0xf4827400
  // 线程native id; 进程优先级; 调度者优先级;
  | sysTid=28661 nice=-4 cgrp=default sched=0/0 handle=0xf72cbbec
  // native线程状态; 调度者状态; 用户时间utime; 系统时间stime; 调度的CPU
  | state=D schedstat=( 3137222937 94427228 5819 ) utm=218 stm=95 core=2 HZ=100
  // stack相关信息
  | stack=0xff717000-0xff719000 stackSize=8MB

疑问一Native线程状态

细心的你可能会发现为什么上面的ANR日志中“main”线程的状态是Suspended想了一下Java线程中的6种状态中并不存在Suspended状态啊。

事实上Suspended代表的是Native线程状态。怎么理解呢在Android里面Java线程的运行都委托于一个Linux标准线程pthread来运行而Android里运行的线程可以分成两种一种是Attach到虚拟机的一种是没有Attach到虚拟机的在虚拟机管理的线程都是托管的线程所以本质上Java线程的状态其实是Native线程的一种映射。

不同的Android版本Native线程的状态不太一样例如Android 9.0就定义了27种线程状态它能更加明确地区分线程当前所处的情况。关于Java线程状态、Native线程状态转换你可以参考thread_state.hThread_nativeGetStatus

我们可以看到Native线程状态的确更加丰富例如将TIMED_WAITING拆分成TimedWaiting和Sleeping两种场景而WAITING更是细化到十几种场景等这对我们分析特定场景问题的时候会有非常大的帮助。

疑问二获得ANR日志

虽然ANR日志信息非常丰富那问题又来了如何拿到卡顿时的ANR日志呢

我们可以利用系统ANR的生成机制具体步骤是

第一步当监控到主线程卡顿时主动向系统发送SIGQUIT信号。

第二步:等待/data/anr/traces.txt文件生成。

第三步:文件生成以后进行上报。

通过ANR日志我们可以直接看到主线程的锁是由“BackgroundHandler”线程持有。相比之下通过getAllStackTraces方法我们只能通过一个一个线程进行猜测。

  // 堆栈相关信息
  at android.content.res.AssetManager.open(AssetManager.java:311)
  - waiting to lock <0x41ddc798> (android.content.res.AssetManager) held by tid=66 (BackgroundHandler)
  at android.content.res.AssetManager.open(AssetManager.java:289)

线程间的死锁和热锁分析是一个非常有意思的话题很多情况分析起来也比较困难例如我们只能拿到Java代码中使用的锁而且有部分类型锁的持有并不会表现在堆栈上面。对这部分内容感兴趣想再深入一下的同学可以认真看一下这两篇文章《Java线程Dump分析》《手Q Android线程死锁监控与自动化分析实践》

3. Hook实现

用SIGQUIT信号量获取ANR日志从而拿到所有线程的各种信息这套方案看起来很美好。但事实上它存在这几个问题

  • 可行性。正如我在崩溃分析所说的一样,很多高版本系统已经没有权限读取/data/anr/traces.txt文件。

  • 性能。获取所有线程堆栈以及各种信息非常耗时,对于卡顿场景不一定合适,它可能会进一步加剧用户的卡顿。

那有什么方法既可以拿到ANR日志整个过程又不会影响用户的体验呢

再回想一下,在崩溃分析的时候我们就讲过一种获得所有线程堆栈的方法。它通过下面几个步骤实现。

它基本模拟了系统打印ANR日志的流程但是因为整个过程使用了一些黑科技可能会造成线上崩溃。

为了兼容性考虑我们会通过fork子进程方式实现这样即使子进程崩溃了也不会影响我们主进程的运行。这样还可以带来另外一个非常大的好处,获取所有线程堆栈这个过程可以做到完全不卡我们主进程。

但使用fork进程会导致进程号改变源码中通过/proc/self方式获取的一些信息都会失败错误的拿了子进程的信息,而子进程只有一个线程例如state、schedstat、utm、stm、core等。不过问题也不大这些信息可以通过指定/proc/[父进程id]的方式重新获取。

"main" prio=7 tid=1 Native
  | group="" sCount=0 dsCount=0 obj=0x74e99000 self=0xb8811080
  | sysTid=23023 nice=-4 cgrp=default sched=0/0 handle=0xb6fccbec
  | state=? schedstat=( 0 0 0 ) utm=0 stm=0 core=0 HZ=100
  | stack=0xbe4dd000-0xbe4df000 stackSize=8MB
  | held mutexes=

总的来说通过Hook方式我们实现了一套“无损”获取所有Java线程堆栈与详细信息的方法。为了降低上报数据量只有主线程的Java线程状态是WAITING、TIME_WAITING或者BLOCKED的时候才会进一步使用这个“大杀器”。

4. 现场信息

现在再来看这样一份我们自己构造的“ANR日志”是不是已经是收集崩溃现场信息的完全体了它似乎缺少了我们常见的头部信息例如进程CPU使用率、GC相关的信息。

正如第6期文章开头所说的一样卡顿跟崩溃一样是需要“现场信息”的。能不能进一步让卡顿的“现场信息”的比系统ANR日志更加丰富我们可以进一步增加这些信息

  • CPU使用率和调度信息。参考第5期的课后练习我们可以得到系统CPU使用率、负载、各线程的CPU使用率以及I/O调度等信息。

  • 内存相关信息。我们可以添加系统总内存、可用内存以及应用各个进程的内存等信息。如果开启了Debug.startAllocCounting或者atrace还可以增加GC相关的信息。

  • I/O和网络相关。我们还可以把卡顿期间所有的I/O和网络操作的详细信息也一并收集这部分内容会在后面进一步展开。

在Android 8.0后Android虚拟机终于支持了JVM的JVMTI机制。Profiler中内存采集等很多模块也切换到这个机制中实现后面我会邀请“学习委员”鹏飞给你讲讲JVMTI机制与应用。使用它可以获得的信息非常丰富包括内存申请、线程创建、类加载、GC等有大量的应用场景。

最后我们还可以利用崩溃分析中的一些思路例如添加用户操作路径等信息这样我们可以得到一份比系统ANR更加丰富的卡顿日志这对我们解决某些疑难的卡顿问题会更有帮助。

卡顿分析

在客户端捕获卡顿之后,最后数据需要上传到后台统一分析。我们可以对数据做什么样的处理?应该关注哪些指标?

1. 卡顿率

如果把主线程卡顿超过3秒定义为一个卡顿问题类似崩溃我们会先评估卡顿问题的影响面也就是UV卡顿率。

UV 卡顿率 = 发生过卡顿 UV / 开启卡顿采集 UV

因为卡顿问题一般都是抽样上报,采样规则跟内存相似,都应该按照人来抽样。一个用户如果命中采集,那么在一天内都会持续的采集数据。

UV卡顿率可以评估卡顿的影响范围但对于低端机器来说比较难去优化卡顿的问题。如果想评估卡顿的严重程度我们可以使用PV卡顿率。

PV 卡顿率 = 发生过卡顿 PV / 启动采集 PV

需要注意的是对于命中采集PV卡顿率的用户每次启动都需要上报作为分母。

2. 卡顿树

发生卡顿时我们会把CPU使用率和负载相关信息也添加到卡顿日志中。虽然采取了抽样策略但每天的日志量还是达到十万级别。这么大的日志量如果简单采用堆栈聚合日志会发现有几百上千种卡顿类型很难看出重点。

我们能不能实现卡顿的火焰图,在一张图里就可以看到卡顿的整体信息?

这里我非常推荐卡顿树的做法对于超过3秒的卡顿具体是4秒还是10秒这涉及手机性能和当时的环境。我们决定抛弃具体的耗时只按照相同堆栈出现的比例来聚合。这样我们从一棵树上面就可以看到哪些堆栈出现的卡顿问题最多它下面又存在的哪些分支。

我们的精力是有限的一般会优先去解决Top的卡顿问题。采用卡顿树的聚合方式可以从全盘的角度看到Top卡顿问题的各个分支情况帮助我们快速找到关键的卡顿点。

总结

今天我们从一个简单的卡顿问题出发一步一步演进出解决这个问题的三种思路。其中Java实现的方案是大部分同学首先想到的方案它虽然简单稳定不过存在信息不全、性能差等问题。

可能很多同学认为问题可以解决就算万事大吉了,但我并不这样认为。我们应该继续敲问自己,如果再出现类似的问题,我们是否也可以采用相同的方法去解决?这个方案的代价对用户会带来多大的影响,是否还有优化的空间?

只有这样,才会出现文中的方案二和方案三,解决方案才会一直向前演进,做得越来越好。也只有这样,我们才能在追求卓越的过程中快速进步。

课后作业

线程等待、死锁和热锁在应用中都是非常普遍的,今天的课后作业是分享一下你的产品中是否出现过这些问题,又是如何解决的?请你在留言区分享一下今天学习、练习的收获与心得。

我在评论中发现很多同学对监控Thread的创建比较感兴趣今天我们的Sample是如何监控线程的创建。在实践前,给你一些可以参考的链接。

对于PLT Hook和Inline Hook的具体实现原理与差别我在后面会详细讲到。这里我们可以把它们先隐藏掉直接利用开源的实现即可。通过这个Sample我希望你可以学会通过分析源码寻找合理的Hook函数与具体的so库。我相信当你熟悉这些方法之后一定会惊喜地发现实现起来其实真的不难。

欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。