Matrix ResourceCanary -- Activity 泄漏及Bitmap冗余检测

背景


随着微信 Android 客户端的代码规模越来越庞大,依赖人工 Review 来确保代码没有泄漏冗余问题,虽然还是最保险的办法,但代码增长的速度总是大于 Review 的速度,完全靠人力介入变得越来越吃力,且依赖线上反馈进行事后排查也非常被动,为此我们从最为常见的 Activity 泄漏和 Bitmap 对象冗余入手提出了研发 ResourceCanary 模块的计划。

作为 Matrix 的一个子模块,ResourceCanary 将把原本难以发现的 Activity 泄漏和重复创建的冗余 Bitmap 暴露出来,并提供引用链等信息帮助排查这些问题的根源,以提高微信客户端的代码质量。

设计目标


在引入任何自动分析工具之前,对于 Activity 泄漏,一般都是在自动化测试阶段监控内存占用,一旦超过预期,则发起一次 GC 后进行 Dump Hprof 操作。分析人员将 Hprof 文件导入 MAT 中查看各个 Activity 的引用链,找出被静态成员或 Application 对象等长生命周期对象持有的 Activity ,再进行修复。对于冗余的 Bitmap ,也是将 Hprof 导入 Android Monitor 后通过 Android Monitor 自带的工具找出冗余的 Bitmap 对象。

可见上面的流程人工参与程度较大,与 Matrix 对质量监控的日常化、流程化的目标不符。我们希望在引入 ResourceCanary 后能实现下面的目标:

  • 自动且较为准确地监测 Activity 泄漏,发现泄漏之后再触发 Dump Hprof 而不是根据预先设定的内存占用阈值盲目触发
  • 自动获取泄漏的 Activity 和冗余 Bitmap 对象的引用链,
  • 能灵活地扩展 Hprof 的分析逻辑,必要时允许提取 Hprof 文件人工分析

预研与大致实现


根据设计目标,首先我们需要解决监测阶段和分析阶段的自动化问题。期间我们参考了 LeakCanary 和 Android Monitor 中的部分代码,并根据需要作了一些修改。

监测阶段

首先回顾一下 Activity 泄漏的大致定义:

Activity 对象被生命周期更长的对象通过强引用持有,使 Activity 生命周期结束后仍无法被 GC 机制回收,导致其占用的内存空间无法得到释放。

不难发现要监测 Activity 泄漏,我们要解决两个问题:

  • 如何在一个恰当的时机得知一个 Activity 已经结束了生命周期
  • 如何判断一个 Activity 无法被 GC 机制回收

对于第一个问题,最直观的想法就是找一个Activity被销毁时的必经调用点记录下当前Activity的信息,显然 Activity.onDestroy() 方法是一个不错的选择。实现上也有下面两种做法:

  • 让所有Activity继承一个 BaseActivity ,然后在BaseActivity.onDestroy()方法中进行记录。
  • 通过某种机制得知 Activity.onDestroy() 方法被调用,然后进行记录
    • Android 4.0以前可以通过反射替换ActivityThread.mInstrumentation对象为自己的代理,然后在代理中的callActivityOnDestroy()方法中记录。
    • Android 4.0以后可以通过 Application.registerActivityLifecycleCallbacks() 方法注册一个回调对象,在回调对象的 onActivityDestroyed() 方法中记录。

考虑到 Matrix 将被设计成一个通用框架,在 ResourceCanary 里让所有 Activit 继承一个 BaseActivity 的做法入侵性太强,基本不在考虑范围内。再加上目前   Android 4.0 版本以前的机器占比并不高,放弃 4.0 版本以前的机器后第二种做法就不再需要反射系统隐藏字段了,因此最终我们选择了第二种做法得知 Activity 已经结束了生命周期。

对于第二个问题,乍一看 Dalvik 或 ART 虚拟机的 GC 机制我们是没法直接干预的,而且 Android Framework 也没有提供任何API让我们直接得知一个对象已被 GC ,但熟悉 Java 的同学大概很快就想到了 WeakReference 也许可以曲线救国。根据 Java API 文档中的描述:

Weak reference objects, which do not prevent their referents from being made finalizable, finalized, and then reclaimed. …… Suppose that the garbage collector determines at a certain point in time that an object is weakly reachable. At that time it will atomically clear all weak references to that object and all weak references to any other weakly-reachable objects from which that object is reachable through a chain of strong and soft references. At the same time it will declare all of the formerly weakly-reachable objects to be finalizable. At the same time or at some later time it will enqueue those newly-cleared weak references that are registered with reference queues.

我们可以通过创建一个持有已销毁的 Activity 的 WeakReference ,然后主动触发一次 GC ,如果这个 Activity 能被回收,则持有它的 WeakReference 会被置空,且这个被回收的 Activity 一段时间后会被加入事先注册到 WeakReference 的一个队列里。这样我们就能间接知道某个被销毁的 Activity 能否被回收了。

另外,由于我们暂时还没发现即时得知一个 Bitmap 是否冗余的方法,因此监测阶段我们并不特别为冗余 Bitmap 设计监测逻辑,留待分析阶段来获取所有冗余的 Bitmap 对象的信息。

分析阶段

通过监测阶段确定了某个Activity已经泄漏并触发了 Dump Hprof 之后,接下来就可以进行下面两项分析了:

  • 从 Hprof 文件中获取泄漏的 Activity 到 GC Root 的强引用链
  • 从 Hprof 文件中获取所有冗余的 Bitmap 对象以及它们的强引用链(即图像数据完全相同的 Bitmap 对象)

GC Root

GC Root 是指这样一类对象,他们本身并不被其他生命周期更长的对象持有,但JVM的特性导致了这些对象无法被 GC 机制回收,因此从他们出发,经过一系列强引用可到达的对象都是无法被回收的。他们包括下列对象:

  • 类;(被JVM加载的类是无法卸载的,因此无法被回收,导致被类持有(即通过静态成员持有)的对象也无法被回收)
  • 活动的 Thread 实例;
  • 局部变量或方法参数变量持有的对象;
  • JNILocalReference 持有的对象;
  • JNIGlobalReference 持有的对象;
  • synchronized 关键字用到的对象;

如果某个 Activity 被泄漏,则必然存在从它到某个 GC Root 的强引用链。只要我们将这条强引用链找出来,开发者就能根据引用链上的对象找到合适的修改点快速解决问题。

获取泄漏的 Activity 到 GC Root 的强引用链

Hprof 文件中包含了 Dump 时刻内存中的所有对象的信息,包括类的描述,实例的数据和引用关系,线程的栈信息等。具体可参考这份文档中的 Binary Dump Format 一节。按照文档描述的格式将Hprof中的实例信息解析成描述引用关系的图结构后,套用经典的图搜索算法即可找到泄漏的 Activity 到 GC Root 的强引用链了。

大多数时候这样的强引用链不止一条,全部找出来会让一次分析操作的耗时大大增加,延长了整个测试流程的周期,而且对解决问题并没有更多帮助。实际上我们只需要找到最短的那条就可以了。如下图:

这种情况下只要切断蓝色箭头即可使泄漏的 Activity 与 GC Root 脱离联系。如果持有泄漏的 Activity 的 GC Root 不止一个,或者从 GC Root 出发的引用不止一条,在 Matrix 框架成为流程化工具的背景下我们可以通过多次检测来解决,这样至少保证了每次执行 ResourceCanary 模块的耗时稳定在一个可预计的范围内,不至于在极端情况下耽误其他流程。

本来我们打算自行实现这个算法,幸运的是在阅读 LeakCanary 的代码时我们发现了一个叫 haha 的库已经把 Hprof 文件按照文档描述的格式解析成了结构化的引用关系图,而且 LeakCanary 也按照与上面的描述类似的思路实现了引用链的提取逻辑,于是我们就不再重复造轮子,直接使用了 LeakCanary 的这部分代码了。

从 Hprof 文件中获取所有冗余的 Bitmap 对象

这个功能 Android Monitor 已经有完整实现了,原理简单粗暴——把所有未被回收的 Bitmap 的数据 buffer 取出来,然后先对比所有长度为 1 的 buffer,找出相同的,记录所属的 Bitmap 对象;再对比所有长度为 2 的、长度为 3 的 buffer ……直到把所有buffer都比对完,这样就记录下了所有冗余的 Bitmap 对象了,接着再套用 LeakCanary 获取引用链的逻辑把这些 Bitmap 对象到 GC Root 的最短强引用链找出来即可。

让 ResourceCanary 更为灵活

目前为止设计目标中的自动化要求已确定了解决方案,还有最后一个问题:上面提到的监测和分析两步是都在 APP 侧做,还是把分析步骤拆到 Matrix 服务端做呢?由于监测步骤监测的是 Activity 泄漏这个 Android 系统特有的概念,因此不得不依赖系统环境;但分析步骤只是一个处理 Hprof 文件的过程,完全不需要依赖 Android 系统,理论上将这两部拆开是完全可行的。而且拆开之后我们至少能获得下面的好处:

  • 更新分析逻辑不再需要重新发客户端版本
  • Hprof 文件留在了服务端,为人工分析提供了机会
  • 如果跳过触发 Dump Hprof,甚至可以把监测步骤在现网环境启用,以发现测试阶段难以触发的 Activity 泄漏

于是 ResourceCanary 最终决定将监测步骤和分析步骤拆成两个独立的工具,以满足设计目标。

细节与改进


减少误报

LeakCanary 的监测部分的工作流程如下图所示:

可见其对 Activity 是否泄漏的判断依赖VM会将可回收的对象加入 WeakReference 关联的 ReferenceQueue 这一特性,在Demo的测试过程中我们发现这中做法在个别系统上可能存在误报,原因大致如下:

  • VM 并没有提供强制触发 GC 的 API ,通过 System.gc()Runtime.getRuntime().gc()只能“建议”系统进行 GC ,如果系统忽略了我们的 GC 请求,可回收的对象就不会被加入 ReferenceQueue
  • 将可回收对象加入 ReferenceQueue 需要等待一段时间,LeakCanary 采用延时 100ms 的做法加以规避,但似乎并不绝对管用
  • 监测逻辑是异步的,如果判断 Activity 是否可回收时某个 Activity 正好还被某个方法的局部变量持有,就会引起误判
  • 若反复进入泄漏的 Activity ,LeakCanary 会重复提示该 Activity 已泄漏

对此我们做了以下改进:

  • 增加一个一定能被回收的“哨兵”对象,用来确认系统确实进行了GC
  • 直接通过WeakReference.get()来判断对象是否已被回收,避免因延迟导致误判
  • 若发现某个Activity无法被回收,再重复判断3次,且要求从该Activity被记录起有2个以上的Activity被创建才认为是泄漏,以防在判断时该Activity被局部变量持有导致误判
  • 对已判断为泄漏的Activity,记录其类名,避免重复提示该Activity已泄漏

裁剪 Hprof

Hprof 文件的大小一般约为 Dump 时的内存占用大小,就微信而言 Dump 出来的 Hprof 大小通常为 150MB~200MB 之间,如果不做任何处理直接将此 Hprof 文件上传到服务端,一方面会消耗大量带宽资源,另一方面服务端将 Hprof 文件长期存档时也会占用服务器的存储空间。

通过分析 Hprof 文件格式可知,Hprof 文件中 buffer 区存放了所有对象的数据,包括字符串数据、所有的数组等,而我们的分析过程却只需要用到部分字符串数据和 Bitmap 的 buffer 数组,其余的 buffer 数据都可以直接剔除,这样处理之后的 Hprof 文件通常能比原始文件小 1/10 以上。

提高 Hprof 分析效率

LeakCanary 中的引用链查找算法都是针对单个目标设计的,ResourceCanary 中查找冗余 Bitmap 时可能找到多个结果,如果分别对每个结果中的 Bitmap 对象调用该算法,在访问引用关系图中的节点时会遇到非常多的重复访问的节点,降低了查找效率。为此我们修改了 LeakCanary 的引用链查找算法,使其在一次调用中能同时查找多个目标到 GC Root 的最短引用链。

使用效果与开销


使用效果

以其中一份检测结果为例,ResourceCanary 会输出如下的引用关系:

Format:TypeName FieldName / array ElemType[] [index] android.app.ActivityThread mOnPauseListeners android.util.ArrayMap mArray array java.lang.Object[] [0] com.tencent.mm.plugin.account.ui.SimpleLoginUI instance

分析之后可知,此处 SimpleLoginUI 的一个实例被 ActivityThread 中类型为 ArrayMap 的 mOnPauseListeners 字段持有,导致了泄漏。

性能开销

监测部分,在周期性轮询阶段由于是在后台线程执行的,目前设定的轮询间隔为1min。以通讯录界面和朋友圈界面的帧率作为参考,在接入ResourceCanary后2min内的平均帧率降低了10帧左右(未接入时同样时段内的平均帧率为120帧/秒),开销并不明显。

Dump Hprof 阶段的开销则较大。Dump 时整个 APP 会卡死约5~15s,但考虑到 ResourceCanary 模块不会在线上环境启用,因此尚可接受。个人猜想 Dump Hprof 操作的耗时通过某些hack应该还有优化的空间;对Hprof的预处理阶段耗时约 3~20s(取决于机器性能和Hprof的大小),内存开销约为 1.5 倍 Hprof 的大小,但由于是在另一个进程中完成的,而且只有在触发了 Dump Hprof 之后才会执行,因此对 APP 正常运行的干扰也较小。不过改进算法使耗时和内存占用都尽可能少,还是要继续探究的。

分析部分由于是服务器上的工具,因此设计时并未太关注性能问题,实际使用时分析一个 200M 左右的 Hprof 平均需要 15s 左右的时间。此部分主要消耗在引用链分析上。由于引用链分析需要广度优先遍历完 Hprof 中记录的全部对象,因此在想到合适的剪枝条件之前时间消耗应该不会有显著降低。

未来的计划


目前 ResourceCanary 支持了 Activity 泄漏监测和冗余 Bitmap 监测,但由于检测机制本身和 ROM 的碎片化等问题,目前仍有少量误报的情况,未来我们将持续跟进误报案例,提高 ResourceCanary 的可靠性。

关于 Matrix


Matrix 当前已开源, 开源地址为: https://github.com/Tencent/matrix 。 欢迎提 Issue 和 PR 。

原文发布于微信公众号 - WeMobileDev(WeMobileDev)

原文发表时间:2018-12-28

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

扫码关注云+社区

领取腾讯云代金券