LeakCanary 2.x 是怎么找到内存泄漏的

LeakCanary 2.x 是怎么找到内存泄漏的

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] = weakReference

KeyedWeakReference 继承自 WeakReference,额外带了一个 key 字符串和一个 description。这个 key 是 UUID,用来在后续分析时精确定位"我们当时 watch 的是哪个对象"。referenceQueueWeakReference 构造函数的标准参数,当 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 接入项目里遇到过这个问题,最后被迫把 HeapDumpTriggerdumpHeap 调用改成直接执行,绕过了 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 在打开文件时先扫描一遍,建立两个索引:classDumpRecordsByIdinstanceDumpPositionsById,前者存类定义的偏移量,后者存实例记录的偏移量。这两个索引用 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 会截断,只保留关键节点。截断策略由 LeakTraceLeakingStatus 决定,每个节点会被判定为 NOT_LEAKINGLEAKINGUNKNOWN。这个判定靠 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 把 mDestroyedActivity 移到了父类 android.view.ContextThemeWrapper,导致 inspector 找不到字段,所有 Activity 泄漏被标记为 UNKNOWN。这个 bug 在 2.10 修复,改成了递归向上查找字段。


另一个容易出问题的 inspector 是 MESSAGE_QUEUE。Handler 的内存泄漏是 Android 经典场景,但 LeakCanary 2.x 的判定逻辑是检查 MessageQueue 里是否有 Messagetarget 指向被泄漏的 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.elementDataHashMap.table 这种内部结构,让人摸不着头脑。


Shark 2.14 引入了 LeakTraceObjectlabels 机制, inspector 可以给节点打标签解释语义。比如 AndroidObjectInspectors.VIEW 会给 View 节点打上 View.mIDView.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,路径显示的是链表节点的 mEndmStart 指针,读起来像在看链表遍历。我后来给项目写了个自定义 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 的,通过 ActivityWatcherFragmentAndViewModelWatcher 注册生命周期回调。如果你的 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,提供了 GoodWatcherLeakCanary.Config 的精简配置,意图很明显:他们也在探索生产环境的轻量监控。


我实际在生产环境用过 LeakCanary 2.12 的定制版本,做法是:


  • 关闭自动 watch Activity/Fragment,只手动 watch 核心单例对象
  • retainedVisibleThreshold 调到 1,但只在 App 退后台时触发 dump
  • HeapDumper 的自定义实现,把 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 输入法框架的内部类,跟 InputMethodManagermServedInputConnection 机制有关。


    我一开始以为是我们的代码在 Activity 销毁后还持有 EditText 引用,但排查了所有 TextWatcherOnFocusChangeListener 的注册注销,都没问题。后来用 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 源码时,我发现 ObjectWatcherwatchedObjects 是个 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 偶尔会出现"内存占用突然上涨又回落"的现象。


    另一个细节是 KeyedWeakReferencekey 用 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-watcherleakcanary-android-coreshark 三个模块里,值得花时间读一遍。特别是 HeapAnalyzer.findLeak 的 dominator tree 简化实现,比读任何算法教材都直观。

    Room 的 FTS 全文搜索,比 SQLite LIKE 快多少 2026-05-30
    Compose 动画的 animate*AsState 底层怎么实现的 2026-06-01

    评论区