LeakCanary源码解析:从WeakReference到HeapDump的完整链路

LeakCanary 的核心检测机制建立在可达性分析之上——通过 WeakReference + ReferenceQueue 判断对象是否被 GC 回收,五秒延迟后仍未回收即判定为泄漏;ObjectWatcher 负责监控,HeapDumpTrigger 负责决策何时 Dump,整个链路设计精巧且克制

LeakCanary 内存泄漏 WeakReference HeapDump GC 可达性分析 Shark

目录导航(点击跳转)

一、引用计数 vs 可达性分析:GC 的两种判定策略

1引用计数法的致命缺陷:循环引用

引用计数(Reference Counting)是最直观的垃圾判定策略:每个对象维护一个计数器,记录有多少个引用指向它。当计数器归零时,对象即可被回收。但这种策略有一个致命缺陷——识别不了循环引用

class Node(val name: String) {
    var next: Node? = null
}

fun demonstrateCircularReference() {
    val a = Node("A")
    val b = Node("B")
    a.next = b   // B 的引用计数 = 1
    b.next = a   // A 的引用计数 = 1 (b.next 指向 a)
    // 此时 a 和 b 的引用计数都不为 0,即使我们已经无法访问它们
    // 如果此处不再有外部引用指向 a 或 b,引用计数法无法回收它们
}
问题本质:即使 ab 已经不再被外部引用,它们互相持有对方的引用,导致引用计数永远不为零。在真实的 Android 应用中,Activity 持有 View 的引用、View 持有 Context 的引用、Handler 持有 Activity 的引用……对象图远比这个例子复杂,循环引用无处不在。

JVM 采用的 GC 核心是可达性分析(Reachability Analysis),而非引用计数。它的基本思路是:从一组称为 GC Roots 的根对象出发,沿着引用链向下搜索,所有能被到达的对象都是"存活"的,无法到达的对象就是"垃圾"。

2GC Roots 可达性分析:从根出发的"可达性判定"

GC Roots 是一组特殊的根对象,包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象
  • Java 虚拟机内部的引用(基本数据类型对应的 Class 对象、常驻的异常对象等)
  • 所有被同步锁(synchronized)持有的对象

以下图为例说明循环引用场景下两种算法的差异:

  • GC Root(栈帧变量)
    • 外部可达对象

而两个互相引用的对象 A 和 B,如果没有被任何 GC Root 直接或间接引用,它们就是不可达的——无论它们之间的引用计数是多少:

引用计数法

A.refCount=1 (B指向A) + B.refCount=1 (A指向B)

A 和 B 都无法回收

可达性分析

GC Roots 出发, A 和 B 均不可达

A 和 B 都可回收

3为什么 LeakCanary 依赖可达性分析

LeakCanary 在后续分析 .hprof 文件时(由 Shark 库完成),正是基于可达性分析算法来构建引用链(Reference Chain)。它从 GC Roots 出发,寻找一条到达"泄漏对象"的最短路径

关键结论:如果只有引用计数法,Shark 就无法在存在循环引用的复杂对象图中定位真正的泄漏根因。可达性分析不仅是 JVM GC 的理论基础,也是 LeakCanary 能够精准定位泄漏源头的算法前提。

引用链的最终呈现形式就是我们在 LeakCanary 通知中看到的那条路径,例如:

GC Root
静态字段
MyApplication.someField
1
中间节点
SomeManager 持有 activity 引用
Leak
泄漏对象
LeakedActivity 实例

二、WeakReference 与 ReferenceQueue:泄漏检测的核心机制

1强引用与弱引用的根本区别

在 Java 中,一个对象只要被强引用指向,就不会被 GC 回收:

var activity = Activity()  // 强引用,GC 不会回收它

WeakReference 是一种弱引用:它指向一个对象,但不阻止 GC 回收它。当没有强引用指向对象时,GC 就会回收它,同时把这个 WeakReference 放入一个 ReferenceQueue:

val queue = ReferenceQueue<Any>()
val ref = WeakReference(mActivity, queue)

// 当 mActivity 被 GC 回收后,ref 会被放入 queue
// 此时 queue.poll() 就能拿到这个 ref
LeakCanary 正是利用这个机制:延迟一段时间后,如果 WeakReference 仍然没有进入 ReferenceQueue(即 queue.poll() 拿不到它),说明对象仍然被强引用指向——这就是内存泄漏。
2ReferenceQueue 的工作机制

ReferenceQueue 是 Java 引用框架的核心组件。它的工作原理如下:

1
创建 WeakReference
WeakReference(target, queue)
2
Target 失去强引用
只剩 WeakReference 指向它
3
GC 回收 Target
GC 线程将 WeakReference 入队
4
queue.poll() 返回 ref
确认对象已被回收

如果步骤 4 一直等不到——即 queue.poll() 始终返回 null——就意味着 target 对象仍然被某条强引用链持有,没有被 GC 回收。这就是内存泄漏的信号。

三、KeyedWeakReference:整个框架的基石

1KeyedWeakReference 源码逐行解析

KeyedWeakReference.kt 代码量不多,但它是整个 LeakCanary 的基石。它继承自 WeakReference,并在其基础上增加了泄漏判定所需的元数据:

class KeyedWeakReference(
  referent: Any,                              // 被监控的对象(如 Activity)
  val key: String,                            // 唯一标识(UUID)
  val description: String,                    // 描述信息(为什么监控它)
  val watchUptimeMillis: Long,                // 开始监控的时间戳
  referenceQueue: ReferenceQueue<Any>         // 关联的引用队列
) : WeakReference<Any>(
  referent, referenceQueue                    // 传给父类 WeakReference
) {
  @Volatile
  var retainedUptimeMillis = -1L              // 泄漏持续时间,-1 = 未泄漏

  override fun clear() {
    super.clear()
    retainedUptimeMillis = -1L
  }

  companion object {
    @Volatile
    @JvmStatic var heapDumpUptimeMillis = 0L  // 最近一次 Heap Dump 的时间戳
  }
}
retainedUptimeMillis 的含义:初始值为 -1L,标注该弱引用暂未被判定为泄漏。当五秒延迟后对象仍未被回收,该字段被设为当前时间戳,表示"泄漏已持续的时间"。所有判定逻辑都以这个字段是否等于 -1 为判断依据。
2clear() 方法:手动切断弱引用

重写的 clear() 方法深入调用链最终到达 Reference.clear0()

1
KeyedWeakReference.clear()
重置 retainedUptimeMillis = -1
2
WeakReference.clear()
调用 Reference.clear()
3
Reference.clear0() (native)
HotSpot VM C++ 实现,切断对 referent 的弱引用

clear0()Reference 类中的一个 native 方法,实现在 HotSpot VM 的 C++ 源码中。它的核心作用是主动切断对 referent 的弱引用,而不是等待 GC 来处理。这用于以下场景:

  • Heap Dump 完成后,清理已归档的引用
  • 手动停止监控某个对象时
  • 清理整个 watchedObjects 集合时
// Reference.java (JDK 源码)
public void clear() {
    this.referent = null;  // Java 层清空引用
}

// 对应的 native 方法在 HotSpot 中:
// 清除 referent 字段,使得下一次 GC 不会将该引用视为活跃引用
关键理解:GC 回收对象时会自动将 WeakReference 入队到 ReferenceQueue,但并不会自动调用 clear()clear() 是手动调用的,用于在业务流程中主动切断弱引用关系。两者是独立的机制——GC 入队是自动的,clear 是手动的。

四、ObjectWatcher:对象的监控中心

1ObjectWatcher 的类结构与核心字段

ObjectWatcher 实现了 ReachabilityWatcher 接口,是整个监控体系的核心调度者。先看它的核心字段:

class ObjectWatcher constructor(
  private val clock: Clock,                          // 时钟,提供时间戳
  private val checkRetainedExecutor: Executor,       // 延迟执行器(默认 5s 延迟)
  private val isEnabled: () -> Boolean = { true }    // 是否启用
) : ReachabilityWatcher {

  // 监听器集合:当对象被判定为泄漏时通知它们
  private val onObjectRetainedListeners = mutableSetOf<OnObjectRetainedListener>()

  // 核心数据结构:维护所有正在被监控的弱引用
  // key = UUID 字符串, value = KeyedWeakReference 实例
  private val watchedObjects = mutableMapOf<String, KeyedWeakReference>()

  // 引用队列:GC 回收对象后自动将 WeakReference 放入此队列
  private val queue = ReferenceQueue<Any>()
}
watchedObjects 是整个监控的核心:它是一个 HashMap,key 是创建 KeyedWeakReference 时生成的 UUID,value 是 KeyedWeakReference 实例。它的目的就是维护所有正在被观测的弱引用——如果有引用被回收了(出现在 ReferenceQueue 中),就从 Map 中移除;剩下的就是可能泄漏的。
2removeWeaklyReachableObjects:区分"已回收"与"可能泄漏"

这是 ObjectWatcher 中最基础也最频繁被调用的方法:

private fun removeWeaklyReachableObjects() {
  var ref: KeyedWeakReference?
  do {
    ref = queue.poll() as KeyedWeakReference?   // 从队列取出已回收的引用
    if (ref != null) {
      watchedObjects.remove(ref.key)            // 从监控 Map 中移除
    }
  } while (ref != null)                         // 循环直到队列清空
}

逻辑非常直接:不断从 ReferenceQueue 中 poll,poll 出来的就是已被 GC 回收的对象所对应的 WeakReference。既然已经被回收了,自然就不是泄漏,从 watchedObjects 中移除。循环结束后,Map 中剩下的就是尚未被回收的——它们可能是泄漏,也可能只是 GC 还没来得及回收。

queue.poll()
ref != null ?
watchedObjects.remove(ref.key)

几乎所有公开方法(hasRetainedObjects、retainedObjectCount、hasWatchedObjects、retainedObjects)都会先调用 removeWeaklyReachableObjects(),确保返回的数据是最新的。

3expectWeaklyReachable:监控的入口方法

这是 ObjectWatcher 最核心的方法,也是外部监控一个对象的唯一入口。它是线程安全的(@Synchronized):

@Synchronized override fun expectWeaklyReachable(
  watchedObject: Any,
  description: String
) {
  if (!isEnabled()) {
    return                                          // 未启用则直接返回
  }
  removeWeaklyReachableObjects()                     // 先清理已回收的引用
  val key = UUID.randomUUID().toString()             // 生成唯一标识
  val watchUptimeMillis = clock.uptimeMillis()       // 记录开始监控时间
  val reference = KeyedWeakReference(
    watchedObject, key, description, watchUptimeMillis, queue
  )

  watchedObjects[key] = reference                    // 放入监控 Map

  checkRetainedExecutor.execute {                    // 延迟 5 秒后执行
    moveToRetained(key)                              // 判定是否泄漏
  }
}

它的语义非常清晰:

  1. 对象已经被创建了(比如 Activity 调用了 onDestroy())
  2. 我们期望它能被 GC 回收
  3. 创建一个 WeakReference 指向它,放入 watchedObjects 监控
  4. 如果过了 5 秒,这个 WeakReference 还没被 ReferenceQueue 回收,说明对象泄漏了
4moveToRetained:最终的泄漏判定

5 秒延迟后执行的方法,是泄漏判定的"终审":

@Synchronized private fun moveToRetained(key: String) {
  removeWeaklyReachableObjects()                     // 最后再清理一次
  val retainedRef = watchedObjects[key]
  if (retainedRef != null) {                         // key 还在 Map 中 = 未被回收
    retainedRef.retainedUptimeMillis = clock.uptimeMillis()  // 标记为泄漏
    onObjectRetainedListeners.forEach { it.onObjectRetained() }  // 通知所有监听器
  }
}

逻辑非常简洁:

  1. 再次调用 removeWeaklyReachableObjects() 做最后的清理
  2. 检查 watchedObjects 中是否还存在该 key
  3. 如果存在,就将 retainedUptimeMillis 设为当前时间(从 -1 变为正数,标记为泄漏)
  4. 通知所有 OnObjectRetainedListener,触发后续的 Heap Dump 流程
为什么不直接在这里 Dump?moveToRetained 只负责判定和通知。是否真的执行 Heap Dump,取决于 HeapDumpTrigger 中的更复杂决策(泄漏数量是否达到阈值、距离上次 Dump 是否够 60 秒、应用是否在前台等)。ObjectWatcher 和 HeapDumpTrigger 的职责是分离的。
5ObjectWatcher 的本质

回顾整个 ObjectWatcher 的设计,它的本质可以总结为三样东西的组合:

01

Map (watchedObjects)

维护所有正在被监控的弱引用,以 UUID 为 key 实现 O(1) 查找和移除

02

ReferenceQueue

接收已被 GC 回收的引用,通过 poll() 持续清空队列来同步回收状态

03

5 秒延迟

给 GC 足够的时间回收对象,避免误判。5 秒后仍留在 Map 中的就是泄漏候选

五、HeapDumpTrigger:从检测到 Dump 的决策引擎

1Heap Dump 是什么

在深入 HeapDumpTrigger 之前,先理解 Heap Dump 的概念:

Heap Dump(堆转储)是 Java 虚拟机(JVM/ART)堆内存中所有对象、类、引用关系和实例数据的一份完整快照文件.hprof 格式)。其中包括对象间的引用关系,通过分析快照即可找出泄漏的对象和引用链。

Heap Dump 是一个昂贵的操作:它会暂停应用,遍历整个堆,将所有对象序列化到磁盘。因此 LeakCanary 需要精心的决策逻辑来避免频繁 Dump

2checkRetainedObjects:核心决策方法

这是 HeapDumpTrigger 最核心的方法。当 ObjectWatcher 判定有对象泄漏后,最终会调用到这里。它的决策链路如下:

1
Dump 是否被禁用?
2
GC 后是否还有泄漏?
3
数量 < 5 且前台?
4
距上次 Dump > 60s?

逐步展开来看。首先判断当前环境是否支持 Dump:

private fun checkRetainedObjects() {
  val iCanHasHeap = HeapDumpControl.iCanHasHeap()
  // ...
  if (iCanHasHeap is Nope) {
    // Dump 被禁用:通知用户或静默跳过
    return
  }

  // 获取泄漏对象数量
  var retainedReferenceCount = objectWatcher.retainedObjectCount

  // 如果大于 0,主动触发一次 GC 后再检查
  if (retainedReferenceCount > 0) {
    gcTrigger.runGc()
    retainedReferenceCount = objectWatcher.retainedObjectCount
  }

  // 检查数量是否达到阈值
  if (checkRetainedCount(retainedReferenceCount, config.retainedVisibleThreshold)) return

  // 检查距离上次 Dump 是否够 60 秒
  val now = SystemClock.uptimeMillis()
  val elapsedSinceLastDumpMillis = now - lastHeapDumpUptimeMillis
  if (elapsedSinceLastDumpMillis < WAIT_BETWEEN_HEAP_DUMPS_MILLIS) {
    scheduleRetainedObjectCheck(
      delayMillis = WAIT_BETWEEN_HEAP_DUMPS_MILLIS - elapsedSinceLastDumpMillis
    )
    return
  }

  // 全部条件通过 → 执行 Dump
  dumpHeap(retainedReferenceCount = retainedReferenceCount, retry = true, reason = "...")
}
重要细节:在获取泄漏数量后,如果大于 0,会先调用 gcTrigger.runGc() 主动触发一次 GC,然后再获取一次数量。这是为了确保在此期间没有对象被 GC 回收,尽可能避免误判。如果 GC 后数量变为 0,后续的 checkRetainedCount 会直接返回 true(无需 Dump)。
3ICanHazHeap:Dump 权限控制

HeapDumpControl.iCanHasHeap() 返回一个密封类,用于表达当前环境是否允许 Dump:

sealed class ICanHazHeap {
  object Yup : ICanHazHeap()                          // 允许 Dump
  abstract class Nope(val reason: () -> String) : ICanHazHeap()  // 不允许
  class SilentNope(reason: () -> String) : Nope(reason)           // 静默拒绝
  class NotifyingNope(reason: () -> String) : Nope(reason)        // 拒绝并通知用户
}

当返回 NotifyingNope 时,HeapDumpTrigger 仍然会先检查是否有泄漏对象,如果有就主动 GC 再确认一次,然后将禁用原因通知给用户。这样即使 Dump 功能被禁用,用户仍然能看到"有 N 个对象泄漏了"的信息。

4checkRetainedCount:阈值与观察策略

这个方法决定了"要不要现在 Dump",核心逻辑围绕一个阈值展开:

private fun checkRetainedCount(
  retainedKeysCount: Int,
  retainedVisibleThreshold: Int,    // 默认值为 5
): Boolean {
  // ...

  if (retainedKeysCount == 0) {
    // 没有泄漏 → 通知"全部回收",return true(不需要 Dump)
    return true
  }

  if (retainedKeysCount < retainedVisibleThreshold) {   // 少于 5 个
    if (applicationVisible || applicationInvisibleLessThanWatchPeriod) {
      // 应用可见或刚进入后台 → 通知"观察中",2 秒后再查
      showRetainedCountNotification(...)
      scheduleRetainedObjectCheck(
        delayMillis = WAIT_FOR_OBJECT_THRESHOLD_MILLIS  // 2000ms
      )
      return true    // 暂不 Dump,再等等
    }
  }

  return false   // 数量 >= 5 或应用已长时间不可见 → 允许 Dump
}

决策逻辑总结:

为什么阈值是 5?这是一个经验值。如果只有 1-2 个对象泄漏,可能是因为 GC 还没来得及回收(即使在主动触发 GC 之后),或者泄漏影响很小。等到累积到 5 个再 Dump,可以避免频繁的 Heap Dump 对用户体验的影响。同时,如果应用已经长时间不可见,即使不到 5 个也会立即 Dump,因为此时对用户体验影响小。
条件
行为
返回值
泄漏数 = 0
通知"全部回收"
true(停止)
泄漏数 1-4 + 前台
通知"观察中",2s 后再查
true(等待)
泄漏数 1-4 + 后台久
直接进入 Dump 流程
false(继续)
泄漏数 >= 5
直接进入 Dump 流程
false(继续)
5scheduleRetainedObjectCheck:延迟重新检查

当条件不满足时(数量不够或距离上次 Dump 太近),会调用这个方法安排延迟检查:

fun scheduleRetainedObjectCheck(delayMillis: Long = 0L) {
  val checkCurrentlyScheduledAt = checkScheduledAt
  if (checkCurrentlyScheduledAt > 0) {
    return                            // 已有任务在排队,不重复调度
  }
  checkScheduledAt = SystemClock.uptimeMillis() + delayMillis
  backgroundHandler.postDelayed({
    checkScheduledAt = 0
    checkRetainedObjects()            // 重新走一遍完整决策流程
  }, delayMillis)
}

这个 Handler 运行在名为 "LeakCanary-Heap-Dump" 的后台线程上:

// InternalLeakCanary.kt 中的初始化
val handlerThread = HandlerThread(LEAK_CANARY_THREAD_NAME)
handlerThread.start()
val backgroundHandler = Handler(handlerThread.looper)
heapDumpTrigger = HeapDumpTrigger(
  application, backgroundHandler, AppWatcher.objectWatcher, gcTrigger, configProvider
)
防重入设计:checkScheduledAt 充当了一个简单的互斥锁。如果已经有检查任务在排队(值 > 0),新的调度请求会被直接忽略。这避免了在短时间内堆积大量重复的检查任务。
6dumpHeap:执行堆转储

当所有检查都通过后,最终执行 Heap Dump:

private fun dumpHeap(
  retainedReferenceCount: Int,
  retry: Boolean,
  reason: String
) {
  val heapDumpFile = directoryProvider.newHeapDumpFile()    // 创建 .hprof 文件
  val heapDumpUptimeMillis = SystemClock.uptimeMillis()     // 记录时间戳
  KeyedWeakReference.heapDumpUptimeMillis = heapDumpUptimeMillis

  // 执行堆转储(将当前堆中的所有对象写入文件)
  configProvider().heapDumper.dumpHeap(heapDumpFile)

  // 清理本次 Dump 之前的所有 WeakReference
  objectWatcher.clearObjectsWatchedBefore(heapDumpUptimeMillis)
}

主要逻辑分四步:

1
创建 .hprof 文件
通过 DirectoryProvider 获取文件路径
2
记录时间戳
写入 KeyedWeakReference.heapDumpUptimeMillis
3
执行堆转储
heapDumper.dumpHeap(file) → 写入所有对象到文件
4
清理已归档引用
clearObjectsWatchedBefore(timestamp)

第 4 步很重要:Dump 完成后,堆里的 KeyedWeakReference 实例已经写入了 hprof 文件,运行时不再需要这些引用,所以将它们从 watchedObjects 中清理掉。Shark 库后续会读取 hprof 文件进行引用链分析。

7onDumpHeapReceived:用户手动触发入口

与自动监控流程不同,用户点击通知或调用 LeakCanary.dumpHeap() 时走的是这条路径:

fun onDumpHeapReceived(forceDump: Boolean) {
  backgroundHandler.post {
    gcTrigger.runGc()                            // 先尝试触发 GC
    val count = objectWatcher.retainedObjectCount
    if (!forceDump && count == 0) {
      return                                     // 非强制 + 无泄漏 → 不 Dump
    }
    dumpHeap(count, retry = false, reason = "user request")
  }
}
8完整决策树:从检测到 Dump 的全路径

将 HeapDumpTrigger 的整个决策过程可视化如下:

  • ObjectWatcher.onObjectRetained() 通知
    • scheduleRetainedObjectCheck()
      • checkRetainedObjects()
        • Dump 被禁用?→ 通知用户,停止
        • GC 后无 retained?→ 通知"全部回收",停止
        • 前台 + 数量 < 5?→ 通知"观察中",2s 后再查
        • 距上次 Dump < 60s?→ 通知"等待中",到时间再查
        • 全部通过 → dumpHeap()
          • 成功 → 清理 watchedObjects → 发 HeapDump 事件
          • 失败 → 5 秒后重试

六、核心思想总结

核心要点

完整调用链路一览
1
expectWeaklyReachable
Activity.onDestroy() → 创建 KeyedWeakReference 放入 Map
2
5 秒延迟
checkRetainedExecutor 在主线程 postDelayed
3
moveToRetained
poll 队列确认未回收 → 标记泄漏 → 通知 Listener
4
HeapDumpTrigger
GC 确认 → 阈值检查 → 间隔检查 → dumpHeap
5
Shark 分析 .hprof
GC Roots 出发构建引用链 → 定位泄漏根因
一句话总结

LeakCanary 用最少的代码、最简单的机制(WeakReference + ReferenceQueue + 5 秒延迟),配合克制的决策策略(5 个阈值 + 60 秒间隔),构建了一套精准、低开销的内存泄漏检测体系——它的精髓不在于"检测得多快",而在于"在合适的时机做合适的事"。