02 | 崩溃优化(下):应用崩溃了,你应该如何去分析?——学习总结

学习文章:https://time.geekbang.org/column/article/70966

“真相永远只有一个” --柯南。

崩溃现场

操作系统--崩溃过程的【旁观者】证人。

崩溃捕获工具应该采集哪些系统信息?什么场景深入挖掘哪些内容?从而更好解决问题。

1、崩溃信息

  • 进程名、线程名。崩溃的进程是前台进程还是后台进程,崩溃是不是发生在 UI 线程。
  • 崩溃堆栈和类型。崩溃是属于 Java 崩溃、Native 崩溃,还是 ANR。看具体崩溃在系统的代码,还是我们自己的代码里面

Process Name: 'com.sample.crash'
Thread Name: 'MyThread'

java.lang.NullPointerException
    at ...TestsActivity.crashInJava(TestsActivity.java:275)

有时候我们除了崩溃的线程,还希望拿到其他关键的线程的日志。

就像上面的例子,虽然是 MyThread 线程崩溃,但是我也希望可以知道主线程当前的调用栈。

2、系统信息

系统的信息有时候会带有一些关键的线索,对我们解决问题有非常大帮助。

  • Logcat。这里包括应用、系统的运行日志。由于系统权限问题,获取到的 Logcat 可能只包含与当前APP相关的。

其中系统的 event logcat 会记录 App 运行的一些基本情况,记录在文件 /system/etc/event-log-tags 中。

  • 机型、系统、厂商、CPU、ABI、Linux 版本等。我们会采集多达几十个维度,这对后面讲到寻找共性问题会很有帮助。
  • 设备状态:是否 root、是否是模拟器。一些问题是由 Xposed 或多开软件造成,对这部分问题我们要区别对待。

3. 内存信息

OOM 、ANR 、虚拟内存耗尽等,很多崩溃都是直接与内存 有关。

手机内存 【2GB以下】与【2GB以上】的崩溃率,会有很大差别。

  • 系统剩余内存。关于系统内存状态,可以直接读取文件 /proc/meminfo 。当系统可用内存很小(低于 MemTotal 的 10%)时,,OOM、大量 GC、系统频繁自杀拉起等问题都非常容易出现。
  • 应用使用内存。包括 Java 内存、RSS(Resident Set Size)、PSS(Proportional Set Size),我们可以得出应用本身内存的占用大小和分布。PSS 和 RSS 通过 /proc/self/smap   or  我的设备是 smaps计算,可以进一步得到例如 apk、dex、so 等更加详细的分类统计。

  •  

    虚拟内存。/proc/self/status ,通过 /proc/self/maps 文件可以得到具体的分布情况。32位CPU 一般 3G引起申请内存失败。64位CPU一般在3-4G之间。

4、资源信息

有的时候我们会发现应用堆内存和设备内存都非常充足,还会出现内存分配失败,这跟资源泄漏可能有比较大的关系。

  • 文件句柄fd。文件句柄的限制可以通过 /proc/self/limits 获得,一般单个进程允许打开的最大文件句柄个数为 1024。但是如果文件句柄超过 800 个就比较危险,需要将所有的 fd 以及对应的文件名输出到日志中,进一步排查是否出现了有文件或者线程的泄漏。

opened files count 812:
0 -> /dev/null
1 -> /dev/log/main4 
2 -> /dev/binder
3 -> /data/data/com.crash.sample/files/test.config
...

  • 线程数。当前线程数大小可以通过上面的 status 文件得到,一个线程可能就占 2MB 的虚拟内存,过多的线程会对虚拟内存和文件句柄带来压力。根据我的经验来说,如果线程数超过400 个就比较危险。
  • JNI。使用 JNI 时,如果不注意很容易出现引用失效、引用爆表等一些崩溃。我们可以通过 DumpReferenceTables 统计 JNI 的引用表,进一步分析是否出现了 JNI 泄漏等问题。

5、应用信息

  • 崩溃场景。崩溃发生在哪个 Activity 或 Fragment,发生在哪个业务中。

  • 关键操作路径。不同于开发过程详细的打点日志,我们可以记录关键的用户操作路径,这对我们复现崩溃会有比较大的帮助。

  • 其他自定义信息。不同的应用关心的重点可能不太一样,比如网易云音乐会关注当前播放的音乐,QQ 浏览器会关注当前打开的网址或视频。此外例如运行时间、是否加载了补丁、是否是全新安装或升级等信息也非常重要。

  • 特定的一些崩溃:磁盘空间、电量、网络使用等特定信息。

 

崩溃分析

第一步:确定重点

对问题有一个大致判断。

1. 确认严重程度。解决崩溃也要看性价比,我们优先解决 Top 崩溃或者对业务有重大影响。下个版本是否存在。

2.崩溃基本信息。确定崩溃的类型以及异常描述,对崩溃有大致的判断。

  • java崩溃。 eg:NullPointException,  OOM 继续查看“内存信息”和“资源信息”。
  • Native崩溃。

    需要观察 signal、code、fault addr等内容,以及崩溃时 Java 的堆栈。关于各 signal 含义的介绍,你可以查看崩溃信号 介绍。

    比较常见的是有 SIGSEGV 和 SIGABRT,前者一般是由于空指针、非法指针造成,后者主要因为 ANR 和调用 abort() 退出所导致。

  • ANR.

    先看看主线程的堆栈,是否是因为锁等待导致。接着看看 ANR 日志中 iowait、CPU、GC、system server 等信息,进一步确定是I/O 问题,或是 CPU 竞争问题,还是由于大量 GC 导致卡死。

3. Logcat。

当从一条崩溃日志中无法看出问题的原因,或者得不到有用信息时,不要放弃,建议查看相同崩溃点下的更多崩溃日志。

4. 各个资源情况。 结合 内存信息、资源信息、是否有关。比如物理内存不足、虚拟内存不足,文件句柄 fd 泄漏。

 

第二步:查找共性

如果使用了上面的方法还是不能有效定位问题,可以尝试查找这类崩溃有没有什么共性。找到了共性,也就可以进一步找到差异,离解决问题也就更进一步。

机型、系统、ROM、厂商、ABI,这些采集到的系统信息都可以作为维度聚合,共性问题例如:

是不是都安装 了 Xposed,  x86, 三星某款机型,只在 android5.0 。

 

第三部:尝试复现

如果我们已经大概知道了崩溃的原因,为了进一步确认更多信息,就需要尝试复现崩溃。

如果我们对崩溃完全没有头绪,也希望通过用户操作路径来尝试重现,然后再去分析崩溃原因。

有时候还需要去抠厂商的 ROM 或手动刷 ROM。这个痛苦的经历告诉我,很多疑难问题需要我们耐得住寂寞,反复猜测、反复发灰度、反复验证。

 

疑难问题:系统崩溃

系统崩溃常常令我们感到非常无助,它可能是某个 Android 版本的 bug,也可能是某个厂商修改 ROM 导致。

这种情况下的崩溃堆栈可能完全没有我们自己的代码,很难直接定位问题。针对这种疑难问题,我来谈谈我的解决思路。

1. 查找可能的原因。

通过上面的共性归类,我们先看看是某个系统版本的问题,还是某个厂商特定 ROM 的问题。虽然崩溃日志可能没有我们自己的代码,但通过操作路径和日志,我们可以找到一些怀疑的点。

2. 尝试规避。

查看可疑的代码调用,是否使用了不恰当的API,是否可以更换其他的实现方式规避。

3. Hook 解决。

这里分为 Java Hook 和 Native Hook。以我最近解决的一个系统崩溃为例,我们发现线上出现一个 Toast 相关的系统崩溃,它只出现在 Android7.0 的系统中,看起来是在 Toast 显示的时候窗口的token 已经无效了。这有可能出现在 Toast 需要显示时,窗口已经销毁了。

android.view.WindowManager$BadTokenException: 
	at android.view.ViewRootImpl.setView(ViewRootImpl.java)
	at android.view.WindowManagerGlobal.addView(WindowManagerGlobal.java)
	at android.view.WindowManagerImpl.addView(WindowManagerImpl.java4)
	at android.widget.Toast$TN.handleShow(Toast.java)

为什么 Android 8.0 的系统不会有这个问题?在查看 Android 8.0 的源码后我们发现有以下修改:

try {
  mWM.addView(mView, mParams);
  trySendAccessibilityEvent();
} catch (WindowManager.BadTokenException e) {
  /* ignore */
}

考虑再三,我们决定参考 Android 8.0 的做法,直接 catch 住这个异常。这里的关键在于寻找 Hook点,这个案例算是相对比较简单的Toast 里面有一个变量叫mTN,它的类型为 handler,我们只需要代理它就可以实捕获。

 

总结

介绍了崩溃问题的一些分析方法、特殊技巧、以及疑难和常见问题的解决方法。

不同类型的应用侧重点可能也有所不同,我们不能只局限在上面所说的一些方法。

 

课后作业

TimeoutException

java.util.concurrent.TimeoutException: 
         android.os.BinderProxy.finalize() timed out after 10 seconds
at android.os.BinderProxy.destroy(Native Method)
at android.os.BinderProxy.finalize(Binder.java:459)
 

今天的 Sample 提供了一种“完全解决“”TimeoutException 的方法,主要是希望你可以更好地学习解决系统崩溃的套路。

1. 通过源码分析。我们发现 TimeoutException 是由系统的FinalizerWatchdogDaemon 抛出来的。

2. 寻找可以规避的方法。尝试调用了它的 Stop() 方法,但是线上发现在 Android 6.0 之前会有线程同步问题。

作者回复: Stop的时候有一定概率导致即使没有超时也会报timeoutexception 。

3. 寻找其他可以 Hook 的点。通过代码的依赖关系,发现一个取巧的 Hook 点。

最终代码你可以参考 Sample 的实现,但是建议只在灰度中使用。这里需要提的是,虽然有一些黑科技可以帮助我们解决某些问题,但对于黑科技的使用我们需要慎重,比如有的黑科技对保活进程频率没有做限制,可能会导致系统卡死。

 

补充一下获得logcat和Jave堆栈的方法:


一. 获取logcat
logcat日志流程是这样的,应用层 --> liblog.so --> logd,底层使用ring buffer来存储数据。
获取的方式有以下三种:
   1. 通过logcat命令获取。
   优点:非常简单,兼容性好。
   缺点:整个链路比较长,可控性差,失败率高,特别是堆破坏或者堆内存不足时,基本会失败。
   2. hook liblog.so实现。通过hook liblog.so 中__android_log_buf_write 方法,将内容重定向到自己的buffer中。
   优点:简单,兼容性相对还好。
   缺点:要一直打开。
   3. 自定义获取代码。通过移植底层获取logcat的实现,通过socket直接跟logd交互。
   优点:比较灵活,预先分配好资源,成功率也比较高。
   缺点:实现非常复杂

二. 获取Java 堆栈
   native崩溃时,通过unwind只能拿到Native堆栈。我们希望可以拿到当时各个线程的Java堆栈
   1. Thread.getAllStackTraces()。
    优点:简单,兼容性好。
    缺点:
        a. 成功率不高,依靠系统接口在极端情况也会失败。
        b. 7.0之后这个接口是没有主线程堆栈。
        c. 使用Java层的接口需要暂停线程
   2. hook libart.so。通过hook ThreadList和Thread的函数,获得跟ANR一样的堆栈。为了稳定性,我们会在fork子进程执行。
   优点:信息很全,基本跟ANR的日志一样,有native线程状态,锁信息等等。
   缺点:黑科技的兼容性问题,失败时可以用Thread.getAllStackTraces()兜底

获取Java堆栈的方法还可以用在卡顿时,因为使用fork进程,所以可以做到完全不卡主进程。这块我们在后面会详细的去讲。

 

 

 

极客时间版权所有: https://time.geekbang.org/column/article/70966

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值