LeakCanary 原理剖析

2020-08-07阅读 1320

引言

“A small leak will sink a great ship.”- Benjamin Franklin

内存泄漏是一个隐形炸弹,其本身并不会造成程序异常,但是随着量的增长会导致其他各种并发症:OOM,UI 卡顿等。

在App开发阶段,我们利用AndroidStudio的Lint静态扫描潜在的内存泄漏,也可以使用Android Studio 自带的 MAT 来分析内存问题。

此外,还有一个内存泄漏检测神器 - LeakCanary,它是Square 公司的开源库,可以在App运行过程中检测内存泄漏,在内存泄漏发生时分析并生成内存泄漏引用链,通知开发人员。

LeakCanary 关键流程

App 工程依赖了LeakCanary 1.5.1 版本,

com.squareup.leakcanary:leakcanary-android:1.5.1

通过代码装载leakCanary:

public class BaiduWalletApplication extends MultiDexApplication {

    @Override

    public void onCreate() {

...

// LeakCanary内存泄露监测

LeakCanary.install(this);

...

}

}

一旦LeakCanary 被装载,它会自动检测并且报告内存泄漏,步骤如下:

检测被持有的对象

转存heap信息

分析heap,产生 leak trace

(高版本)将内存泄漏归类为Application Leak 或 Library Leak。

JVM 垃圾回收概念

在此之前,我们要先讲一些关于垃圾回收的概念:

可达性分析

Java 虚拟机中使用一种叫作"可达性分析“的算法来决定对象是否可以被回收。

可达性分析算法是从离散数学中的图论引入的,JVM 把内存中所有的对象之间的引用关系看作一张图,通过一组名为”GC Root"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,最后通过判断对象的引用链是否可达来决定对象是否可以被回收。如下图所示:

GC root

判断对象是否存活我们是通过GC Roots的引用可达性来判断的。A,B,C,D,E 被GC Root 直接或者间接引用,而G, H, F 没有被任何 GC Root 直接或者间接引用。所以当垃圾回收发生时,GHF会被回收。

GC触发时机

不同的虚拟机实现有着不同的 GC 实现机制,但是一般情况下每一种 GC 实现都会在以下两种情况下触发垃圾回收。

Allocation Failure:在堆内存中分配时,如果因为可用剩余空间不足导致对象内存分配失败,这时系统会触发一次 GC。

System.gc():在应用层,Java 开发工程师可以主动调用此 API 来请求一次 GC,但是不一定执行。在Android开发中,调用Runtime.gc(), 可以直接触发gc。

GC Root 对象

Java 虚拟机栈(局部变量表)中的引用的对象。

方法区中静态引用指向的对象。

仍处于存活状态中的线程对象。

Native 方法中 JNI 引用的对象。

四类引用

但是JVM中的引用关系不止一种,而是有四种,根据引用强度的由强到弱,他们分别是:强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)。

Java 中的引用类型

检测内存泄漏

检测内存泄漏的是LeakCanary的主要流程它有四个阶段:

阶段关键类作用

WatchRefWatcher监听GC之后,对象是否被回收

GCGcTrigger触发GC

Heap DumpAndroidHeapDumperdump heap, 生成.hprof 文件

AnalyzeHeapAnalyzer解析.hprof,构建leak trace,及其他泄漏对象信息。

LeakCanary 建议我们在Application创建时,调用 LeakCanary.install(this); 即下列代码,来启动对Activity 引用的destory节点的hook,进而观察activity对象的引用。

ActivityRefWatcher - 监听Activity destroy

这里不仅构造了一个RefWatcher返回给调用方,并且启动了对Activity引用的监听。

ActivityRefWatcher 监听Activity生命周期

LeakCanary会hook Android 具有生命周期的组件,会自动检测如Activity destoryed的节点,并且检测后续的垃圾回收。

RefWatcher

RefWatcher会使用WeakRefrence 持有那些被销毁的对象,使用一个随机key,标示被销毁的对象。

LeakCanary 中当一个 Activity 需要被回收时,就将其包装到一个 WeakReference 中,并且在 WeakReference 的构造器中传入自定义的 ReferenceQueue。

然后给包装后的 WeakReference 做一个标记 Key,并且在一个强引用 Set 中添加相应的 Key 记录。

最后主动触发 GC,遍历自定义 ReferenceQueue 中所有的记录,并根据获取的 Reference 对象将 Set 中的记录也删除

经过上面 3 步之后,还保留在 Set 中的就是:应当被 GC 回收,但是实际还保留在内存中的对象,也就是发生泄漏了的对象。

下面详细看一下代码:

监听 Activity 销毁

retainedKeys  是一个String类型的集合( Set),随机生成一个key并存入,在Watch,GC,Heap Dump,Analyze 四个阶段作为被观察对象的标示被使用。

KeyedWeakReference继承自WeakReference,传入了queue参数,作为WeakReference指向的对象被回收之后,WeakReference 被放入的ReferenceQueue。

KeyedWeakReference

下一步就是确认被观察的refrence是否出现泄漏。即在GC发生之后, 被观察的对象,依然存在,那么就潜在地发生了内存泄漏。

ensureGoneAsync,顾名思义,异步地,判断GC发生之后,被观察的对象是否会消失。

它通过WatchExecutor在主线程idle时,post一个Runable任务,执行观察操作:

异步地观察即将销毁的对象

注:WatchExecutor在重试操作的设计上有值得借鉴的地方,此处不做赘述。

观察 即将销毁的对象核心步骤

ensureGone step1 : 清除弱可达引用 removeWeaklyReachableReferences()

WeakReference 和 ReferenceQueue

它对内存泄漏检测的原理就是基于 WeakReference 和 ReferenceQueue 实现的。

WeakReferenceDemo

WeakReference 的构造函数可以传入 ReferenceQueue,当 WeakReference 指向的对象被垃圾回收器回收时,会把 WeakReference 放入 ReferenceQueue 中。

代码实例打印了GC前后,WeakReference指向的对象 和 ReferenceQueue。

GC之前:WeakReference指向新创建的Variable对象,ReferenceQueue为null。

GC之后,weakReference指向的对象,会被回收,obj.get()结果为null。此时,WeakReference 会被添加到 ReferenceQueue里。

ReferenceQueue里包含观察的KeyedWeakReference对象,就意味着KeyedWeakReference指向的对象被回收了。

遍历 ReferenceQueue,通过queue.poll(), 将被那些持有的对象被回收的WeakReference从队列移除(未必包含当前观察的对象)。

并读取key,把相同的key值从retainedKeys这个Set里移除。

移除被观察的对象的key

ensureGone step2 : 判断被观察对象是否已被回收 gone(KeyedWeakReference reference)

gone(reference) 方法判断 reference 是否被回收了,如下:

实现很简单,只要在 retainedKeys 中不包含此 reference,就说明 WeakReference 引用的对象已经被回收。

检查被观察是否已回收

如果被观察对象已经被回收了,此次观察结束。否则进行下一步。

ensureGone step3 : 对象未被回收,主动触发GC操作,gcTrigger.runGc();

使用GcTrigger 触发回收

System.gc() 未必会立马执行,所以通过Runtime.getRuntime().gc() 立即执行垃圾回收操作,

gc执行多久,被观察的对象什么时候被回收,都是不确定的。有什么黑科技指定该对象立马被回收?没有。

所以通过 enqueueReferences() 等待100ms,等待gc结束。

try {  Thread.sleep(100); } catch (InterruptedException e) {  throw new AssertionError(); }

最后,使用System.runFinalization() 运行任何挂起的 finalized 的对象的finalize方法, 确保在垃圾回收执行之后,被回收对象执行finalize方法,达到weakly reachable 状态。

ensureGone step4 : 再次清除弱可达引用 removeWeaklyReachableReferences()

ensureGone step5 : 再次判断被观察对象是否已被回收,避免heap dump  gone(KeyedWeakReference reference)

在GC结束之后,再次清除弱可达对象,并且判断,被观察对象是否已回收。

为什么这里要在GC前后,做两次判断,这是个性能上的优化。ensureGone会因为某些原因retry,被观察对象可能早就回收了,没有必要先做一次GC。其次,因为dump操作,比较耗时耗资源,尽可能避免。

如果被观察对象已经被回收,就直接结束此次观察。否则,执行dump heap操作。

ensureGone step6 : 对象未回收,弹出Toast倒计时5S后转存堆信息  AndroidFileDumper.dumpHeap()

AndroidFileDumper.dumpHeap() 是串行执行的,也就是,假如上个dump 分析还未结束,当前的dumpHeap操作,不会执行,并且放到下次重试。

此函数有两个关键点

leakDirectoryProvider.newHeapDumpFile(); 它会过滤存储路径下所有的heap文件,假如一个新的heapDump文件还未分析结束,并且未超时,则返回RETRY_LATER。

在主线程空闲时,弹出Toast,并且等待5s,然后执行 Debug.dumpHprofData(),将堆信息转存到指定路径下,生成hprof文件。

最后返回hprof文件。

dump heap

ensureGone step7 : 分析 .hprof 文件,生成leak trace heapdumpListener.analyze(HeapDump heapDump);

analyze heap

heapDumpFile:      .hprof 文件,reference.key:        被观察对象的标示keyreference.name:    被观察对象的标示nameexcludedRefs:        在heapDump时,不需要做分析的类。

将上述信息及观察,GC,dump heap 三个阶段的耗时,封装到HeapDump对象中,作为参数,传递给heapdumpListener.analyze(HeapDump heapDump)方法,接下来做堆信息分析操作。

这就是内存泄漏检测的大致流程。

时序图

分析 heap dump

ServiceHeapDumpListener 在dump heap 结束之后,会启动HeapAnalyzerService,执行对.hprof 文件的分析,找到leak trace,并打印leak info,通知用户。

内存泄漏引用链

analyze

HeapAnalyzerService - 执行分析流程

HeapAnalyzerService 这个IntentService 执行分析流程

HeapAnalyzerService通过HeapAnalyzer获取到AnalysisResult,传递给AbstractAnalysisResultService处理展示。

HeapAnalyzer - 找到泄漏路径

寻找泄漏路径

checkforLeak的过程,就是将.hprof 文件转换为AnalysisResult的过程。

AnalysisResult 类图

checkForLeak step1 :  .hprof →  SnapShot  HprofParser.parse()

HeapAnalyzer 通过haha库,解析时刻运行时的所有堆和相关元数据的到快照,并通过SnapShot查询运行时的线程、GC root、每个对象的内存分配以及其引用的其他所有的对象等信息。

SnapShot 存储了当前所有的堆,和一些元数据。有三种可能的堆:DEFAULT,APP,ZYGOTE。GC root 总是在默认堆。

Snapshot 部分数据类图

checkForLeak step2: 修剪重复的GC Root,减轻内存压力  deduplicateGcRoots(Snapshot snapshot)

以GC Root 的 【type name + id 】为key,通过map排除key 相同的gc root, 并赋值snapShot.gcRoots.

此处存疑,即什么是相同的GC root?也就是相同的id?那么GC root 的id 由什么决定?

创建一个Key是<String,RootObj>是的Map,uniqueRootMap

遍历所有的GC Root,为每个GC root 生成标示key,【type name + id】,此处的type name指GC root 类型,也就是Java Local,Java Static 或者其他。

key已经存在于uniqueRootMap的GC root,不再重复添加。

将去重后的GC Root 赋值给 SnapShot。

checkForLeak step 3:  找到发生内存泄漏的引用,findLeakingReference(String key, Snapshot snapshot)

通过snapShot,获取当前内存里,所有KeyedWeakReference 类型的对象实例。为什么可以获取到呢?因为snapshot 是dump时所有对象及其关系的快照,自然也就有leakCanary的 KeyedWeakReference。

遍历KeyedWeakReference对象实例,获取对象的filed值,KeyedWeakReference.key。

假如此key,与传入的参数key相等,就是我们要找的实例对象。

findLeakingReference(String key, Snapshot snapshot)

checkForLeak step 4:  生成leak trace,findLeakTrace(long analysisStartNanoTime, Snapshot snapshot,

Instance leakingRef)

找到GC root 到 泄漏对象之间的最短路径。 以图的形式组织引用关系,所以这个问题被转化为寻找图中两个点的最短路径。

根据结果构建leak trace

计算内存泄漏的size,注意 bitmap 的大小也要被计算在内。

获得泄漏路径之后,将结果转发给DisplayLeakService 处理,打印log,展示通知。

分析heap,生成leaktrace

LeakCanary 的实现原理其实比较简单,但是内部实现还有一些其他的细节值得我们注意。

LeakCanary的其他细节

内存泄漏的检测时机

很显然这种内存泄漏的检测与分析是比较消耗性能的,因此为了尽量不影响 UI 线程的渲染,LeakCanary 也做了些优化操作。在 ensureGoneAsync 方法中调用了 WatchExecutor 的 execute 方法来执行检测操作,如下:

异步地观察对象是否已回收

前情提要代码:

异步地监听对象是否回收

可以看出实际是向主线程 MessageQueue 中插入了一个 IdleHandler,IdleHandler 只会在主线程空闲时才会被 Looper 从队列中取出并执行。因此能够有效避免内存检测工作占用 UI 渲染时间。

通过 addIdleHandler 也经常用来做 App 的启动优化,比如在 Application 的 onCreate 方法中经常做 3 方库的初始化工作。可以将优先级较低、暂时使用不到的 3 方库的初始化操作放到 IdleHandler 中,从而加快 Application 的启动过程。不过个人感觉方法名叫 addIdleMessage 更合适一些,因为向 MessageQueue 插入的都是 Message 对象。

特殊机型适配

因为有些特殊机型的系统本身就存在一些内存泄漏的情况,导致 Activity 不被回收,所以在检测内存泄漏时,需要将这些情况排除在外。在 LeakCanary 的初始化方法 install 中,通过 excludedRefs 方法指定了一系列需要忽略的场景。

这些场景都被枚举在 AndroidExcludedRefs 中,这种统一规避特殊机型的方式,也值得我们借鉴。

LeakCanary 如何检测其他类

LeakCanary 默认只能机检测 Activity 的泄漏,但是 RefWatcher 的 watch 方法传入的参数实际是 Object,所以理论上是可以检测任何类的。LeakCanary 的 install 方法会返回一个 RefWatcher 对象,我们只需要在 Application 中保存此 RefWatch 对象,然后将需要被检测的对象传给 watch 方法即可,具体如下所示:

LeakCanary 检查其他类

Activity 内存泄漏预防

为什么要单独将 Activity 单独做预防,是因为 Activity 承担了与用户交互的职责,因此内部需要持有大量的资源引用以及与系统交互的 Context,这会导致一个 Activity 对象的 retained size 特别大。一旦 Activity 因为被外部系统所持有而导致发生内存泄漏,被牵连导致其他对象的内存泄漏也会非常多。

造成 Activity 内存泄漏的场景主要有以下几种情况。

1. 将 Context 或者 View 置为 static

View 默认会持有一个 Context 的引用,如果将其置为 static 将会造成 View 在方法区中无法被快速回收,最终导致 Activity 内存泄漏。

2. 未解注册各种 Listener

在 Activity 中可能会注册各种系统监听器,比如广播。或者注册EventBus。还有个很容易被忽视的场景,即匿名内部类,会默认持有当前类的对象。

3. 非静态 Handler 导致 Activity 泄漏

4. 三方库使用 Context

在项目中经常会使用各种三方库,有些三方库的初始化需要我们传入一个 Context 对象。但是三方库中很有可能一直持有此 Context 引用。

提示:这也提醒我们自己在实现 SDK 时,也尽量避免造成外部 Context 的泄漏。

建议解决方案

对于内存泄漏问题,推荐一下几点:

1、对于生命周期比Activity长的对象如果需要应该使用ApplicationContext,在需要使用Context参数的时候先考虑Application.Context.

2、在引用组件Activity,Fragment时,优先考虑使用弱引用。

3、WeakReference 是解决问题的快捷方式,实际上开发者要对引用组件的生命周期有明确的预期:在使用异步操作时注意Activity销毁时,需要清空任务列表。

4、内部类持有外部类的引用尽量修改成静态内部类中使用弱引用持有外部类的引用。

还有一类容易被忽略的内存泄漏:即短生命周期对象被长生命周期对象引用,在调用结束之后,未被释放。

比如使用 ThreadLocal存取数据。ThreadLocal是随线程生命周期存活的,假如较早地使用完对象,如果不及时释放,就会造成内存占用。

尽管线程结束ThreadLocal也会被销毁,但是线程存活期间,依然占用着内存。

参考文档

https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342

推荐阅读更多精彩内容