leakcanary
线程栈中的局部变量表引用的所有变量,即运行线程中引用到的所有变量,包括线程中方法参数和局部变量
存活的线程对象
native 的 jni引用
class 对象 (classLoader 不会卸载class)
引用类型的静态变量
// 1跟2其实说的是一个东西
Reference queues, to which registered reference objects are appended by the garbage collector after the appropriate reachability changes are detected.
在检测到适当的可达性改变后,垃圾收集器将注册的引用对象(WeakReference)追加到引用队列(ReferenceQueue)。
核心思路是:
leakCanary做法是ondestory后手动出发GC,GC过后对象WeakReference一直不被加入 ReferenceQueue,它可能存在内存泄漏。
利用 双参初始化的弱引用 WeakReference(T referent, ReferenceQueue<? super T> q) 在object对象变成弱可达的时候(可视为已被回收/(其他非强引用也一样)),会将该WeakReference对象入队q中 的特性(也就是queque中最后会存在已经被回收了的weakreference对象),通过在Activity和Fragment的onDestroy()中,将该Activity或Fragment实例的弱引用初始化(双参object,queue)后放入map中(key随机固定uuid),GC后 把map中的 queue包含的对象 移除,map中剩余的即为可能泄露的对象
KOOM
由于leakCanary频繁的Gc时带来的STW(stop the world)会导致明显的卡顿,不适合作为线上监控机制
KOOM采用内存阈值监控来触发镜像采集,将对象是否泄漏的判断延迟到了解析时,阈值监控只要在常驻的子线程中每隔五秒获取关注的几个内存指标。达到阈值则直接suspendAndFork主进程,在新进程(由于fork自主进程,copy_on_write机制因此拥有主进程一样的进程信息)进行dump,之后resumeAndWait主进程。新进程会裁剪dump文件保存。
- 裁剪时KOOM会根据堆类型进行裁剪:
- 针对system space(Zygote Space、Image Space):会裁剪PRIMITIVE_ARRAY_DUMP、HEAP_DUMP_INFO、INSTANCE_DUMP和OBJECT_ARRAY_DUMP这4个子TAG,(也就是只保留了CLASS DUMP)会删除这四个子TAG的全部内容(包函子TAG全都会删除)。
- 针对app space:会处理PRIMITIVE_ARRAY_DUMP这一块数据,但会保留metadata,方便回填。
用于监控应用的 Java 内存泄漏问题,它的核心原理
周期性查询Java堆内存、线程数、文件描述符数等资源占用情况,当连续多次超过设定阈值或突发性连续快速突破高阈值时,触发镜像采集
镜像采集采用
虚拟机supend->fork虚拟机进程->虚拟机resume->dump内存镜像
的策略,将传统Dump冻结进程20s的时间缩减至20ms以内基于shark执行镜像解析,并针对shark做了一系列调整用于提升性能,在手机设备测即可执行离线内存泄露判定与引用链查找,生成分析报告
泄露是如何判定的?
对于Activity和Fragment当对象已经销毁却仍有从GC Root到此对象的引用路径时,认为此对象已经泄露
Activity的销毁判定规则为,mFinished或mDestroyed值为true
Fragment的销毁判定规则为,mFragmentManager为空且mCalled为true,mCalled为true表示此fragment已经经历了一些生命周期
//识别到监控的activity在gc后迟迟不加入ReferenceQueue,会触发dump hprof(Android 设备上,要 dump 当前内存快照,一般会调用 Debug.dumpHprofData()
方法)
Hprof裁剪
主要是用leakcanary的shark组件,LeakCanary 用 shark 组件来实现裁剪 hprof 文件功能,在 shark-cli 工具中,我们可以通过添加 strip-hprof
选项来裁剪 hprof 文件,它的实现思路是:通过将所有基本类型数组替换为空数组(大小不变)。
各家框架基本都会对这份hprof再进行裁剪:
Matrix:Matrix 的裁剪思路主要是将除了部分字符串和 Bitmap 以外实例对象中的 buffer 数组。之所以保留 Bitmap 是因为 Matirx 有个检测重复 Bitmap 的功能,会对 Bitmap 的 buffer 数组做一次 MD5 操作来判断是否重复。
KOOM 通过 xhook 实现 PLT hook,通过 hook 两个虚拟机方法 open()
和 writ()
来实现裁剪 hprof
KOOM 裁剪时会根据堆类型进行裁剪:
- 针对system space(Zygote Space、Image Space):会裁剪PRIMITIVE_ARRAY_DUMP、HEAP_DUMP_INFO、INSTANCE_DUMP和OBJECT_ARRAY_DUMP这4个子TAG,(也就是只保留了CLASS DUMP)会删除这四个子TAG的全部内容(包函子TAG全都会删除)。
- 针对app space:会处理PRIMITIVE_ARRAY_DUMP这一块数据,但会保留metadata,方便回填。
//注:LeakCanary2 重写了一个解析 hprof 文件的库,叫做 shark,它是用来代替原来的 haha,根据官方的说法,相比于 haha,shark 内存减少了10倍,速度快了6倍。KOOM 除了使用 shark 来解析,还在这个的基础上做了一些优化,减少了内存的占用,具体可以看源码。
Hprof分析
Matrix:
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的最短强引用链找出来即可。
美团Probe
Probe 还优化分析泄露对象的链路,因为 Probe 相对于其他方案,理论上它是支持所有对象的内存泄露检测的(排除了原始类型等),而 LeakCanary 和 Matrix 只支持 Activity 和 Fragment 等对象,这个优化是基于 RetainSize 越大的对象对内存的影响也越大,是最有可能造成OOM的“元凶” 这一原则。
首先,在 dump 出所有对象后,创建一个 TOP N 的小根堆,根据 RetainSize 排序,初始 N 默认是 5,这里有个小细节,就是 Probe 会处理 ByteArray 类型的实例:
1
2
3
4 复制代码if (isByteArray(var6)) {
var6.parent.addRetainedSize(var1.getHeapIndex(var4), var6.getTotalRetainedSize());
var15.add(var6.parent);
}将 ByteArray 的 RetainSize 加到它的父节点,同时将它的父节点加到堆中。
在初始化小根堆后,继续遍历做动态调整,将大于文件大小 5% 的对象直接加到堆中,同时增大 TOP N 的值,否则,跟堆顶元素做比较。在遍历对象的同时,如果是相同类的不同实例,则只会保存一份实例,同时将它们的 RetainSize 和 Num 计算进去:
1
2
3
4
5 复制代码if (var3.containsKey(var9)) {
InstanceExtra var17 = (InstanceExtra)var3.get(var9);
var17.retainSize += var6.getTotalRetainedSize();
++var17.num;
}还会将在 计数压缩逻辑 中 RetainSize 补回来:
1
2
3
4
5
6
7
8
9 复制代码if (var10 != null) {
var16.retainSize += var10.getSize();
long var11 = var16.num;
var16.num = (long)var10.getCount() + var11;
ClassCountInfo var18 = (ClassCountInfo)AbandonedInstanceManager.getInstance().countMap.get(var9);
if (var18 != null && var18.classId > 0L) {
var16.id = var18.classId;
}
}
关于 OOM
Out of memory (OOM) 当系统无法满足申请的内存大小时,就会抛出 OOM 错误。导致 OOM 的原因,除了我们上面说讲的内存泄露以外,可能还会是线程创建超过限制,可以通过 /proc/sys/kernel/threads-max 获取:
1 | cat /proc/sys/kernel/threads-max |
还有一种可能是 FD(File descriptor)文件描述符超过限制,可以通过 /proc/pid/limits 获取,其中 Max open files 就是可创建的文件描述符数量:
1 | Limit Soft Limit Hard Limit Units |
LeakCanary 具体原理
具体流程如下:
Application中intall(新版本不用,是通过ContenProvider创建时拿到application对象后自动注册)。
然后 通过application.registerActivityLifecycleCallbacks 对每个activity 注册生命周期 onDestroy()回调,
并进一步,根据不同的Android API,兼容地对 AndroidX,AndroidO,Support lib等调用 activity.fragmentManager/activity.supportFragmentManager方法,监听fragment的生命周期 的 onDestroyView() 和 onDestroy()
在 activity/fragment 销毁的生命周期时,调用watch方法。执行removeWeaklyReachableObjects()//“移除watchedObjects中含有的queue元素含有的key
然后将该activity/fragment的弱引用对象KeyWeakReference添加入哈希表watchedObjects
(RandomUUID 为key,queue
为弱引用的第二参数队列)。
之后在子线程中再来一次移除弱可达对象removeWeaklyReachableObjects,接下来调用gc(休眠100ms),最后留存下来的watchedObjects 即为大概率泄露的对象
如果 处于调试状态/已检测泄露对象超过5个/据上一次dump时间小于60s return
dump时调用Debug.dumpHprofData()方法,获取堆信息存储到本地文件。然后在HeapAnalyzeService中进行分析(”shark库进行分析”)
1 |
|
API
https://square.github.io/leakcanary/recipes/#matching-known-library-leaks
- LeakCanary config
val dumpHeap: Boolean = true,
val dumpHeapWhenDebugging: Boolean = false,
val retainedVisibleThreshold: Int = 5,
val referenceMatchers: List
val onHeapAnalyzedListener: OnHeapAnalyzedListener = DefaultOnHeapAnalyzedListener.create(),
val metadataExtractor: MetadataExtractor = AndroidMetadataExtractor,
val computeRetainedHeapSize: Boolean = true,
val maxStoredHeapDumps: Int = 7,
val requestWriteExternalStoragePermission: Boolean = false,
val leakingObjectFinder: LeakingObjectFinder = KeyedWeakReferenceFinder,
val useExperimentalLeakFinders: Boolean = false
AppWatcher config
val watchActivities: Boolean = true,
val watchFragments: Boolean = true,
val watchFragmentViews: Boolean = true,
val watchViewModels: Boolean = true,
val watchDurationMillis: Long = TimeUnit.SECONDS.toMillis(5),
val enabled: Boolean = true分离进程分析泄漏
dependencies { // debugImplementation 'com.squareup.leakcanary:leakcanary-android:${version}' debugImplementation 'com.squareup.leakcanary:leakcanary-android-process:${version}' }
补充
- ActivityDestroyWatcher初始化:application注册监听Activity生命周期,并在Activity destroy的时候调用objectWatcher.watch()
- FragmentDestroyWatcher初始化:
- 如果android 8.0以上,注册AndroidOFragmentDestroyWatcher监听fragment生命周期
- 注册AndroidSupportFragmentDestroyWatcher监听fragment生命周期
- 注册AndroidXFragmentDestroyWatcher监听fragment生命周期
以上三种本质上都是通过传入的Activity.fragmentManager.registerFragmentLifecycleCallbacks进行监听fragment生命周期回调
- 通过InternalLeakCanary类的invoke函数:
- 初始化一些检测内存泄露过程中需要的对象。
- addOnObjectRetainedListener设置可能存在内存泄漏的回调。
- 通过AndroidHeapDumper进行内存泄漏之后进行 heap dump 任务。
- 通过GcTrigger 手动调用 GC 再次确认内存泄露。
- 启动内存泄漏检查的线程。
- 通过registerVisibilityListener监听应用程序的可见性。
实践
基于LeakCanary2.5。
问题:ViewPager切换Fragment时,业务上会将所有展示过的Fragment都加入列表手动缓存,而ViewPager的默认缓存机制是不超过三个,超出的Fragment根据LRU调用其onDestroyView生命周期移除其中的所有View。但对于业务来说我们不希望用户切换回来之后由于ViewPager的缓存限制而重新等待view的创建加载,同时也不希望开发者针对这种情况调用ViewPager的setOffscreenPageLimit()
的方式实现。
解决方案:
通过在初始化时通过配置AppWatcher.config
关闭LeakCanary对Fragment的泄露监听,然后自己实现一套(CV原代码改动)关于Fragment的泄露监听。
1 | //先配置,将Fragment及FragmentView的泄露从LeakCanary转到以下自己实现逻辑 |
1 | //#leakcanary.internal.AndroidXFragmentDestroyWatcher.class |