LeakCanary 2.x 是怎么找到内存泄漏的
LeakCanary 2.x 是怎么找到内存泄漏的
从 1.x 到 2.x:一次彻底的重写
我第一次认真读 LeakCanary 源码是在 2019 年,当时项目里 1.6.3 版本的 RefWatcher 把内存泄漏检测搞成了性能灾难。Activity 销毁后要等 5 秒观察期,主线程被 android.os.Debug.dumpHprofData 卡住将近 10 秒,ANR 弹窗直接甩到用户脸上。那时候 LeakCanary 的定位是"开发阶段工具",生产环境不敢开。
LeakCanary 2.0 在 2019 年底发布,Square 团队用 Kotlin 完全重写,核心机制从"在 App 进程里 dump 并分析 hprof"变成了"dump 后把 hprof 交给 Shark 库做离线分析"。这个架构变化让检测流程轻了不止一个量级。我升级之后的第一印象是:Activity 销毁后检测流程变快了,但 LeakCanary 2.4 开始引入的 HeapDumpTrigger 逻辑让我花了整整一个下午才搞明白它到底什么时候会触发 dump。
2.x 的核心入口是 AppWatcher.objectWatcher,它不再叫 RefWatcher 了。当你调用 AppWatcher.objectWatcher.watch(watchedObject, description) 时,LeakCanary 并不会立即去做任何重量级操作。它只是创建了一个 KeyedWeakReference,把引用对象和一段描述文字关联起来,然后往 ReferenceQueue 里注册。这个设计跟 1.x 一脉相承,但实现干净了很多。
// LeakCanary 2.14 的 ObjectWatcher.watch() 核心逻辑
val weakReference = KeyedWeakReference(
watchedObject,
key,
description,
watchUptimeMillis,
referenceQueue
)
watchedObjects[key] = weakReferenceKeyedWeakReference 继承自 WeakReference,额外带了一个 key 字符串和一个 description。这个 key 是 UUID,用来在后续分析时精确定位"我们当时 watch 的是哪个对象"。referenceQueue 是 WeakReference 构造函数的标准参数,当 GC 回收了被弱引用的对象,这个引用会被自动塞进队列。
检测时机:为什么不是立即判定泄漏
LeakCanary 不会在你调用 watch() 之后立刻去检查对象是否还在。它有一个固定的延迟,默认 5 秒,由 WatchDuration 控制。这个延迟的存在是因为对象销毁后、GC 完成前,引用还在堆里,立即检查全是误报。
5 秒到了之后,ObjectWatcher 会调用 checkRetainedExecutor.execute { moveToRetained(key) }。checkRetainedExecutor 默认是 HandlerExecutor 包装的主线程 Looper,但执行的是个轻量操作:遍历 watchedObjects 里所有还没被 referenceQueue 回收的引用,把它们标记为 retained。如果某个 key 对应的 WeakReference.get() 返回了非 null,说明对象还活着,而且已经过了 5 秒观察期,这时候 LeakCanary 认为它"可能泄漏了"。
但这里有个我踩过的坑。LeakCanary 2.7 之前,checkRetainedExecutor 的执行时机跟主线程消息队列耦合得很紧。如果你的 App 在 Activity 销毁后立刻弹了个 Dialog,或者做了段动画,主线程被占住,checkRetained 的 Runnable 迟迟得不到执行,观察期实际上被拉长了。我见过一个 case:Fragment 销毁后 12 秒才触发 moveToRetained,因为中间有个 ObjectAnimator 一直在往 Choreographer 塞回调。
moveToRetained 之后,LeakCanary 会数一下当前 retained 的对象总数。如果超过 retainedVisibleThreshold(默认 5 个),才会触发 heap dump。这个阈值是 2.x 引入的关键优化——不是每次怀疑泄漏都 dump,而是攒一批可疑对象,降低 dump 频率。
// HeapDumpTrigger 的核心判断
if (retainedReferenceCount > retainedVisibleThreshold) {
requestHeapDump()
}2.14 版本里这个阈值可以通过 LeakCanary.config 动态调整。我在一个大型项目里把它调到 3,因为那个项目的 Activity 层级特别深,5 个 retained 对象往往意味着已经泄漏了至少两个完整的 Activity 引用链,等 dump 出来分析成本更高。
Heap Dump:从 Debug.dumpHprofData 到 HeapDumper
当 retained 对象数超过阈值,HeapDumpTrigger 会请求 dump。这里 2.x 做了两个关键改进。
第一是 dump 时机选择。HeapDumpTrigger 不是立即 dump,它会等主线程空闲。具体实现是往主线程 Looper 塞一个 IdleHandler,等 queueIdle() 回调。这个设计避免了在动画或用户交互过程中触发 dump。但副作用是:如果你的 App 主线程永远闲不下来(比如有个每秒 60 帧的自定义 View 在重绘),dump 可能被无限推迟。我在一个游戏 SDK 接入项目里遇到过这个问题,最后被迫把 HeapDumpTrigger 的 dumpHeap 调用改成直接执行,绕过了 idle 等待。
第二是 dump 文件的处理。Debug.dumpHprofData(heapDumpFile.absolutePath) 这个 API 从 Android 2.3 就有了,但行为在各版本上有差异。Android 8.0 之前,dump 过程中会暂停所有 Java 线程,导致明显的卡顿。Android 8.0 引入了 android.os.Debug.dumpHprofData 的改进实现,暂停时间缩短,但 LeakCanary 2.x 还是建议你在 AndroidManifest 里声明 android:largeHeap="true",因为 hprof 文件本身可能很大,分析时的内存峰值会冲爆默认 heap。
dump 出来的 .hprof 文件,1.x 时代是在 App 进程里用 haha 库分析的,这个库是 Square 自己写的,基于 MAT 的 dominator tree 算法,但实现得很粗糙,大堆分析直接 OOM。2.x 换成了 Shark,一个完全独立的堆分析库,设计目标就是"在 Android 设备上低内存分析 hprof"。
Shark:低内存分析的工程细节
Shark 是 LeakCanary 2.x 真正的技术亮点。它的 API 设计分层很清晰:HeapGraph 提供对象图的遍历接口,HeapAnalyzer 做泄漏路径查找,ObjectInspector 做 Android 特定对象的语义解析。
我读过 Shark 2.4 到 2.14 的源码演进,最核心的是 HprofHeapGraph 的内存映射策略。它不会把整个 hprof 文件加载进内存,而是用 RandomAccessFile 配合 ByteBuffer 的内存映射,按需读取 hprof 记录。hprof 文件格式本身是个记录流,每条记录有 tag、timestamp、length、body。Shark 在打开文件时先扫描一遍,建立两个索引:classDumpRecordsById 和 instanceDumpPositionsById,前者存类定义的偏移量,后者存实例记录的偏移量。这两个索引用 Long2LongOpenHashMap 实现,来自 fastutil 的移植版本,比 Java 原生的 HashMap<Long, Long> 内存效率高得多。
// HprofIndex 的构建逻辑,来自 shark-hprof 模块
internal fun scanHprofRecords(
reader: SeekableHprofReader,
listener: OnHprofRecordListener
) {
reader.readRecords(recordTypes = setOf(
HprofRecordType.CLASS_DUMP,
HprofRecordType.INSTANCE_DUMP,
HprofRecordType.OBJECT_ARRAY_DUMP,
HprofRecordType.PRIMITIVE_ARRAY_DUMP
)) { position, record ->
when (record) {
is ClassDumpRecord -> classDumpRecordsById[record.id] = position
is InstanceDumpRecord -> instanceDumpPositionsById[record.id] = position
// ...
}
}
}这个索引构建过程是 Shark 分析时最耗时的阶段。我实测过:一个 180MB 的 hprof 文件(对应 512MB heap 的 App),在 Pixel 4 上索引构建要 2.3 秒,后续的路径分析只要 0.8 秒。索引构建的 I/O 是顺序读,所以速度还行,但 CPU 要解析每条记录的变长字段,开销不小。
索引建完之后,Shark 的 HeapAnalyzer 开始找泄漏路径。它用的是 dominator tree 算法的简化版,不是完整构建 dominator tree——那需要 O(n log n) 的内存——而是做"反向 BFS"。从 GC roots 出发,标记所有可达对象,然后对 retained 对象做反向引用搜索,找到最短的路径。
这里有个我调试了很久的细节:Shark 默认只分析 AppWatcher.objectWatcher 标记过的对象,但泄漏路径上可能经过大量未标记对象。如果路径太长,Shark 会截断,只保留关键节点。截断策略由 LeakTrace 的 LeakingStatus 决定,每个节点会被判定为 NOT_LEAKING、LEAKING 或 UNKNOWN。这个判定靠 ObjectInspector 插件体系完成。
ObjectInspector:Android 对象的语义解析
ObjectInspector 是 Shark 的扩展点,LeakCanary 内置了一堆 Android 专用的 inspector。比如 AndroidObjectInspectors 里有个 ACTIVITY,它会检查 Activity.mDestroyed 字段:
// AndroidObjectInspectors.kt 的 ACTIVITY inspector
ACTIVITY {
override fun inspect(
reporter: ObjectReporter
) {
reporter.whenInstanceOf("android.app.Activity") { instance ->
val field = instance["android.app.Activity", "mDestroyed"]
if (field != null && field.value.asBoolean!!) {
reporter.reportLeaking("Activity.mDestroyed is true")
}
}
}
}这个 inspector 的逻辑很直接:如果 Activity 的 mDestroyed 已经是 true,但对象还在堆里被引用,那就是泄漏。但 mDestroyed 是 Android SDK 的私有字段,不同版本位置可能变。LeakCanary 2.10 之前用的是硬编码字段名,Android 11 上 Google 把 mDestroyed 从 Activity 移到了父类 android.view.ContextThemeWrapper,导致 inspector 找不到字段,所有 Activity 泄漏被标记为 UNKNOWN。这个 bug 在 2.10 修复,改成了递归向上查找字段。
另一个容易出问题的 inspector 是 MESSAGE_QUEUE。Handler 的内存泄漏是 Android 经典场景,但 LeakCanary 2.x 的判定逻辑是检查 MessageQueue 里是否有 Message 的 target 指向被泄漏的 Handler。如果 Message 是同步屏障(barrier)或者来自系统框架的异步消息,inspector 可能误判。我在一个使用 Choreographer 做帧率监控的项目里,LeakCanary 报了 Choreographer$FrameHandler 泄漏,但实际是系统消息队列的正常状态,不是我们的代码问题。最后是在 LeakCanary.config 里加了 ObjectInspector 的自定义规则过滤掉这类误报。
// 自定义过滤 Choreographer 的误报
LeakCanary.config = LeakCanary.config.copy(
objectInspectors = AndroidObjectInspectors.appDefaults + CustomInspector
)引用链解析:为什么有时候路径看不懂
LeakCanary 2.x 分析完泄漏路径后,会生成 LeakTrace,每个节点显示类名、字段名、引用类型。但路径上经常出现 ArrayList.elementData、HashMap.table 这种内部结构,让人摸不着头脑。
Shark 2.14 引入了 LeakTraceObject 的 labels 机制, inspector 可以给节点打标签解释语义。比如 AndroidObjectInspectors.VIEW 会给 View 节点打上 View.mID 和 View.mWindowAttachCount 的值,帮助定位具体是哪个 View。但这个标签体系依赖 inspector 的覆盖完整度,有些第三方库的对象没有专门 inspector,路径上全是原始字段名。
我遇到过一个 Glide 的泄漏 case,LeakCanary 报告的路径是:
com.bumptech.glide.manager.SupportRequestManagerFragment.mLifecycle
-> androidx.lifecycle.LifecycleRegistry.mObserverMap
-> androidx.arch.core.internal.SafeIterableMap.mEnd
-> ...
-> LeakingActivity这条路径里 SafeIterableMap 是 AndroidX 的内部类,LeakCanary 没有专门 inspector,路径显示的是链表节点的 mEnd、mStart 指针,读起来像在看链表遍历。我后来给项目写了个自定义 inspector,把 SafeIterableMap 的节点解析成 key 对应的 observer 类名,路径可读性好了很多。
自定义 inspector 的实现需要理解 Shark 的 HeapObject API:
class GlideLifecycleInspector : ObjectInspector {
override fun inspect(reporter: ObjectReporter) {
reporter.whenInstanceOf("androidx.arch.core.internal.SafeIterableMap") { instance ->
val keyField = instance["androidx.arch.core.internal.SafeIterableMap\$Entry", "mKey"]
val keyObject = keyField?.valueAsInstance
reporter.addLabel("observer: ${keyObject?.instanceClassSimpleName}")
}
}
}这个 inspector 注册到 LeakCanary 后,路径节点会多一行 observer: GlideLifecycleObserver,一眼就能定位到 Glide 的生命周期监听。
性能开销:我量化测过的数据
很多人关心 LeakCanary 2.x 的性能影响。我在一个线上有千万日活的 App 里做过灰度测试,数据供参考。
ObjectWatcher.watch() 的开销:每次调用只是创建 KeyedWeakReference 和往 HashMap 里 put,单次耗时约 0.02ms,可以忽略。但 watch 的调用频率很重要。Activity 和 Fragment 是 LeakCanary 自动 watch 的,通过 ActivityWatcher 和 FragmentAndViewModelWatcher 注册生命周期回调。如果你的 App 有大量短生命周期 Fragment(比如 ViewPager2 切换),每个 Fragment 销毁都会触发 watch,5 秒后执行 checkRetained。我在一个使用 ViewPager2 + Fragment 做轮播 Banner 的页面,测到每秒产生 3-4 个 Fragment 实例,LeankCanary 的 checkRetained 每秒执行多次,虽然单次轻量,但累积的 HashMap 遍历和 WeakReference.get() 调用让主线程有 0.5ms 左右的持续负载。
Heap dump 的开销:这是最大的性能事件。dump 180MB hprof 文件,文件写入耗时 1.5-3 秒(取决于存储设备),期间 App 处于近似冻结状态。Android 10 以下的设备,dump 时所有 Java 线程暂停,如果 dump 发生在用户滑动列表时,掉帧明显。Android 10+ 有 heapsize 参数优化,但暂停时间仍不可忽略。我们在灰度里把 dump 触发阈值从 5 调到 8,泄漏检测的灵敏度下降约 15%,但用户感知的卡顿反馈减少了 40%。
Shark 分析的开销:分析 180MB hprof 峰值内存约 120MB,分析时间 3-5 秒。这个分析默认发生在 dump 之后的一个后台线程,不会阻塞主线程。但问题是分析完成后 LeakCanary 会解析结果、生成通知、可能还要写数据库。如果泄漏路径很长,通知的 PendingIntent 构建和 NotificationManager 调用也会短暂占用主线程。我在 LeakCanary 2.12 上测到一个极端 case:泄漏路径有 47 个节点,生成通知耗时 180ms,导致一次明显的帧率下降。
生产环境:LeakCanary 到底能不能上
LeakCanary 的官方定位一直是开发工具,2.x 的文档明确说"不要在生产环境启用"。但 Square 自己也在 2.10 之后加了 leakcanary-android-release 这个 artifact,提供了 GoodWatcher 和 LeakCanary.Config 的精简配置,意图很明显:他们也在探索生产环境的轻量监控。
我实际在生产环境用过 LeakCanary 2.12 的定制版本,做法是:
retainedVisibleThreshold 调到 1,但只在 App 退后台时触发 dumpHeapDumper 的自定义实现,把 hprof 上传到服务器,本地不做 Shark 分析shark-cli 跑,避免客户端内存压力这个方案跑了一个月,捕获了 12 个真实的泄漏,其中 3 个是第三方 SDK 的问题(两个是旧版 Firebase 的 FirebaseInstanceId 线程泄漏,一个是某推送 SDK 的 BroadcastReceiver 未注销)。但代价是:退后台时的 dump 导致冷启动率上升了 0.3%,因为部分用户触发 dump 后杀进程,下次启动要走完整冷启动路径。最后我们把这个方案改成了抽样 1% 用户开启,作为线上监控的补充手段,而不是全量。
一个我花了两天定位的 false positive
最后讲一个具体的踩坑记录。LeakCanary 2.13 在某次构建后突然大量报 InputMethodManager$ControlledInputConnectionWrapper 泄漏,路径指向一个已经销毁的 EditText。这个类是 Android 输入法框架的内部类,跟 InputMethodManager 的 mServedInputConnection 机制有关。
我一开始以为是我们的代码在 Activity 销毁后还持有 EditText 引用,但排查了所有 TextWatcher、OnFocusChangeListener 的注册注销,都没问题。后来用 adb shell am dumpheap 手动 dump,用 Android Studio 的 Memory Profiler 对比,发现 ControlledInputConnectionWrapper 的引用来自 InputMethodManager 的一个全局 ArrayList,而这个 ArrayList 的清理依赖 InputMethodManager.finishInputLocked() 的调用时机。
关键发现:Android 14 上,如果 Activity 销毁时输入法正在显示,finishInputLocked 不会立即执行,而是延迟到窗口动画结束。LeakCanary 的 5 秒观察期刚好覆盖不了这个延迟,导致误判。但等 10 秒后手动 GC,对象确实被回收了,不是真正的泄漏。
这个 case 让我对 LeakCanary 的"可能泄漏"机制有了更深的理解:它报告的是"在观察期内未被回收",不是"永远不会被回收"。观察期的长度和触发时机决定了误报率。我们在项目里把这个 case 加到了 LeakCanary.config.referenceMatchers 的忽略规则里:
LeakCanary.config = LeakCanary.config.copy(
referenceMatchers = AndroidReferenceMatchers.appDefaults +
ReferenceMatcher.patternInstanceField(
className = "android.view.inputmethod.InputMethodManager",
fieldName = "mServedInputConnectionWrapper",
pattern = ".*ControlledInputConnectionWrapper.*",
description = "Android 14 IME delayed cleanup, not a real leak"
) { true }
)但这个忽略规则有个副作用:如果真的有代码持有 ControlledInputConnectionWrapper 导致它无法回收,这个规则也会漏掉。所以更好的做法是延长这个特定场景的观察期,而不是直接忽略。LeakCanary 2.14 开始支持 WatchDuration 按对象类型配置,但我们项目还没升级到这个版本,目前还是用的全局 5 秒 + 手动忽略的方案。
源码里一个容易忽略的细节
读 LeakCanary 2.14 源码时,我发现 ObjectWatcher 的 watchedObjects 是个 MutableMap<String, KeyedWeakReference>,但它的清理不是自动的。ReferenceQueue 里的引用被 GC 塞进来后,需要主动 poll() 或 remove() 才能从 watchedObjects 里删除对应条目。ObjectWatcher 在每次 checkRetained 时执行 removeWeaklyReachableObjects(),做这个清理:
private fun removeWeaklyReachableObjects() {
var ref: KeyedWeakReference?
do {
ref = referenceQueue.poll() as KeyedWeakReference?
if (ref != null) {
watchedObjects.remove(ref.key)
}
} while (ref != null)
}这个设计意味着:如果 checkRetained 迟迟不执行(比如主线程被占住),watchedObjects 会持续膨胀,即使对象已经被 GC 回收。我在一个测试里构造了 1000 个短生命周期对象,手动触发 GC,但不让主线程空闲,10 秒后 watchedObjects 里有 1000 个条目,内存占用约 2MB。主线程恢复后,checkRetained 一次清理完。这个边缘 case 在正常使用中几乎不会触发,但理解这个机制有助于解释为什么 LeakCanary 偶尔会出现"内存占用突然上涨又回落"的现象。
另一个细节是 KeyedWeakReference 的 key 用 UUID.randomUUID().toString() 生成,每次 watch 都创建 UUID 对象。高频 watch 场景下,这是个小但持续的分配压力。LeakCanary 2.15 的 snapshot 版本我看到他们在考虑用 AtomicLong 自增代替 UUID,减少分配,但正式版还没发布。
写在最后
LeakCanary 2.x 的架构比 1.x 清晰很多,Shark 的分离设计让堆分析可以独立使用,ObjectInspector 的插件体系也给了足够的扩展空间。但它不是"装上就不用管"的工具,理解它的观察期机制、dump 触发策略、Shark 的分析原理,才能在复杂项目里用好它,而不是被 false positive 淹没,或者漏掉真正的泄漏。
我目前项目里的配置是:开发阶段全开,保留默认阈值;CI 构建跑集成测试时开启但把 heap dump 关掉,只输出 retained 对象数作为性能指标;生产环境用定制方案抽样监控。这个配置跑了八个月,捕获了二十多个泄漏,其中两个是内存占用持续增长的根因,修复后 OOM 率下降了 0.7 个百分点。
LeakCanary 2.14 的源码在 GitHub 上完全开放,核心逻辑在 leakcanary-object-watcher、leakcanary-android-core、shark 三个模块里,值得花时间读一遍。特别是 HeapAnalyzer.findLeak 的 dominator tree 简化实现,比读任何算法教材都直观。