本文涉及的源码基于LeakCanary2.0:https://github.com/square/leakcanary
LeakCanary是square公司开源的内存泄漏检测工具,近期发布了2.0正式版,并带来了全新的内存分析工具shark,本文旨在梳理其整个工作流程。
注册监听
LeakCanary利用了ContentProvider的随进程启动实例化的特性实现无感知初始化。整个初始化流程位于 InternalAppWatcher
的install方法:
1 | fun install(application: Application) { |
可以看到主要分为注册监听和通知回调两个步骤。注册监听的目的是为了能实时感知关键对象的销毁时机,以进一步检测对象是否发生泄漏。对于Android应用,Activity和Fragment无外乎是交互最为频繁的组件了,因此这里通过两个对应的watcher向其注册了生命周期回调。
注册监听的逻辑分为两部分,对于Activity,Application本身就对其生命周期变化提供了标准的回调接口,但对Fragment而言就稍显复杂了些,Fragment本身存在多个版本:集成于SDK的标准组件 / support包 / androidX,且早期版本并未提供Fragment的生命周期回调接口,因此FragmentDestroyWatcher包含了诸多兼容性处理实现了对位于Android O及其以上、support版本(>=25.1.0)以及AndroidX版本的Fragment的监控能力,最后依据Fragment依赖Activity的特性,在Activity的onCreate回调中通过FragmentManager的registerActivityLifecycleCallbacks方法对上述各种Fragment统一注册回调。这样每当组件销毁时,组件对象便会被放入objectWatcher
的观察集合,objectWatcher用于初步判断对象是否发生泄漏,具体实现稍后再谈。
完成注册后,接下来继续剩余的初始化流程。 InternalLeakCanary
类实现了 (Application) -> Unit 接口并在InternalAppWatcher的构造方法中被反射注册为onAppWatcherInstalled
对象,以接收初始化通知,InternalLeakCanary的初始化逻辑如下:
1 | override fun invoke(application: Application) { |
InternalLeakCanary的职责主要是接收来自objectWatcher的 “存在尚未销毁的对象” 的消息并驱动HeapDumpTrigger
进行堆转储(dump heap),分析内存快照并发布最终的结果(当然这里并非就是立即堆转储,后面再说)。这里有一个疑惑:既然都知道了哪个对象未销毁,为什么还要堆转储进一步分析?其实这和objectWatcher的实现有关。
判断泄漏
objectWatcher具体方案是利用弱引用 WeakReference
的特性来实现的,我们知道软引用 SoftReference
、弱引用以及虚引用 PhantomReference
可以关联一个引用队列 ReferenceQueue
,当引用reference的对象referent确定将被回收时,该reference会先被放入到所关联的queue中,因此可以利用该特性来确定对象最终是否被回收。
选用弱引用而不是虚引用的原因:排除必须等到内存将满时才被回收的软引用,对于虚引用,reference在被加入到关联的queue时,reference内部所持有的referent并不会被置为null,因此需要手动调用clear()方法。
对于销毁的组件,如果能正常入队就表示确被回收反之可初步认为发生了泄漏。但是GC机制是主动的,因此对象什么时候被回收是无法确定的,LeakCanary采取了默认等待5s再判断所watch的对象是否被回收的方案来获得一个可能结果,虽然是一个佛系数字,但正常的GC运行还是使得在给定的时间内大部分情况下对象都得到了回收,有效减少了堆转储的必要,毕竟是一个耗费资源和影响体验的操作:
1 | private val checkRetainedExecutor = Executor {// 主线程post一个5s延迟消息 |
从上述片段可以看到,watchedObjects
存放的就是被观察的对象引用。每watch一个对象,就会在5s后再通过moveToRetained
检测一遍是否被回收,其具体逻辑就是遍历引用队列queue,将其中的reference(若存在)依次从watchedObjects中移除,最后看看watchedObjects中是否还存在该对象引用,如果存在就表示该对象有可能无法被回收。但GC的时机毕竟无法预知,假如某个对象在第6s的时候才被回收,也就是说,objectWatcher得出的 “存在尚未销毁的对象”只是一个初步结果,还不能完全确定这些对象是否能够真正被回收。
回到InternalLeakCanary,对于这些尚未被回收的对象,转交给HeapDumpTrigger做进一步判定。
1 | private fun checkRetainedObjects(reason: String) { |
可见HeapDumpTrigger并不急着立即触发dump堆内存并分析堆转储文件,而是先尝试触发一次GC,虽然GC无法手动执行;并且也因为GC无法手动执行,所以触发后先等待一小段时间(默认100ms),当然我们的前提是假定虚拟机收到消息后会尽快执行GC,这样在小段时间之后我们再通过checkRetainedCount
检查引用集合中是否仍然存在活着的对象就可以判断是否有对象无法被回收。
checkRetainedCount除了判断存活的对象个数是否为0外,还通过指定的阈值(默认5)个数来限制短时间内反复触发后续的dump heap,例如一个Activity+N个Fragment,当Activity被关闭时,假使Activity确实无法释放,那么短时间内每个组件对象最后都会依次触发dump heap导致生成多个hprof文件。
由此可见,这一次的判断虽然可以进一步排除“假”泄漏对象,但依然不是完全确定的,GC毕竟无法人为控制,我们前面只是基于假设GC在触发后立即得到了执行,到此只能祭出最后的杀手锏了。
接下来继续后面的流程,在上述片段注释1处开始dump出内存快照,dumpHeap()
内部很简单,就是弹出toast并调用系统接口 Debug.dumpHprofData
生成堆转储hprof
文件。生成的文件接下来交给注释2出的HeapAnalyzerService
来执行分析。HeapAnalyzerService是一个前台IntentService(可配置到其它进程以降低对主进程的影响),为分析堆转储文件提供运行环境,其内部逻辑简单明了,就是调用HeapAnalyzer
执行分析并得到分析结果,然后通过OnHeapAnalyzedListener
回调。HeapAnalyzer位于Shark模块,我们先来看看shark是什么。
shark
此前LeakCanary用于分析hprof的工具依次包括HAHA、HAHA2以及perflib。shark是square团队开发的一款全新的分析hprof文件的工具,其官方宣布比Android Studio用于memory profiler的核心库perflib
要快8倍并且内存占用少10倍,更加适合手机端的分析工具。其目的就是提供快速解析hprof文件和分析快照的能力,并找出真正的泄漏对象以及对象到GcRoot的最短引用路径链,以便帮助开发者更加直观的找出泄漏的真正原因。
由此可知,先前初步分析得到的泄漏对象与这里的堆分析和得到的真正泄漏对象并没有依赖关系,先前的初步分析仅仅只是一个触发堆转储和分析操作的过滤。
hprof文件的标准协议主要由head和body组成,head包含一些元信息,例如文件协议的版本、开始的时间戳等。body则是由一系列不同类型的Record组成,Record主要用于描述trace、object(实例 / 数组 / 类 / …)、thread等信息,依次分为4个部分:TAG、TIMESTAMP、LENGTH、BODY,其中TAG就是表示Record类型,LENGTH用于指示BODY的长度。Record之间依次排列或嵌套,最终组成hprof文件。
在Shark库的设计中,Hprof
类就是用于描述从内存中dump出的.hprof
文件;密封类HprofRecord
的各个子类一一对应上述协议中的各种Record;真正的解析则由HprofReader
实现,核心方法为readHprofRecords
():
1 | fun readHprofRecords( |
不难看出,readHprofRecords就是一个遵从协议实现的通用提取器,Record的类型有很多,通过指定关心的record类型和回调接口就可以将hprof文件转化为Record对象集合。
解析得到的records被进一步抽象为HprofMemoryIndex
,Index的作用就是就是将得到的record按类型进行归类和计数,并通过特定规则进行排序;最终Index和Hprof一起再组成HprofGraph
,graph做为hprof的最上层描述,将所有堆中数据抽象为了 gcRoots、objects、classes、instances
4种集合,并提供了快速定位dump堆中具体对象的能力。
HeapAnalyzer
heapAnalyzer作为shark模块的出入口,衔接解析和分析两大流程,核心逻辑如下:
1 | Hprof.open(heapDumpFile) |
整个流程非常清晰:首先通过indexHprof解析得到graph,然后构造 FindLeakInput
开始分析并找出泄漏,最后包装分析结果返回。
graph的组成和功能我们已经清楚了,我们直接来看findLeaks
具体是如何工作的。整个流程分为了三个步骤,分别对应三个关键方法:findRetainedObjects
、findPathsFromGcRoots
、buildLeakTraces
。
findRetainedObjects
顾名思义就是找到存活(泄漏)的所有对象,具体逻辑就是遍历由graph提供的objects集合,通过特定规则进行标记过滤,最后得到一组由存活对象id构成的集合。
这里的特定规则由多个ObjectInspector
组成,根据LeakCanary的文档描述,默认的Inspector是建立在AOSP / libraries / JDK / …等基础上的开发经验和知识总结而来的,即对于Android而言(具体类AndroidObjectInspectors
),我们知道哪些地方是可以造成内存泄漏的,因此只需要在内存快照中依次遍历特定的对象查看其是否 满足 / 肯定不满足 泄漏条件即可知道具体的泄漏对象,这些特定对象包括:
- android.widget.View间接引用的Activity的mDestroyed字段;
- android.widget.Editor内部的mTextView字段;
- android.app.Activity的mDestoryed字段;
- android.content.ContextWrapper间接引用的Activity的mDestroyed字段;
- android.app.Dialog的mDecor字段;
- Fragment的mFragmentManager字段;
- android.os.MessageQueue的mQuitting字段;
- android.view.ViewRootImpl的mView字段;
- android.view.Window的mDestroyed字段;
- android.widget.Toast中的mTN对象内部的mWM字段和mView字段;
对于JDK而言(具体类ObjectInspectors
),我们知道哪些地方肯定不会泄漏,例如:ClassLoader、Class、匿名Class等,以及对于单例而言(具体类AppSingletonInspector
),对象肯定不属于泄漏。
上述规则其实不难理解,内存泄漏的大致定义是:
无效的对象仍然与GcRoot保持了可达的引用路径,导致其无法被GC释放。
而「无效」是逻辑上的概念,GC是无法理解的,但作为开发人员是知道的,例如在Android平台上,应用具体的场景切换中我们是知道哪些对象的状态会转变为无效,例如销毁的Activity,因此才会在初始化时对其进行watch。
findPathsFromGcRoots
接下来就是找出所有从泄漏对象到GcRoot的引用路径链。总体思路是采用 广度优先遍历 算法从Roots向下查找,直到到达泄漏对象。由于涉及的代码繁多,这里仅阐述主要逻辑,整个过程是先从graph中得到的gcRoots被抽象为node按照优先级依次入队,作为队列的初始数据构成树根,然后开始从根节点开始遍历,对每个节点依据类型的不同采取对应的模式进行访问并得到所引用的对象集合,引用集合继续被抽象为node加入队列构成子节点以待后续遍历,如此反复直到当前遍历的node对象属于第一步中的泄漏对象,期间所经过的所有node便构成了从泄漏对象到GcRoot的引用路径。
上述过程中,用于维护遍历顺序的队列是通过toVisist、toVisitLast两对Queue和Set来构造的,其中Set用于保证树的遍历顺序始终遵从“从上至下”,使最终引用链结果不会包含更长的路径;两种Queue则提供一个优先级分级的概念,用于保证GcRoots中的ThreadObject优先于JavaFrame被遍历(当然也用于降低遍历LibraryLeak类型的泄漏的优先级),这样也是为了得到离泄漏对象最近的引用路径,避免得到形如thread-…->frame-…>thread-…>obj这样的结果。
除此之外,针对已知泄露(例如Android Framework内部的一些已知泄漏)的剔除也是在这里实现的,避免检测过程中反复打扰。已知泄漏使用ReferenceMatcher
来描述,泄漏的具体引用使用ReferencePattern
描述,分为threadLocal-variable、static-field、instance-field和native-globalVariable四种类型,并在遍历入队过程中,根据对象的类型不同,针对HeapClass、HeapInstance、HeapObjectArray提取对应的name在相应的已知泄漏集合中进行判断,以决定是否剔除。
当然还包括一些其它细节,例如在遍历入队过程中对基本类型和String等对象的剔除,都是为了得到更短更低重复性的路径集合。
buildLeakTraces
我们知道,一个对象被多个对象引用是很常见的行为,前面得到的引用路径集合泄漏对象和GcRoot之间可能存在多条路径,为了更利于分析,还需要进行裁剪。
裁剪的过程并不复杂,首先先将路径链反转为从GcRoot到泄漏对象的方向,然后通过updateTrie
方法转化为一个以无效node为根节点的树,最后再通过 深度优先遍历 算法得到从根节点(无效node的children)到叶子节点的所有路径,即为最终的最短路径。其中裁剪的逻辑就发生在构造树的过程中:
1 | private fun deduplicateShortestPaths(inputPathResults: List<ReferencePathNode>): List<ReferencePathNode> { |
方法deduplicateShortestPaths
中的rootTrieNode
作为树的根节点,其children指向所有的GcRoot;在注释1处,如果某条路径遍历到最后一个节点时发现起父节点parentNode的children中已经存在自身,即GcRoot和泄漏对象相同,说明此前已有一条相等或更长的重复路径,那么此处的覆盖就将更长的引用链丢弃(裁剪)了。
至此主要工作结束,剩余的逻辑主要是为引用路径链的每个节点赋予详细的描述信息,包括通过objectInspectors重新inspect等,最终构建为applicationLeaks和LibraryLeaks两种形式的泄露描述并得到HeapAnalysis,在Service中通过DefaultOnHeapAnalyzedListener写入数据库并Notification提示,这些后续逻辑相对简单,就不一一细说了。
总结
LeakCanary的整个工作流程从向Activity/Fragment注册生命周期监听开始,当组件销毁时触发对对应对象的观察,利用WeakReference和手动触发GC双重机制来判断对象是否泄露,并在对象可能确实泄露时dump内存快照利用shark进行解析和分析,整个分析过程分为三个流程,先依据Android平台的特性和开发经验找出目标(泄露)对象,然后从GcRoot开始遍历,采用广度优先遍历算法得到所有符合条件的引用路径链,最终裁剪得到泄露对象到GcRoot的最短引用路径链,整个流程结束。
为了更详细地阐述整个流程,本文省略了很多旁枝末节和代码,但不妨碍理解,可以看到从注册对关键对象的观察,到判断对象是否泄漏,再到最后dump内存快照进行分析直至确定最后结果,LeakCanary的实现逻辑还是非常清晰和高效的。但同时我们也发现,LeakCanary也不是万能的,如何确定一个对象是否有效,它的规则并不能覆盖到所有的业务场景。