LeakCanary内存收集
# 01.LeakCanary内存泄漏检测原理
# 目录介绍
- 01.整体概述介绍
- 1.1 项目背景介绍
- 1.2 内存泄漏概念
- 1.3 设计目标
- 1.4 产生收益
- 02.开发设计思路
- 2.1 整体设计思路
- 2.2 初始化思路
- 2.3 设计组件监听
- 2.4 无用对象监听
- 2.5 内存泄漏监听
- 2.6 Dump内存
- 2.7 分析内存快照
- 2.8 输出堆栈链报告
- 2.9 KOOM设计思路
- 03.LeakCanary原理
- 3.1 要思考一些问题
- 3.2 原理流程的概括
- 3.3 初始化流程
- 3.4 监听组件销毁
- 3.5 监听无用对象
- 3.6 监控内存泄漏
- 3.7 Dump内存快照
- 3.8 分析堆快照
- 3.9 输出分析报告
- 3.10 ObjectWatcher内部状态转换完整分析
- 04.一些技术点思考
- 4.1 WeakReference与ReferenceQueue机制
- 4.2 引用链如何生成
- 4.3 Shark分析引擎原理
- 4.4 提高Dump分析效率
- 4.5 相同问题分组
- 4.6 如何标记怀疑对象
- 4.7 GC触发与等待策略深度解析
- 4.8 堆快照解析的两次遍历策略深度解析
- 05.优秀代码设计解析
- 5.1 设计模式在LeakCanary中的应用
- 5.2 sealed class的巧妙使用
- 5.3 委托模式与扩展函数的运用
- 5.4 KeyedWeakReference的设计
- 5.5 Clock抽象与可测试性设计
- 5.6 Shark引擎中的图遍历算法设计
- 06.方案基础设计
- 6.1 整体架构图
- 6.2 UML设计图
- 6.3 关键流程图
- 6.4 接口设计图
- 6.5 模块间依赖关系
- 07.其他设计说明
- 7.1 性能设计优化
- 7.2 稳定性设计
- 7.3 灰度设计
- 7.4 降级设计
- 7.5 异常设计优化
- 08.线上内存泄漏检测方案
- 8.1 LeakCanary为何不能用于线上
- 8.2 KOOM线上检测方案
- 8.3 线上内存泄漏监控体系
- 8.4 各方案对比分析
- 09.常见面试题深度解析
- 9.1 经典面试问题
- 9.2 进阶面试问题
- 9.3 学习建议
# 01.整体概述介绍
# 1.1 项目背景介绍
在Android应用开发中,内存泄漏是最常见且危害极大的性能问题之一。内存泄漏会导致应用可用内存逐渐减少,当堆积到一定程度时就会触发频繁GC甚至OOM崩溃,严重影响用户体验。
然而内存泄漏的排查一直是一个困难的工作。传统方式需要开发者手动使用Android Profiler或MAT工具分析hprof文件,这个过程非常耗时且需要专业知识。而且很多内存泄漏在开发测试阶段不容易被发现,往往到线上才暴露问题。
LeakCanary是Square公司开源的Android内存泄漏检测库,它能够自动检测应用中的内存泄漏,并以友好的方式展示泄漏的引用链,大大降低了内存泄漏排查的门槛和成本。
LeakCanary的版本演进:
版本演进:
LeakCanary 1.x(2015年发布)
├── 基于haha库分析hprof文件
├── 需要手动在build.gradle中添加依赖
├── 需要在Application中初始化
└── 分析速度较慢
LeakCanary 2.x(2019年发布)
├── 使用全新的Shark分析引擎,替换haha
├── 完全用Kotlin重写
├── 利用ContentProvider实现自动初始化(零配置)
├── 分析速度提升约6倍
├── 支持更多泄漏场景检测
└── 更友好的UI展示
# 1.2 内存泄漏概念
什么是内存泄漏? 当App无法释放不再需要的对象引用时,即为内存泄漏。也可以理解为:生命周期长的对象持有了生命周期短的对象引用,导致短生命周期对象无法被GC回收。
内存泄露(Memory Leaks)指不再使用的对象或数据没有被回收,随着内存泄漏的堆积,应用性能会逐渐变差,甚至发生OOM崩溃。
应用中的内存泄漏可以分为两类:
- Java内存泄露:不再使用的对象被生命周期更长的GC Root引用,无法被判定为垃圾对象而导致内存泄漏。LeakCanary主要监控Java内存泄漏。
- Native内存泄露:Native内存没有垃圾回收机制,需要手动管理,未手动回收导致内存泄漏。
常见的内存泄漏场景:
常见内存泄漏类型:
1. Activity/Fragment泄漏
├── 非静态内部类持有外部类引用(如Handler、AsyncTask)
├── 匿名内部类持有Activity引用(如Listener、Callback)
├── 单例模式持有Activity Context
└── 静态变量持有Activity引用
2. 资源未关闭
├── Cursor未关闭
├── Stream未关闭
├── TypedArray未回收
└── Bitmap未回收
3. 注册未注销
├── BroadcastReceiver未注销
├── EventBus未反注册
├── 观察者模式未移除Observer
└── ContentObserver未注销
4. 集合类泄漏
├── 静态集合类只增不减
├── HashMap的Key对象修改hashCode
└── 全局缓存无淘汰机制
5. WebView泄漏
└── WebView持有Activity引用,销毁时需特殊处理
# 1.3 设计目标
LeakCanary支持以下五种Android场景中的内存泄漏监测:
- 已销毁的Activity对象(进入DESTROYED状态)
- 已销毁的Fragment对象和Fragment View对象(进入DESTROYED状态)
- 已清除的ViewModel对象(进入CLEARED状态)
- 已销毁的Service对象(进入DESTROYED状态)
- 已从WindowManager中移除的RootView对象
疑惑:为什么只监控这五种场景?
这五种场景覆盖了Android应用中最常见且影响最大的内存泄漏类型。Activity和Fragment是最重量级的UI组件,泄漏它们意味着整个视图树都无法回收。ViewModel和Service是常被长生命周期对象引用的组件。RootView的泄漏则可能导致整个Window无法回收。
论证:为什么不监控所有对象?
如果监控所有对象,会带来极大的性能开销。每个被监控对象都需要创建WeakReference和维护引用队列,大量对象的监控会导致频繁GC和内存抖动。LeakCanary选择重点监控这五类高价值目标,在检测效果和性能开销之间取得了最佳平衡。
# 1.4 产生收益
LeakCanary的核心优势:
- 自动化检测:不需要手动触发,应用运行过程中自动检测
- 零配置:只需添加依赖,无需任何初始化代码(基于ContentProvider自动初始化)
- 精确定位:提供完整的引用链,精确到泄漏对象被哪个GC Root持有
- 友好展示:通过通知栏提醒和专门的UI页面展示泄漏信息
- 开发效率:大大缩短内存泄漏的排查时间,从数小时缩短到分钟级别
# 02.开发设计思路
# 2.1 整体设计思路
LeakCanary的核心工作流程可以概括为以下5个阶段:
LeakCanary核心工作流程:
阶段1:注册无用对象监听
└── 在Android Framework中注册监听器
└── 感知五种泄漏场景中产生无用对象的时机
└── 如Activity.onDestroy()后,产生一个无用Activity对象
阶段2:监控内存泄漏
└── 为无用对象关联WeakReference + ReferenceQueue
└── 等待5秒后检查弱引用是否进入引用队列
└── 如果没有进入,则认为对象发生泄漏
└── 泄漏对象计数达到阈值才触发分析
阶段3:Heap Dump
└── 调用Debug.dumpHprofData()生成hprof文件
└── Dump过程会锁堆,应用短暂冻结
└── 生成的hprof文件通常10+MB
阶段4:分析堆快照
└── 使用Shark引擎分析hprof文件
└── 在独立线程或独立进程中执行
└── 查找泄漏对象到GC Root的最短引用链
阶段5:输出分析报告
└── Logcat打印分析结果
└── 发送系统通知
└── 可视化报告页面展示
# 2.2 初始化思路
LeakCanary 2.x利用ContentProvider实现零配置自动初始化,这是一个非常巧妙的设计。
疑惑:为什么不需要在Application中手动初始化? ContentProvider的onCreate()在Application.onCreate()之前调用。Android应用启动时组件初始化顺序:
Application.attachBaseContext()
↓
ContentProvider.onCreate() ← LeakCanary在这里初始化
↓
Application.onCreate()
↓
Activity.onCreate()
LeakCanary在AndroidManifest中注册了一个名为AppWatcherInstaller的ContentProvider,系统在创建Application时会自动创建这个ContentProvider,从而触发LeakCanary的初始化逻辑。
// LeakCanary自动初始化的关键代码
internal sealed class AppWatcherInstaller : ContentProvider() {
internal class MainProcess : AppWatcherInstaller()
override fun onCreate(): Boolean {
val application = context!!.applicationContext as Application
// 安装LeakCanary
AppWatcher.manualInstall(application)
return true
}
}
初始化做了什么:
- 注册Activity生命周期监听(通过registerActivityLifecycleCallbacks)
- 注册Fragment生命周期监听
- 注册ViewModel清除监听
- 注册Service销毁监听(通过Hook)
- 注册RootView移除监听(通过Hook)
# 2.3 设计组件监听
不同组件的监听策略不同,因为Android Framework提供的监听接口各不相同:
Activity监控: 通过Application.registerActivityLifecycleCallbacks()接口监听Activity的onDestroy事件。将当前Activity对象交给ObjectWatcher监控
Fragment与Fragment View监控:
- 首先通过
Application.registerActivityLifecycleCallbacks()接口监听Activity的onCreate事件 - 再通过
FragmentManager.registerFragmentLifecycleCallbacks()接口监听Fragment的生命周期 - 在
onFragmentViewDestroyed()中监控Fragment View - 在
onFragmentDestroyed()中监控Fragment
ViewModel监控:
- Android Framework未提供ViewModel.onClear()全局监听方法
- LeakCanary通过Hook方式实现
- 在Activity.onCreate和Fragment.onCreate事件中实例化一个自定义ViewModel
- 在ViewModel.onClear()方法中,通过反射获取当前作用域中所有的ViewModel对象
- 将这些ViewModel对象交给ObjectWatcher监控
ViewModel监控的Hook原理:
Activity/Fragment.onCreate()
↓
ViewModelProvider(owner).get(ViewModelClearedWatcher::class)
→ 创建自定义的ViewModelClearedWatcher
↓
当Activity/Fragment销毁时
↓
ViewModelClearedWatcher.onCleared()
↓
反射获取ViewModelStore中所有ViewModel
→ viewModelStore.keys().forEach { key ->
val viewModel = ViewModelStore.get(key)
objectWatcher.expectWeaklyReachable(viewModel, ...)
}
Service监控:
- Android Framework未提供Service.onDestroy()全局监听方法
- 需要两步Hook来实现:
- Hook主线程消息循环的
mH.mCallback回调,监听STOP_SERVICE消息,暂存即将销毁的Service对象 - 使用动态代理Hook
IActivityManagerBinder对象,代理serviceDoneExecuting()方法,视为Service.onDestroy()的执行时机
- Hook主线程消息循环的
RootView监控:
- 通过Hook
WindowManagerGlobal.mViewsRootView列表获取RootView新增和移除的时机 - 检查View对应的Window类型(Dialog、DreamService等)
- 注册
View.addOnAttachStateChangeListener()监听 - 在
onViewDetachedFromWindow()回调中将View对象交给Watcher监控
# 2.4 无用对象监听
如何标记一个对象并判断其是否泄漏?核心原理是利用Java的 WeakReference + ReferenceQueue机制。
原理说明: 为弱引用指定一个引用队列,当弱引用指向的对象被GC回收时,此弱引用就会被添加到这个队列中。通过判断引用队列中有没有这个弱引用,就能判断该弱引用指向的对象是否被回收了。
// 演示WeakReference + ReferenceQueue的工作原理
ReferenceQueue<Object> queue = new ReferenceQueue<>();
private void test() {
Object obj = new Object();
// 创建弱引用,关联引用队列
WeakReference<Object> reference = new WeakReference<>(obj, queue);
System.out.println("弱引用对象: " + reference);
// GC前,queue为空
System.gc();
printlnQueue("GC前(obj未置null)"); // 输出: 空
// 置空obj,使其成为GC候选对象
obj = null;
System.gc();
printlnQueue("GC后(obj已置null)"); // 输出: 弱引用对象
}
private void printlnQueue(String tag) {
Object obj;
while ((obj = queue.poll()) != null) {
System.out.println(tag + ": " + obj);
}
}
// 输出结果:
// 弱引用对象: java.lang.ref.WeakReference@6e0be858
// GC前(obj未置null):(空)
// GC后(obj已置null): java.lang.ref.WeakReference@6e0be858
利用这个特性,可以检测Activity的内存泄漏:
- Activity在onDestroy()之后应该被销毁
- 用WeakReference指向Activity,并关联ReferenceQueue
- 等待GC后检查引用队列是否包含该弱引用
- 如果不包含,说明Activity没有被回收,可能发生了泄漏
# 2.5 内存泄漏监听
LeakCanary的泄漏判定流程分为两层:
第一层:注册无用对象监听。在Android Framework中注册全局监听器或者Hook,在对象进入无用状态时(如Activity.onDestroy())将其交给ObjectWatcher监控。
第二层:利用引用对象感知垃圾回收。为无用对象包装KeyedWeakReference,并在一段时间后(默认5秒)观察弱引用是否如期进入关联的引用队列。
// ObjectWatcher核心逻辑简化版
class ObjectWatcher {
private val watchedObjects = mutableMapOf<String, KeyedWeakReference>()
private val queue = ReferenceQueue<Any>()
fun expectWeaklyReachable(watchedObject: Any, description: String) {
val key = UUID.randomUUID().toString()
val reference = KeyedWeakReference(watchedObject, key, description, queue)
watchedObjects[key] = reference
// 5秒后检查
checkRetainedExecutor.execute {
moveToRetained(key)
}
}
private fun moveToRetained(key: String) {
// 先清理已回收的对象
removeWeaklyReachableObjects()
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
// 对象仍在监控Map中 → 疑似泄漏
retainedRef.retainedUptimeMillis = clock.uptimeMillis()
onObjectRetainedListeners.forEach { it.onObjectRetained() }
}
}
private fun removeWeaklyReachableObjects() {
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
watchedObjects.remove(ref.key) // 已被回收,移除监控
}
} while (ref != null)
}
}
LeakCanary发现泄漏对象后就会立刻触发分析吗?
不会。LeakCanary不会每次发现泄漏对象都进行分析,而是有两个拦截条件:
- 拦截1:泄漏对象计数未达到阈值(默认5个),或者进入后台时间未达到阈值
- 拦截2:距离上一次HeapDump未超过60秒
这样设计是因为HeapDump和分析过程非常耗时,频繁执行会严重影响用户体验。
# 2.6 Dump内存
当泄漏对象计数达到阈值时,LeakCanary会触发HeapDump操作。HeapDump触发流程:
泄漏对象计数 >= 5
↓
检查距离上次Dump是否超过60秒
├── 否 → 等待
└── 是 ↓
触发GC(Runtime.getRuntime().gc())
↓
等待100ms
↓
再次检查泄漏对象(移除已回收的)
↓
如果仍有泄漏对象 → 执行Dump
↓
调用Debug.dumpHprofData(filePath)
└── 该方法会锁堆(Stop-The-World)
└── 生成.hprof文件(通常10-30MB)
↓
发送HeapDump事件
↓
开启前台服务进行分析
Dump的两种触发策略:
- 自动触发:泄漏对象计数达到阈值(默认5个)时自动触发
- 手动触发:用户点击通知栏提前触发分析
# 2.7 分析内存快照
LeakCanary 2.x使用自研的Shark引擎替代了1.x时代的haha库来分析hprof文件。Shark分析流程:
hprof文件分析流程:
1. 解析文件头
└── 读取hprof文件格式头信息
└── 获取解析开始位置
2. 构建内存索引
└── 扫描hprof文件中的所有Record
└── 建立对象ID到文件偏移的映射
└── 使用两次遍历策略(第一次建索引,第二次按需读取)
3. 构建对象图(Graph)
└── 基于索引按需加载对象信息
└── 避免一次性加载整个堆到内存
└── 使用Lazy Loading策略
4. 查找泄漏路径
└── 从泄漏对象出发
└── 使用广度优先搜索(BFS)
└── 查找到GC Root的最短引用链
└── 考虑引用类型(强引用、软引用、弱引用)
5. 生成分析报告
└── 输出引用链
└── 标记怀疑对象
└── 分组相同泄漏
疑惑:为什么Shark比haha快6倍?
答疑:
- haha将整个hprof文件加载到内存,内存消耗巨大;Shark使用索引+按需加载,内存消耗小得多
- haha使用Java的反射机制解析;Shark使用高效的Okio进行IO操作
- Shark对hprof文件只做两次遍历(建索引+分析),而haha需要多次遍历
- Shark针对Android的hprof格式做了专门优化
# 2.8 输出堆栈链报告
LeakCanary生成的泄漏报告包含以下关键信息,泄漏报告示例:
┬───
│ GC Root: System class
│
├─ android.app.ActivityThread
│ Leaking: NO (it's a system class)
│ ↓ ActivityThread.mActivities
│ ~~~~~~~~~~~
├─ android.util.ArrayMap
│ Leaking: NO
│ ↓ ArrayMap.mArray
│ ~~~~~~
├─ java.lang.Object[]
│ Leaking: NO
│ ↓ Object[1]
│ ~~~~~~~
├─ android.app.ActivityThread$ActivityClientRecord
│ Leaking: NO
│ ↓ ActivityClientRecord.activity
│ ~~~~~~~~
╰→ com.example.MainActivity
Leaking: YES (Activity is destroyed)
key = "xxx-xxx-xxx"
报告中的关键信息:
├── Leaking: YES/NO → 是否是泄漏对象
├── ~~~下划线~~~ → 怀疑泄漏路径
├── key → 泄漏对象的唯一标识
└── 引用链从GC Root到泄漏对象
# 2.9 KOOM设计思路
快手技术团队开源的KOOM(Kwai OOM)框架针对LeakCanary的性能瓶颈做了优化。
核心优化:利用Copy-on-Write思想,fork子进程进行HeapDump
LeakCanary的Dump问题:
Debug.dumpHprofData() → 锁堆(Stop-The-World)
→ 应用冻结数秒
→ 用户体验极差
→ 不适合线上使用
KOOM的优化方案:
fork()子进程 → 子进程继承父进程内存快照(COW)
→ 子进程中执行Debug.dumpHprofData()
→ 父进程(主应用)不受影响
→ 适合线上使用
COW (Copy-On-Write) 原理:
fork()后父子进程共享同一物理内存页
→ 只有写操作时才会复制对应的内存页
→ fork本身非常快(只复制页表)
→ 子进程获得了父进程内存的完整快照
KOOM还做了以下优化:
- 使用内存阈值触发检测(而非对象计数)
- 优化hprof文件裁剪,减小文件体积
- 支持线上环境使用
# 03.LeakCanary原理
# 3.1 思考一些问题
在深入源码之前,先思考几个问题:
核心问题:
1. LeakCanary的核心工作流程是什么?设计思想是什么?
2. 初始化做了哪些工作?如何做到零配置?
3. 如何监听各种组件的销毁事件?尤其是ViewModel、Service?
4. 如何判断对象是否泄漏?判定条件是什么?
5. WeakReference + ReferenceQueue的检测机制原理?
6. 检测出泄漏后如何生成泄漏信息?引用链如何得到?
7. 为什么不能用于线上?有什么替代方案?
原理简单介绍:
通过监听组件的生命周期(Activity.onDestroy等),在组件销毁后手动触发GC,然后通过ReferenceQueue + WeakReference来判断对象是否被回收。如果GC后对象仍未被回收,则进行HeapDump生成hprof文件,再通过Shark引擎分析泄漏的引用链。
# 3.2 原理流程概括
如何触发检测:
LeakCanary基于LeakSentry开发,LeakSentry会Hook Android生命周期,自动检测Activity或Fragment被销毁时实例是否被回收。销毁的实例传给ObjectWatcher(即老版的RefWatcher),ObjectWatcher持有它们的弱引用。等待5秒,如果GC触发后弱引用还没有被清理,则认为可能发生内存泄漏。
判断是否存在内存泄漏:
- 尝试从ReferenceQueue中获取待分析对象对应的弱引用
- 如果弱引用已在队列中,说明对象正在被回收 → 返回DONE
- 如果不在队列中,可能存在泄漏 → 手动触发GC
- GC后再次检查引用队列
- 如果仍不在队列中 → 确认泄漏
分析内存泄漏:
确认泄漏后,调用heapDumper.dumpHeap()生成hprof文件,Shark引擎分析hprof文件查找从泄漏对象到GC Root的最短引用链。
整体流程图:
组件销毁(onDestroy等)
↓
ObjectWatcher.expectWeaklyReachable(object)
↓
创建KeyedWeakReference + ReferenceQueue
↓
等待5秒
↓
检查ReferenceQueue
├── 弱引用在队列中 → 对象已回收 → 移除监控(正常)
└── 弱引用不在队列中 → 触发GC → 再次检查
├── 在队列中 → 移除监控(正常)
└── 不在队列中 → 确认泄漏 → 泄漏计数+1
↓
泄漏计数 >= 阈值?
├── 否 → 发送通知提醒
└── 是 → HeapDump → Shark分析 → 输出报告
# 3.3 初始化流程
LeakCanary 2.x的初始化通过ContentProvider自动完成:
// AppWatcherInstaller 在 AndroidManifest 中自动注册
internal sealed class AppWatcherInstaller : ContentProvider() {
override fun onCreate(): Boolean {
val application = context!!.applicationContext as Application
AppWatcher.manualInstall(application)
return true
}
}
// AppWatcher.manualInstall() 完成初始化
fun manualInstall(
application: Application,
retainedDelayMillis: Long = TimeUnit.SECONDS.toMillis(5),
watchersToInstall: List<InstallableWatcher> = appDefaultWatchers(application)
) {
// 1. 安装各种Watcher
watchersToInstall.forEach { watcher ->
watcher.install()
}
}
// 默认安装的5种Watcher
fun appDefaultWatchers(application: Application): List<InstallableWatcher> {
return listOf(
ActivityWatcher(application, reachabilityWatcher),
FragmentAndViewModelWatcher(application, reachabilityWatcher),
RootViewWatcher(reachabilityWatcher),
ServiceWatcher(reachabilityWatcher)
)
}
# 3.4 监听组件销毁
Activity监听 - ActivityWatcher:
内部注册了Activity的全局生命周期监听,在onDestroy()时追踪当前Activity对象:
class ActivityWatcher(
private val application: Application,
private val reachabilityWatcher: ReachabilityWatcher
) : InstallableWatcher {
private val lifecycleCallbacks = object : Application.ActivityLifecycleCallbacks
by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy()"
)
}
}
override fun install() {
application.registerActivityLifecycleCallbacks(lifecycleCallbacks)
}
}
Fragment监听 - FragmentAndViewModelWatcher:
先注册Activity生命周期监听,然后在Activity.onCreate()时注册Fragment的生命周期监听:
// 在Activity.onCreate中注册Fragment监听
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
val fragmentManager = activity.fragmentManager
fragmentManager.registerFragmentLifecycleCallbacks(object :
FragmentManager.FragmentLifecycleCallbacks() {
override fun onFragmentViewDestroyed(fm: FragmentManager, f: Fragment) {
// 监控Fragment的View
val view = f.view
if (view != null) {
reachabilityWatcher.expectWeaklyReachable(view, ...)
}
}
override fun onFragmentDestroyed(fm: FragmentManager, f: Fragment) {
// 监控Fragment自身
reachabilityWatcher.expectWeaklyReachable(f, ...)
}
}, true)
}
ViewModel监听:
通过在作用域中创建自定义ViewModel,利用其onCleared()回调来获取所有ViewModel的清除时机:
// 自定义ViewModel用于监听其他ViewModel的清除
class ViewModelClearedWatcher(
storeOwner: ViewModelStoreOwner,
private val reachabilityWatcher: ReachabilityWatcher
) : ViewModel() {
private val viewModelMap: Map<String, ViewModel>? = try {
// 反射获取ViewModelStore内部的map
val storeField = ViewModelStore::class.java.getDeclaredField("map")
storeField.isAccessible = true
storeField.get(storeOwner.viewModelStore) as Map<String, ViewModel>
} catch (ignored: Exception) { null }
override fun onCleared() {
// 当前作用域的ViewModel被清理时
viewModelMap?.values?.forEach { viewModel ->
reachabilityWatcher.expectWeaklyReachable(
viewModel, "${viewModel::class.java.name} received ViewModel#onCleared()"
)
}
}
}
Service监听 - ServiceWatcher:
由于Android Framework未提供Service.onDestroy()的全局监听接口,LeakCanary通过两步Hook实现:
Service监控的双Hook方案:
Hook 1: 监听ActivityThread.mH.mCallback中的STOP_SERVICE消息
→ 在收到STOP_SERVICE消息时
→ 通过反射从ActivityThread.mServices中取出Service对象
→ 暂存到一个WeakHashMap中
Hook 2: 动态代理IActivityManager
→ 代理serviceDoneExecuting()方法
→ 该方法在Service.onDestroy()执行后被调用
→ 从暂存Map中取出Service对象
→ 交给ObjectWatcher监控
# 3.5 监听无用对象
ObjectWatcher是LeakCanary的核心组件,负责管理所有被监控对象的生命周期:
ObjectWatcher内部数据结构:
watchedObjects: LinkedHashMap<String, KeyedWeakReference>
├── key: UUID随机生成的唯一标识
└── value: KeyedWeakReference(继承WeakReference)
└── 使用LinkedHashMap保持插入顺序,便于遍历
queue: ReferenceQueue<Any>
└── 当WeakReference指向的对象被回收时
└── 该WeakReference会自动进入此队列
retainedObjects: MutableSet<String>
└── 存储已确认泄漏的对象的key
└── 每次dump后清空并重新累积
KeyedWeakReference:
├── key: String(唯一标识)
├── description: String(描述信息)
├── watchUptimeMillis: Long(开始监控的时间)
├── retainedUptimeMillis: Long(确认泄漏的时间,-1表示未泄漏)
└── 继承自WeakReference<Any>,关联ReferenceQueue
判断逻辑详细说明:
- 对要监听的对象,使用KeyedWeakReference与其关联(初始化时传入引用队列queue),并保存到watchedObjects Map中
- 使用Handler延迟5秒后执行判断
- 判断时先遍历ReferenceQueue,将已回收对象从watchedObjects中移除
- 再检查目标对象是否仍在watchedObjects中
- 如果仍在,调用
Runtime.getRuntime().gc()触发GC - GC后等待100ms,再次执行步骤3-4
- 如果对象仍然存在 → 确认泄漏
完整源码级流程分析:
// ObjectWatcher.expectWeaklyReachable() 完整实现
@Synchronized
fun expectWeaklyReachable(
watchedObject: Any,
description: String
) {
if (!isEnabled()) return // 功能开关检查
// 第一步:先清理已经入队的引用(已回收的对象)
removeWeaklyReachableObjects()
// 第二步:生成唯一key并创建KeyedWeakReference
val key = UUID.randomUUID().toString()
val watchUptimeMillis = clock.uptimeMillis()
val reference = KeyedWeakReference(
watchedObject, key, description, watchUptimeMillis, queue
)
SharkLog.d {
"Watching " +
(if (watchedObject is Class<*>) watchedObject.toString()
else "instance of ${watchedObject.javaClass.name}") +
(if (description.isNotEmpty()) " ($description)" else "") +
" with key $key"
}
// 第三步:存入监控Map
watchedObjects[key] = reference
// 第四步:延迟5秒后检查(核心异步检查逻辑)
checkRetainedExecutor.execute {
moveToRetained(key)
}
}
removeWeaklyReachableObjects() - 清理已回收对象的关键方法:
private fun removeWeaklyReachableObjects() {
// ReferenceQueue.poll() 非阻塞获取已入队的引用
// 如果该引用在队列中 → 说明对象已被GC回收 → 从watchedObjects中安全移除
var ref: KeyedWeakReference?
do {
ref = queue.poll() as KeyedWeakReference?
if (ref != null) {
watchedObjects.remove(ref.key)
}
} while (ref != null)
}
moveToRetained() - 判定泄漏的核心方法:
@Synchronized
private fun moveToRetained(key: String) {
// 再次清理已回收对象(5秒内可能又有GC发生)
removeWeaklyReachableObjects()
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
// 对象仍在监控Map中 → 疑似泄漏
retainedRef.retainedUptimeMillis = clock.uptimeMillis()
// 检查是否已被用户忽略(通过UI手动忽略)
if (!ignoredObjectMatcher.isIgnored(retainedRef)) {
retainedObjects.add(key) // 加入已确认泄漏集合
}
// 通知所有泄漏监听器
onObjectRetainedListeners.forEach {
it.onObjectRetained()
}
// 调度下一次检查(HeapDumpTrigger会响应)
checkRetainedExecutor.execute {
checkRetainedObjects()
}
}
}
GC触发策略的深入分析:
LeakCanary在5秒等待后,如果对象未被回收,不会立即判定为泄漏,而是执行一套GC重试策略:
// GC触发策略(在HeapDumpTrigger中)
fun checkRetainedObjects(reason: String) {
val config = configProvider()
// 第一次检查:只看引用队列
var retainedReferenceCount = objectWatcher.retainedObjectCount
if (retainedReferenceCount == 0) return
// 第二次:手动触发GC,再次检查
// 一次GC可能不够,Java的GC不保证完全回收
var gcTriggerCount = 0
while (gcTriggerCount < 3 && retainedReferenceCount > 0) {
Runtime.getRuntime().gc()
// 等待100ms让GC完成
Thread.sleep(100)
// 运行finalization(处理有finalize()方法的对象)
Runtime.getRuntime().runFinalization()
// 重新检查引用队列
objectWatcher.removeWeaklyReachableObjects()
retainedReferenceCount = objectWatcher.retainedObjectCount
gcTriggerCount++
}
// 三次GC后仍存在 → 高度确信发生泄漏
if (retainedReferenceCount > 0) {
// 判断是否需要dump
// ...
}
}
为什么需要多次GC?
Java的GC并不是请求就立即执行且保证完全回收的:
System.gc()/Runtime.gc()只是一个建议,JVM不一定立即响应- 即使GC执行了,一次GC可能只回收部分代(如Young GC不回收Old Gen)
- 有
finalize()方法的对象需要两次GC:第一次标记后执行finalize,第二次才能真正回收 - 调用
runFinalization()可以加速finalize方法的执行,让二次标记更快完成
# 3.6 监控内存泄漏
LeakCanary确认泄漏后的处理由一个完整的状态机驱动,核心类为HeapDumpTrigger:
HeapDumpTrigger 内部状态机:
HeapDumpTrigger 五种状态:
IDLE(空闲)
└── 初始状态 / Dump完成后的状态
└── 等待新的泄漏事件
WAITING_FOR_RETAINED_COUNT(等待泄漏累积)
└── 已发现泄漏对象,但未达阈值
└── 显示通知,提示有N个泄漏对象
└── 用户可手动点击通知触发dump
WAITING_FOR_DUMP_INTERVAL(等待dump冷却)
└── 泄漏已达阈值,但距离上次dump不足60秒
└── 等待冷却时间结束
DUMPING(正在Dump)
└── 正在执行Debug.dumpHprofData()
└── 应用冻结状态
ANALYZING(正在分析)
└── Shark引擎正在分析hprof文件
└── 在后台线程/独立进程中执行
完整泄漏处理决策流程:
// HeapDumpTrigger 核心调度逻辑
fun checkRetainedObjects() {
val retainedKeys = objectWatcher.retainedObjects.toList()
val count = retainedKeys.size
if (count == 0) {
// 无泄漏,回到IDLE状态
return
}
// ====== 决策1: 冷却期检查 ======
val lastDumpTime = lastDisplayedRetainedObjectCount?.lastHeapDumpTime ?: 0
val timeSinceLastDump = SystemClock.uptimeMillis() - lastDumpTime
if (timeSinceLastDump < WAIT_BETWEEN_HEAP_DUMPS_MILLIS) {
// 距离上次dump不足60秒,等待冷却
// 状态: WAITING_FOR_DUMP_INTERVAL
scheduleRetainedCheck(
delayMillis = WAIT_BETWEEN_HEAP_DUMPS_MILLIS - timeSinceLastDump
)
return
}
// ====== 决策2: 阈值检查 ======
val minRetainedCount = if (applicationVisible) {
retainedVisibleThreshold // 前台阈值,默认5
} else {
retainedInvisibleThreshold // 后台阈值,默认较小
}
if (count < minRetainedCount) {
// 泄漏数量未达标,发送通知但不dump
// 状态: WAITING_FOR_RETAINED_COUNT
updateRetainedCountNotification(count)
scheduleRetainedCheck(retainedDelayMillis)
return
}
// ====== 决策3: 后台策略 ======
if (!applicationVisible) {
// App在后台,可以更宽松地触发dump
// 后台dump对用户体验影响小
}
// ====== 决策4: 执行Dump ======
// 状态: DUMPING
dumpHeap(retainedKeys)
}
泄漏对象的生命周期管理:
// ObjectWatcher中泄漏对象的管理
class ObjectWatcher {
// watchedObjects: 所有正在监控的对象(包括已确认泄漏的)
private val watchedObjects = LinkedHashMap<String, KeyedWeakReference>()
// retainedObjects: 已确认泄漏的对象的key集合
private val retainedObjects = MutableSet<String>()
val retainedObjectCount: Int
get() = retainedObjects.size
// 移除已确认泄漏的对象(dump完成后调用)
fun removeRetainedObjects() {
synchronized(this) {
retainedObjects.clear()
}
}
}
前后台感知策略:
// LeakCanary通过ProcessLifecycleOwner感知前后台
// 前台泄漏阈值更高(减少对用户的干扰)
// 后台泄漏阈值更低(更积极地检测)
// 前台阈值 vs 后台阈值
config = config.copy(
retainedVisibleThreshold = 5, // 前台:积累5个泄漏才dump
retainedInvisibleThreshold = 1, // 后台:1个泄漏即可dump
dumpHeapWhenDebugging = false, // Debug时不dump(避免干扰调试)
)
两种触发Dump的路径:
- 自动触发:泄漏对象计数达到阈值 → 自动触发dump
- 手动触发:用户看到通知栏的"X个泄漏对象",点击通知 → 手动触发dump(绕过阈值检查)
# 3.7 Dump内存快照
在判定有内存泄漏后,LeakCanary调用系统提供的Debug.dumpHprofData(File)函数,生成虚拟机的内存快照(hprof文件)。
HPROF文件格式简介:
HPROF(Heap/CPU Profiling Tool)是JVM标准的堆转储格式,Android ART/Dalvik虚拟机采用类似的格式。hprof文件本质上是一个二进制文件,由一系列Record串联组成:
HPROF文件结构:
┌──────────────────────────────┐
│ Header(文件头) │
│ ├── format string │
│ ├── 标识符大小(4或8字节) │
│ └── 时间戳 │
├──────────────────────────────┤
│ Record 1(字符串表) │
├──────────────────────────────┤
│ Record 2(类定义) │
├──────────────────────────────┤
│ Record 3(实例数据) │
│ ├── 对象ID │
│ ├── 堆栈跟踪序列号 │
│ ├── 类ID │
│ └── 实例字段值(基本类型+引用)│
├──────────────────────────────┤
│ Record 4(数组数据) │
│ ├── 数组对象ID │
│ ├── 堆栈跟踪序列号 │
│ ├── 元素数量 │
│ ├── 元素类型 │
│ └── 元素值 │
├──────────────────────────────┤
│ Record 5(GC Root) │
│ ├── JNI Global/Local │
│ ├── Java Frame │
│ ├── Static Field │
│ ├── Thread Object │
│ ├── System Class │
│ ├── Monitor Used │
│ └── Sticky Class │
├──────────────────────────────┤
│ ...更多Record... │
└──────────────────────────────┘
Dump实现细节:
Dump流程:
1. 触发条件满足 → 开始Dump
2. 调用 AndroidDebugHeapDumper.dumpHeap()
└── 内部调用 Debug.dumpHprofData(file.getAbsolutePath())
└── 该方法会暂停所有线程(Stop-The-World)
└── GC线程遍历Java堆中的所有可达对象
└── 将对象信息(实例数据、数组、类信息)序列化为HPROF格式
└── 写入目标文件
└── 恢复所有线程
3. Stop-The-World 详解:
└── dumpHprofData内部调用 VMDebug.dumpHprofData()
└── 这是一个native方法,最终调用ART的Heap::Dump()
└── 需要先获取mutator_lock(全局互斥锁)
└── 暂停所有Java线程 → 冻结时间取决于堆大小
└── 堆100MB时,冻结约1-2秒
└── 堆300MB时,冻结约3-5秒
4. 生成hprof文件(通常10-30MB)
└── 文件大小 ≈ 堆中对象的总内存占据量
└── 包含所有GC Root、类定义、实例数据、数组
5. 将泄漏对象的referenceKey和hprof文件封装为HeapDump对象
└── HeapDump(hprofFile, referenceKey, excludedRefs, ...)
6. 发送LeakCanary内部事件 → EventBus通知
└── HeapDumped事件 → 触发分析流程
7. 开启前台服务(HeapAnalyzerService)执行分析工作
└── 前台服务避免进程被系统kill
└── 独立线程中执行Shark分析
AndroidDebugHeapDumper源码分析:
// Android平台Dump实现
class AndroidDebugHeapDumper : HeapDumper {
override fun dumpHeap(): DumpHeapResult {
val heapDumpFile = createTempFile()
return try {
// 核心调用:锁堆并生成hprof
Debug.dumpHprofData(heapDumpFile.absolutePath)
// 验证文件生成成功
if (heapDumpFile.exists()) {
DumpHeapResult.Success(heapDumpFile)
} else {
DumpHeapResult.FileNotCreated("hprof文件未生成")
}
} catch (e: Exception) {
SharkLog.d(e) { "Heap dump failed" }
DumpHeapResult.DumpFailed(e.message ?: "Unknown error")
}
}
}
Dump过程中的安全保护:
// LeakCanary的Dump保护机制
fun dumpHeap(retainedKeys: List<String>) {
// 保护1: 标记dump状态,防止重复dump
if (isDumping) return
isDumping = true
// 保护2: 记录dump时间,用于冷却期判断
lastHeapDumpTime = SystemClock.uptimeMillis()
// 保护3: 在dump前最后清理一次引用队列
// (对象可能在dump前被回收,降低误报)
objectWatcher.removeWeaklyReachableObjects()
// 保护4: 文件路径冲突处理
// 旧的hprof文件在分析完成后会被删除
val heapDumpFile = File(
context.cacheDir,
"leakcanary-${System.currentTimeMillis()}.hprof"
)
// 执行dump
val result = heapDumper.dumpHeap()
// 保护5: dump结果校验
when (result) {
is DumpHeapResult.Success -> {
// 发送到分析服务
HeapAnalyzerService.runAnalysis(context, result.file)
}
is DumpHeapResult.FileNotCreated,
is DumpHeapResult.DumpFailed -> {
// 重置状态,允许重试
isDumping = false
}
}
}
# 3.8 分析堆快照
LeakCanary 2.x使用Shark引擎分析hprof文件。
分析流程:
Shark分析hprof文件的步骤:
Step 1: 解析文件头
└── 读取hprof文件版本、时间戳等元信息
└── 确定解析起始位置
Step 2: 构建内存索引(第一次遍历)
└── 扫描所有Record类型
└── 记录每个对象的ID和在文件中的偏移量
└── 建立高效的索引数据结构
└── 不将对象数据加载到内存
Step 3: 构建对象图(按需)
└── 使用索引按需读取对象信息
└── 建立对象间的引用关系图
└── 识别GC Root对象
Step 4: 查找泄漏路径(BFS广度优先搜索)
└── 从所有GC Root出发
└── 广度优先遍历对象图
└── 找到到达泄漏对象的最短路径
└── 记录路径上的每个引用节点
Step 5: 生成报告
└── 格式化引用链
└── 标记Leaking YES/NO
└── 计算泄漏签名
└── 分组相同泄漏
# 3.9 输出分析报告
分析完成后,LeakCanary通过多种方式输出结果:
- Logcat输出:在控制台打印完整的引用链信息
- 系统通知:发送通知栏消息,点击可跳转到详情页
- 可视化页面:LeakCanary提供了专门的Activity展示泄漏信息
- 桌面快捷方式:生成快捷入口方便查看
# 3.10 ObjectWatcher内部状态转换完整分析
ObjectWatcher对每个被监控对象的生命周期管理可以抽象为一个状态机:
单个被监控对象的状态转换:
expectWeaklyReachable(obj)
│
▼
┌──────────────┐
│ WATCHING │ 等待5秒
│ (监控中) │ obj有WeakReference指向
└──────┬───────┘
│ 5秒后 checkRetainedExecutor 执行
▼
┌──────────────┐
│ CHECKING │ 第一次检查
│ (检查中) │ 遍历ReferenceQueue
└──┬────────┬──┘
│ │
ref在queue中 ref不在queue中
│ │
▼ ▼
┌──────┐ ┌──────────────────┐
│ DONE │ │ TRIGGER_GC │ 手动触发GC
│(正常)│ │ (触发GC重试) │ Runtime.gc()
└──────┘ └────────┬─────────┘
│ GC后100ms再次检查
▼
┌──────────┬──────────┐
│ │
ref在queue中 ref不在queue中
│ │
▼ ▼
┌──────┐ ┌──────────────┐
│ DONE │ │ RETAINED │ 确认泄漏
│(正常)│ │ (已泄漏) │ retained=true
└──────┘ └──────┬───────┘
│ 通知listener
▼
┌──────────────┐
│ COUNTING │ 累积计数
│ (累积中) │ retainedCount++
└──────┬───────┘
│ count >= threshold
▼
┌──────────────┐
│ DUMPING │ 生成hprof
│ (Dump中) │ Debug.dumpHprofData()
└──────┬───────┘
│
▼
┌──────────────┐
│ ANALYZING │ Shark分析
│ (分析中) │ BFS查找引用链
└──────┬───────┘
│
▼
┌──────────────┐
│ REPORTED │ 输出报告
│ (已报告) │ Logcat/通知/UI
└──────────────┘
ObjectWatcher全局状态管理:
ObjectWatcher内部有一个全局的"是否已通知过泄漏"标志位,用于控制通知频率:
class ObjectWatcher {
// 全局标志:是否已经发送过泄漏通知
// 避免每个对象都单独发通知
private var hasRetainedObjects = false
@Synchronized
private fun moveToRetained(key: String) {
removeWeaklyReachableObjects()
val retainedRef = watchedObjects[key]
if (retainedRef != null) {
retainedRef.retainedUptimeMillis = clock.uptimeMillis()
retainedObjects.add(key)
if (!hasRetainedObjects) {
hasRetainedObjects = true
// 只在首次发现泄漏时通知
onObjectRetainedListeners.forEach {
it.onObjectRetained()
}
}
}
}
// Dump完成后重置标志
fun clearRetainedObjects() {
hasRetainedObjects = false
retainedObjects.clear()
}
}
时序设计的关键考量:
时间轴(以Activity泄漏为例):
t=0ms Activity.onDestroy()
→ expectWeaklyReachable(activity)
→ 创建KeyedWeakReference + 调度5秒后检查
t=500ms (期间)GC可能自动发生
→ 如果回收了 → WeakReference自动入队
t=5000ms 第一次检查
→ removeWeaklyReachableObjects()
→ 检查引用队列
→ 如果不在 → 手动触发 GC
t=5100ms 第二次检查(GC后100ms)
→ removeWeaklyReachableObjects() 再次清理
→ 如果还在 → 确认泄漏 → retainedCount++
t=6000ms (假设) 累积5个泄漏对象
→ 触发 HeapDump
为什么选5秒?
1. 给GC足够的自然执行时间(大部分对象5秒内被回收)
2. 5秒是用户操作间隙的合理时间
3. 过短 → GC没来得执行 → 误报
4. 过长 → 泄漏对象占用内存时间久 → 增加OOM风险
5. 可通过 config.watchDurationMillis 自定义
# 04.一些技术点思考
# 4.1 WeakReference与ReferenceQueue机制
Java中有四种引用类型,LeakCanary利用了WeakReference的特性:
Java四种引用类型:
强引用 (Strong Reference)
Object obj = new Object();
└── GC时不会回收,即使OOM也不回收
└── 这是内存泄漏的根本原因
软引用 (SoftReference)
SoftReference<Object> soft = new SoftReference<>(obj);
└── 内存不足时才会回收
└── 适合做内存敏感的缓存
弱引用 (WeakReference) ← LeakCanary使用
WeakReference<Object> weak = new WeakReference<>(obj, queue);
└── 只要发生GC就会回收(如果没有强引用指向)
└── 可以关联ReferenceQueue
└── 对象被回收时,WeakReference自动入队
虚引用 (PhantomReference)
PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);
└── 随时可能被回收
└── 必须配合ReferenceQueue使用
└── get()方法永远返回null
ReferenceQueue的工作原理:
ReferenceQueue工作原理:
1. 创建WeakReference时关联ReferenceQueue
WeakReference ref = new WeakReference(obj, queue)
2. 当obj被GC回收时
JVM将ref放入queue中(由GC线程完成)
3. 应用线程通过queue.poll()获取已入队的引用
→ 如果返回非null,说明对应的对象已被回收
→ 如果返回null,说明没有新的对象被回收
LeakCanary的使用方式:
创建WeakReference → 关联queue → 5秒后poll queue
→ 如果该WeakReference在queue中 → 对象已回收(正常)
→ 如果不在queue中 → 对象未回收(可能泄漏)
JVM层面ReferenceQueue入队机制深入分析:
ReferenceQueue并非普通的链表队列,它的入队操作是由JVM的GC直接驱动的:
JVM Reference处理全景图:
┌─────────────────────────────────────────────────────────┐
│ GC线程(虚拟机层面) │
│ │
│ GC标记阶段:遍历所有可达对象 │
│ ├── 从GC Root出发,标记所有可达对象(强引用) │
│ └── 未被标记的对象 → 判定为垃圾 │
│ │
│ GC回收阶段: │
│ ├── 检查垃圾对象是否被Reference子类引用 │
│ │ ├── WeakReference → 对象finalize后回收 │
│ │ ├── SoftReference → 内存充足时不回收 │
│ │ └── PhantomReference → 仅跟踪回收时机 │
│ │ │
│ └── 对于被WeakReference引用的对象: │
│ ├── 回收该对象 ← 释放内存 │
│ └── 将WeakReference放入关联的ReferenceQueue │
│ │
└─────────────────────────────────────────────────────────┘
│
▼ ReferenceQueue内部数据结构
┌─────────────────────────────────────────────────────────┐
│ ReferenceQueue (JVM实现) │
│ │
│ 内部结构:单链表 │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ head │──→│ ref1 │──→│ ref2 │──→ null │
│ └──────┘ └──────┘ └──────┘ │
│ │
│ 入队(enqueue):GC线程调用 Reference.enqueue() │
│ └── 将Reference插入链表尾部 │
│ └── synchronized保证线程安全 │
│ │
│ 出队(poll/remove):应用线程调用 │
│ ├── poll(): 非阻塞,返回null如果队列空 │
│ └── remove(): 阻塞等待,直到有元素入队 │
│ │
└─────────────────────────────────────────────────────────┘
ART虚拟机中Reference的特殊处理:
Android ART虚拟机对Reference有专门的处理逻辑,位于 runtime/gc/reference_processor.cc:
ART ReferenceProcessor 处理流程:
1. GC发现仅有WeakReference引用的对象
→ 不立即回收(需要先处理ReferenceQueue)
2. GC调用 ReferenceProcessor::ProcessReferences()
→ 遍历所有待处理的Reference
→ 对于SoftReference:检查内存压力,决定是否保留引用
→ 对于WeakReference:清除引用(referent = null)
→ 调用 Reference.enqueue() 将引用加入队列
3. Java层通过queue.poll()感知回收事件
关键设计:
→ GC线程直接操作Java对象(ReferenceQueue链表)
→ 不需要Java层的线程协调
→ 效率高,无上下文切换开销
为何WeakReference的get()可能返回非null但不在队列中?
这是LeakCanary判断中的边缘情况:
// 场景:GC进行中,引用尚未入队
WeakReference<Object> ref = new WeakReference<>(obj, queue);
// ... GC发生,obj被标记为垃圾 ...
// GC线程正在清除引用,但尚未完成enqueue操作
// 此时:
ref.get() // 返回 null(引用已被清除)
queue.poll() // 返回 null(引用尚未入队!)
// 这是RTFM(Race Between GC and Application Thread)
// LeakCanary的处理方式:
// 等待5秒 + 多次GC + sleep(100ms)
// 让GC有足够时间完成整个引用处理流程
// 避免因时序问题产生误报
ReferenceHandler守护线程:
Java虚拟机内部有一个名为"Reference Handler"的守护线程,专门负责处理引用对象的pending队列:
ReferenceHandler工作流程(简化版):
1. JVM维护一个全局的pending链表
└── GC线程将待处理的Reference放入此链表
2. ReferenceHandler守护线程持续运行
└── 从pending链表取出Reference
└── 对于WeakReference:清除referent字段
└── 调用 enqueue() 将引用放入用户指定的ReferenceQueue
3. 应用线程通过queue.poll()获取
延迟分析:
GC回收对象 → pending链表 → ReferenceHandler处理 → ReferenceQueue
这个链路存在约1-50ms的不确定性延迟
LeakCanary的100ms等待时间就是为了覆盖这个延迟
# 4.2 引用链如何生成
泄漏对象的引用链是分析报告的核心信息。LeakCanary使用广度优先搜索(BFS)算法在对象图中查找最短引用链:
引用链生成算法(BFS):
输入:
- GC Root对象集合
- 目标泄漏对象
算法:
queue = [所有GC Root]
visited = {}
parent = {} // 记录每个对象的前驱节点
while queue is not empty:
current = queue.dequeue()
if current == 泄漏对象:
return reconstructPath(parent, current) // 重建路径
for each reference in current.references:
child = reference.target
if child not in visited:
visited.add(child)
parent[child] = (current, reference.name)
queue.enqueue(child)
return null // 未找到路径
路径重建:
从泄漏对象沿着parent链回溯到GC Root
→ 得到GC Root → ... → 泄漏对象的完整引用链
→ 因为是BFS,保证是最短路径
为什么选择BFS而不是DFS?
BFS能保证找到的是最短引用链,这对于开发者排查问题非常重要。最短引用链意味着最直接的泄漏原因,减少了排查的干扰信息。
Shark中PathFinder的完整实现分析:
// Shark引擎中PathFinder的核心实现(简化版)
class PathFinder(
private val graph: HeapGraph,
private val listener: PathFindingListener
) {
// 结果容器
private val pathsToLeakingObjects = mutableListOf<PathFindingResult>()
fun findPaths(
leakingObjectIds: Set<Long>,
computeRetainedHeapSize: Boolean = false
): PathFindingResults {
// 1. 获取所有GC Root
val gcRoots = graph.gcRoots.toList()
// 2. 初始化BFS队列(使用优先级队列支持不同引用类型)
val queue = PriorityQueue<BfsNode>(compareBy { it.distance })
// 3. 将所有GC Root加入队列作为起点
for (gcRoot in gcRoots) {
queue.offer(BfsNode(gcRoot.id, gcRoot, null, 0))
}
// 4. BFS主循环
val visited = mutableSetOf<Long>()
val parentMap = mutableMapOf<Long, BfsNode>()
val foundLeakingIds = mutableSetOf<Long>()
while (queue.isNotEmpty() && foundLeakingIds.size < leakingObjectIds.size) {
val current = queue.poll()
if (current.objectId in visited) continue
visited.add(current.objectId)
// 检查是否到达泄漏对象
if (current.objectId in leakingObjectIds) {
foundLeakingIds.add(current.objectId)
// 重建路径
val path = reconstructPath(current)
pathsToLeakingObjects.add(
PathFindingResult(current.objectId, path)
)
continue
}
// 遍历当前对象的所有引用字段
val heapObject = graph.findObjectById(current.objectId)
for (reference in heapObject.readReferences()) {
val childId = reference.valueObjectId
if (childId !in visited && childId != ValueHolder.NULL_REFERENCE) {
parentMap[childId] = current
// 距离计算考虑引用类型权重
// 强引用权重最低(0),优先遍历
// 软引用权重高(2),延后遍历
val weight = when (reference.referenceType) {
ReferenceType.STRONG -> 0
ReferenceType.SOFT -> 2
ReferenceType.WEAK -> 4
}
queue.offer(
BfsNode(childId, reference, current,
current.distance + weight)
)
}
}
}
return PathFindingResults(pathsToLeakingObjects, foundLeakingIds)
}
// 从BFS节点反向重建引用路径
private fun reconstructPath(endNode: BfsNode): ShortestPath {
val nodes = mutableListOf<LeakTraceObject>()
var current: BfsNode? = endNode
while (current != null) {
nodes.add(
LeakTraceObject(
type = current.type,
reference = current.reference,
leakingStatus = determineLeakingStatus(current)
)
)
current = current.parent
}
return ShortestPath(nodes.reversed()) // 反转得到 GC Root → 泄漏对象
}
}
引用类型优先级的BFS策略:
Shark的BFS不是普通的等权BFS,而是带权重的优先搜索:
引用类型权重分配:
ReferenceType.STRONG → 权重 0(最高优先级,优先搜索)
→ 强引用路径才是真正导致泄漏的原因
→ BFS优先探索强引用路径
ReferenceType.SOFT → 权重 2
→ 软引用路径次之
ReferenceType.WEAK → 权重 4(最低优先级)
→ 弱引用路径不构成真正的泄漏(因为GC会回收WeakReference)
为什么带权BFS?
→ 找到的路径必须包含至少一条强引用链
→ 如果最短路径都是弱引用/软引用 → 对象不该泄漏
→ 带权BFS保证找到的是"最强最短"引用链
Shark如何高效读取对象引用关系:
// Shark按需读取对象引用(Lazy Loading)
fun HeapObject.readReferences(): Sequence<HeapReference> {
return when (this) {
is HeapInstance -> {
// 实例对象:读取所有引用类型字段
val record = hprofIndex.readInstanceRecord(objectId)
record.fieldValues
.filter { it.type == HprofValueType.OBJECT }
.map { field ->
HeapReference(
name = field.fieldName,
valueObjectId = field.value.asObjectId,
referenceType = ReferenceType.STRONG
)
}
}
is HeapObjectArray -> {
// 对象数组:遍历所有元素
val record = hprofIndex.readObjectArrayRecord(objectId)
record.elementIds.mapIndexed { index, elementId ->
HeapReference(
name = "[$index]",
valueObjectId = elementId,
referenceType = ReferenceType.STRONG
)
}
}
is HeapPrimitiveArray -> {
// 基本类型数组:没有对象引用
emptySequence()
}
is HeapClass -> {
// Class对象:读取静态字段的引用
val record = hprofIndex.readClassRecord(objectId)
record.staticFields
.filter { it.type == HprofValueType.OBJECT }
.map { field ->
HeapReference(
name = field.fieldName,
valueObjectId = field.value.asObjectId,
referenceType = ReferenceType.STATIC_FIELD
)
}
}
}
}
# 4.3 Shark分析引擎原理
Shark(Smart Heap Analysis Reports for Kotlin)是LeakCanary 2.x专门开发的hprof分析引擎:
Shark vs haha 对比:
haha (LeakCanary 1.x):
├── 基于Android Studio的hprof解析器
├── 一次性加载整个hprof到内存
├── 使用HashMap存储对象信息
├── 内存消耗大,分析慢
└── 不再维护
Shark (LeakCanary 2.x):
├── 全新设计,使用Kotlin编写
├── 两次遍历策略(建索引+按需读取)
├── 使用Okio进行高效IO操作
├── 内存消耗小(按需加载)
├── 分析速度快约6倍
└── 支持更丰富的分析能力
Shark的两次遍历策略:
第一次遍历:建立索引
→ 只记录每个对象的ID和文件偏移
→ 不读取对象的具体字段数据
→ 索引数据结构紧凑,内存开销小
第二次遍历(按需):
→ BFS搜索时,需要某个对象的引用信息
→ 根据索引直接seek到文件对应位置读取
→ 避免将整个堆加载到内存
HprofIndex 索引构建的完整原理:
HprofIndex是Shark引擎的核心索引结构,决定了分析效率:
// HprofIndex的索引结构(简化版)
class HprofIndex private constructor(
private val hprof: Hprof,
private val indexedGcRootTags: Set<HprofRecordTag>
) {
// ===== 核心索引数据结构 =====
// 1. String索引:字符串ID → 字符串内容
// 用于将hprof中的字符串ID解析为可读的类名/字段名
private val stringIndex: Map<Long, String>
// 2. Class索引:类ID → HprofRecord(类定义Record)
// 存储类的完整定义(父类、字段、静态字段值)
private val classIndex: Map<Long, HprofRecord.HeapDumpRecord.ClassRecord>
// 3. Instance索引:对象ID → (文件偏移量, 类ID)
// 核心索引!记录每个实例对象在hprof文件中的位置
// 不存储对象的具体字段数据 → 内存节省
private val instanceIndex: Map<Long, LongOffsetToClassId>
// 4. Object Array索引:数组ID → (文件偏移量, 元素类型)
private val arrayIndex: Map<Long, LongOffsetToArrayType>
// 5. Primitive Array索引:基本类型数组ID → 文件偏移量
private val primitiveArrayIndex: Map<Long, Long>
// 6. GC Root列表:提前存储的GC Root信息
private val gcRoots: List<GcRoot>
// 按需读取实例数据
fun readInstanceRecord(objectId: Long): HprofRecord.InstanceRecord {
val (offset, classId) = instanceIndex[objectId]
?: throw IllegalStateException("Unknown object: $objectId")
// 通过索引记录的偏移量,直接seek到文件位置读取
return hprof.readInstanceRecordAt(offset, objectId, classId)
}
// 按需读取对象数组数据
fun readObjectArrayRecord(objectId: Long): HprofRecord.ObjectArrayRecord {
val (offset, arrayType) = arrayIndex[objectId]
?: throw IllegalStateException("Unknown array: $objectId")
return hprof.readObjectArrayRecordAt(offset, objectId, arrayType)
}
}
// 轻量级数据结构
data class LongOffsetToClassId(
val offset: Long, // 文件偏移量(8字节)
val classId: Long // 类ID(8字节)
) // 每条记录仅16字节!vs 完整加载可能需要KB级
第一次遍历 - 索引构建的详细流程:
第一次遍历(索引构建)流程:
┌─────────────────────────────────────────────────────┐
│ 打开hprof文件 │
│ ↓ │
│ 读取文件头(format, idSize, timestamp) │
│ ↓ │
│ while (还有Record可读): │
│ ├── 读取Record Header(tag + time + length) │
│ │ │
│ ├── STRING Record: │
│ │ → stringIndex[StringId] = "com.example.Activity"│
│ │ │
│ ├── LOAD_CLASS Record: │
│ │ → 暂存classSerialNumber到classId的映射 │
│ │ │
│ ├── CLASS_DUMP Record: │
│ │ → classIndex[ClassId] = ClassRecord(...) │
│ │ → 记录父类、字段定义、静态字段值 │
│ │ │
│ ├── INSTANCE_DUMP Record: │
│ │ → instanceIndex[ObjectId] = (offset, classId) │
│ │ → 关键:只记偏移量,不解析字段数据! │
│ │ │
│ ├── OBJECT_ARRAY_DUMP Record: │
│ │ → arrayIndex[ArrayId] = (offset, elementType) │
│ │ │
│ └── 跳过多余记录(如HEAP_DUMP_INFO) │
│ │
│ 完成 → 关闭输入流 │
└─────────────────────────────────────────────────────┘
内存对比:
hprof文件: 50MB
haha全量加载: ~300MB(对象+HashMap开销)
Shark索引加载: ~15MB(仅索引结构)
第二次遍历(按需)的优化技巧:
// Shark按需读取的关键:基于索引的随机访问
fun readInstanceFieldValues(offset: Long, classId: Long): List<FieldValue> {
// 1. 通过索引获取文件偏移量
// 2. 使用Okio的BufferedSource + seek()定位到指定位置
return hprofFile.source().use { source ->
val bufferedSource = source.buffer()
// 跳到实例数据的起始位置
bufferedSource.skip(offset)
// 从ClassIndex获取该类的字段定义
val classRecord = classIndex[classId]!!
val fields = classRecord.fields // List<Field>
// 按字段定义顺序读取值
fields.map { field ->
val type = field.type
val value = when (type) {
BasicType.OBJECT -> {
val refId = bufferedSource.readId()
// 如果是0(null引用),不需要进一步索引查找
if (refId == 0L) FieldValue.NullReference
else FieldValue.Reference(refId)
}
BasicType.BOOLEAN -> FieldValue.Boolean(bufferedSource.readByte() != 0.toByte())
BasicType.INT -> FieldValue.Int(bufferedSource.readInt())
BasicType.LONG -> FieldValue.Long(bufferedSource.readLong())
// ... 其他基本类型
}
FieldValue(field.name, type, value)
}
}
}
Okio在Shark中的应用:
Shark选择Okio作为IO层,而非Java标准IO,有以下优势:
- 零拷贝Buffer:
Buffer类内部使用链表管理数据分段,避免数据拷贝 - 高效的seek操作:基于偏移量的精确定位,避免重复读取
- 内存可控:分段读取大文件,不会占用连续大块内存
- 协程友好:可集成Kotlin协程实现非阻塞IO
# 4.4 提高Dump分析效率
Dump分析是十分耗时的行为。由于LeakCanary分析堆快照的过程存在一定的内存消耗,整个分析过程一般会持续几十秒,对于一些性能差的机型会造成明显的卡顿甚至ANR。
优化方案:
- 多进程分析:将分析工作放到独立进程中,不影响主进程性能
- 线程异步执行:即使在同一进程中,也使用后台线程执行
- 按需加载:Shark引擎不一次性加载整个hprof到内存
- 缓存机制:对相同泄漏模式的结果进行缓存
分析策略选择:
LeakCanary会根据依赖项选择策略:
├── 如果添加了leakcanary-android-process依赖
│ └── 使用独立进程(:leakcanary)进行分析
│ └── 完全不影响主进程
├── 否则
│ └── 使用后台Thread进行分析
│ └── 开启前台Service保证不被杀
# 4.5 相同问题分组
LeakCanary会将相同问题重复触发的内存泄漏进行分组,减少重复排查工作。
分组方法:按引用链的签名
泄漏签名计算方式:
引用链签名 = hash(每个引用节点的类型拼接)
示例:
泄漏1的引用链:
ActivityThread → ArrayMap → Object[] → ActivityClientRecord → MainActivity
签名 = hash("ActivityThread.ArrayMap.Object[].ActivityClientRecord.MainActivity")
泄漏2的引用链(同一个泄漏触发第二次):
ActivityThread → ArrayMap → Object[] → ActivityClientRecord → MainActivity
签名 = hash("ActivityThread.ArrayMap.Object[].ActivityClientRecord.MainActivity")
签名相同 → 归为同一组 → 只需排查一次
# 4.6 如何标记怀疑对象
为了提高排查效率,LeakCanary会自动帮助缩小排查范围:
标记策略:
1. 已知的系统级对象 → Leaking: NO
如ActivityThread、WindowManagerGlobal等
这些对象本身就有全局生命周期,不是泄漏原因
2. 目标泄漏对象 → Leaking: YES
已确认被销毁但未被回收的对象
如已调用onDestroy()的Activity
3. 怀疑对象 → 用~~~标记
位于Leaking: NO和Leaking: YES之间的对象
这些对象就是需要开发者重点排查的
示例:
├─ android.app.ActivityThread Leaking: NO
│ ↓ ActivityThread.mHandler
├─ android.os.Handler Leaking: NO
│ ↓ Handler.mCallback
│ ~~~~~~~~~ ← 怀疑点
├─ com.example.MyCallback Leaking: UNKNOWN
│ ↓ MyCallback.activity
│ ~~~~~~~~ ← 怀疑点
╰→ com.example.MainActivity Leaking: YES
# 4.7 GC触发与等待策略深度解析
LeakCanary为何需要手动触发GC?为什么需要等待100ms?为什么需要多次GC?这些设计背后有深刻的技术考量。
System.gc() vs Runtime.gc() 的选择:
// LeakCanary实际使用的方式
Runtime.getRuntime().gc() // ← 选这个
Runtime.getRuntime().runFinalization()
// 为什么不直接用 System.gc()?
// System.gc() 内部调用了 Runtime.getRuntime().gc()
// 两者功能完全等价,但Runtime.gc()更明确
// 关键注解:当前虚拟机的GC实现
// HotSpot/ART 中 gc() 默认触发 Full GC
// → 如果虚拟机配置 -XX:+DisableExplicitGC
// → System.gc() 将被忽略!这是线上环境的常见设置
// → 这也是LeakCanary不适合线上的原因之一
GC触发策略的完整流程分析:
LeakCanary的GC触发与等待策略(三次GC重试机制):
第一次检查(5秒后):
├── removeWeaklyReachableObjects() → 清理已入队的引用
├── watchedObjects中还有目标对象?
│ ├── 否 → 正常结束
│ └── 是 → 进入GC重试流程
│
├── GC重试 Round 1:
│ ├── Runtime.getRuntime().gc() ← 请求Full GC
│ ├── Thread.sleep(100) ← 等待GC完成
│ ├── Runtime.getRuntime().runFinalization() ← 加速finalize
│ └── removeWeaklyReachableObjects() → 检查结果
│ ├── 已回收 → 正常结束
│ └── 未回收 → 进入Round 2
│
├── GC重试 Round 2:
│ └── 重复Round 1的步骤
│ ├── 已回收 → 正常结束
│ └── 未回收 → 进入Round 3
│
└── GC重试 Round 3:
└── 重复Round 1的步骤
├── 已回收 → 正常结束
└── 未回收 → 确认泄漏!→ retainedCount++
为什么需要100ms等待?
GC执行的时间窗口分析:
t=0ms Runtime.gc() 调用
→ 设置 GC 标志位
→ 不保证立即执行
t=0~10ms 虚拟机调度GC线程
→ GC线程被唤醒
→ 准备执行GC
t=10~50ms GC标记阶段
→ 标记所有可达对象
→ 包括finalize队列的处理
t=50~80ms GC清理阶段
→ 回收不可达对象内存
→ ReferenceHandler处理引用
→ 将WeakReference放入ReferenceQueue
t=80~100ms GC完成
→ 内存释放完成
→ 引用队列更新完成
100ms的等待时间:
→ 覆盖了 ART GC 最坏情况下的完整执行时间
→ 考虑了 ReferenceHandler 的处理延迟
→ 考虑了 finalize 方法的执行时间
→ 100ms对用户体验几乎无感知
为什么可能需要3次GC?
Java GC的"不保证"特性:
为什么一次GC不够?
场景1: Young GC vs Full GC
Runtime.gc() 请求Full GC,但虚拟机可能:
├── 只执行 Young GC(轻量级)
│ └── Young Gen中的WeakReference被回收 → 入队 ✓
└── Old Gen未被扫描
└── Old Gen中的WeakReference未被处理 → 仍在监控中 ✗
场景2: Finalize延迟
有finalize()方法的对象需要两次GC:
GC#1 → 标记对象为finalizable → 放入finalization队列
→ 等待finalizer线程执行finalize()
GC#2 → finalize()已执行 → 真正回收对象 → 引用入队
runFinalization() 可以加速这个过程,但不保证同步完成
场景3: 并发GC的不确定性
ART的并发GC(CMS, CC):
→ GC与应用线程并发执行
→ 标记阶段不暂停应用
→ 只有最终清理阶段短暂暂停
→ 引用入队可能延迟到下一轮GC
三次GC重试覆盖了以上所有情况的最坏组合
LeakCanary中运行时GC的关闭机制:
// 可通过配置禁用运行时GC(某些测试场景)
objectWatcher.config = objectWatcher.config.copy(
// 是否在每个Activity.onDestroy()时触发GC
runGCWhenExceedingThreshold = true // 默认true
)
// 或者在LeakCanary Config中控制
LeakCanary.config = LeakCanary.config.copy(
dumpHeap = true,
// 是否在dump前手动触发GC
runGCBeforeDump = true
)
# 4.8 堆快照解析的两次遍历策略深度解析
Shark引擎采用"两次遍历 + 按需加载"策略,这是它比haha快6倍的核心秘密。本节深入剖析这个设计的原理和实现。
问题背景:hprof文件的特性
hprof文件是一个线性的二进制流,Record按时间顺序排列,没有随机访问结构:
hprof文件的线性特性造成的挑战:
┌──────────────┬──────────────┬──────────────┬──────────────┬──────────────┐
│ STRING │ LOAD_CLASS │ INSTANCE │ ARRAY │ CLASS_DUMP │
│ "Activity" │ 0x1234 │ 0xAABB data │ 0xCCDD data │ 0x1234 │
└──────────────┴──────────────┴──────────────┴──────────────┴──────────────┘
↑ 第1个Record ↑ 第100个Record ↑ 第50000个Record ↑ 第80000个Record
挑战:
1. 对象的类定义(CLASS_DUMP)可能在实例数据(INSTANCE)之后
→ 必须先把所有CLASS_DUMP找出来才能解析INSTANCE
2. BFS搜索时,需要访问:当前对象 → 所有引用的子对象
如果每次访问都遍历整个文件 → 时间复杂度O(n²) → 不可接受
3. 堆可能很大(300MB+),全部加载到内存 → OOM
Shark两次遍历的设计原理:
第一次遍历(Build Index):O(n) 顺序读
目的:构建所有对象的"文件位置目录"
┌──────────────────────────────────────────────────────────┐
│ HprofIndex 结构 │
│ │
│ instanceIndex: Map<ObjectId, ObjectPosition> │
│ ┌─────────┬─────────────────────┬──────────────┐ │
│ │ 0xAABB │ offset=102400, cid= │ 0x1234 │ │
│ │ 0xBBCC │ offset=204800, cid= │ 0x5678 │ │
│ │ 0xCCDD │ offset=307200, cid= │ 0x1234 │ │
│ │ 0xDDEE │ offset=409600, cid= │ 0x9ABC │ │
│ │ ... │ ... │ ... │ │
│ └─────────┴─────────────────────┴──────────────┘ │
│ │
│ 每个条目:ObjectId(8B) + Offset(8B) + ClassId(8B) │
│ = 24字节/对象 │
│ 500,000个对象 × 24字节 = 仅12MB │
│ vs 完整加载到内存:可能需要200MB+ │
└──────────────────────────────────────────────────────────┘
第二次遍历(On-demand):O(k) 按需读
BFS搜索时,需要查看对象0xAABB的引用字段:
1. instanceIndex[0xAABB] → (offset=102400, classId=0x1234)
2. classIndex[0x1234] → fields: [name:String, age:int, manager:Employee]
3. 文件.seek(102400) → 按字段顺序读取实例值
4. 返回引用列表:[childId1, childId2, ...]
关键:只读取当前被访问的对象,不加载整个堆
索引存储的高效实现:
// Shark索引设计的核心优化
// 1. 针对密集ID的优化存储
// 对象ID在hprof文件中通常是密集排列的
// (如 0x1000, 0x1008, 0x1010, ...)
// 使用数组代替HashMap可以大幅节省内存
class DenseObjectIndex(
private val idSize: Int,
private val baseId: Long,
private val offsets: LongArray, // 按ID偏移量直接数组访问
private val classIds: LongArray
) {
// O(1) 查找,无HashMap开销
fun getOffset(objectId: Long): Long {
val index = ((objectId - baseId) / idSize).toInt()
return offsets[index]
}
// 内存对比:
// HashMap<Long, Long> : 每个Entry ~56字节(Node对象+Long key+Long value)
// LongArray : 每个元素 8字节
// 内存节省约 7倍!
}
// 2. 针对稀疏ID的优化存储
// 如果对象ID分布稀疏,使用排序数组+二分查找
class SparseObjectIndex(
private val ids: LongArray, // 排序后的ID数组
private val offsets: LongArray, // 对应的偏移量
private val classIds: LongArray
) {
// O(log n) 查找,内存紧凑
fun getOffset(objectId: Long): Long {
val index = ids.binarySearch(objectId)
return if (index >= 0) offsets[index]
else throw ObjectNotFoundException(objectId)
}
}
实际性能对比数据:
Benchmark: 分析 50MB hprof 文件,包含约600,000个对象
| 指标 | haha (1.x) | Shark (2.x) | 提升 |
|------------------|--------------|---------------|---------|
| 索引阶段耗时 | 直接加载 | 2.3秒 | N/A |
| 内存峰值 | ~280MB | ~45MB | 6.2倍 |
| BFS搜索耗时 | 8.5秒 | 1.2秒 | 7.1倍 |
| 总分析耗时 | 12秒 | 3.5秒 | 3.4倍 |
| 是否可在低端机运行 | × (OOM) | ✓ | - |
注:haha将整个堆加载到内存后才开始分析,Shark边加载边分析
Shark的两阶段分析Pipeline设计:
分析Pipeline的流水线设计:
阶段1: IndexingPhase (索引阶段)
┌─────────────────────────────────┐
│ HprofFileReader → 顺序扫描hprof │
│ ├── 按顺序读取每个Record │
│ ├── 提取关键索引信息 │
│ └── 构建HprofIndex │
└────────────┬────────────────────┘
│ HprofIndex
▼
阶段2: AnalysisPhase (分析阶段)
┌─────────────────────────────────┐
│ PathFinder → BFS搜索引用链 │
│ ├── 从索引获取对象位置 │
│ ├── seek到文件位置按需读取 │
│ └── 构建最短引用链 │
└────────────┬────────────────────┘
│ List<ShortestPath>
▼
阶段3: ReportingPhase (报告阶段)
┌─────────────────────────────────┐
│ LeakTraceRenderer → 生成报告 │
│ ├── 格式化为可读的引用链 │
│ ├── 标记怀疑对象 │
│ └── 计算泄漏签名用于分组 │
└─────────────────────────────────┘
Shark支持的分析类型扩展:
Shark不仅是泄漏分析工具,还支持多种堆分析场景:
// Shark支持的分析类型
sealed class HeapAnalysis {
// 1. 泄漏分析(LeakCanary主场景)
data class LeakAnalysis(
val leakTraces: List<LeakTrace>,
val leakCount: Int,
val metadata: Map<String, String>
) : HeapAnalysis()
// 2. 堆增长分析(找出堆中最大的对象)
data class HeapGrowthAnalysis(
val topGrowingObjects: List<ObjectGrowth>,
val growthRate: Double,
val metadata: Map<String, String>
) : HeapAnalysis()
// 3. 元数据分析(统计类实例数量、堆中对象分布)
data class MetadataAnalysis(
val classCount: Map<String, Int>,
val totalInstanceCount: Long,
val totalHeapSize: Long,
val metadata: Map<String, String>
) : HeapAnalysis()
}
# 05.优秀代码设计解析
# 5.1 设计模式在LeakCanary中的应用
LeakCanary的源码中运用了多种经典设计模式,值得深入学习:
LeakCanary中的设计模式:
1. 观察者模式(核心)
ObjectWatcher作为被观察者,各种Watcher作为观察者
→ OnObjectRetainedListener接口:观察泄漏事件
→ 当泄漏对象被确认时,通知所有Listener
→ 解耦了泄漏检测和泄漏处理逻辑
// 观察者接口
fun interface OnObjectRetainedListener {
fun onObjectRetained()
}
// 注册观察者
objectWatcher.addOnObjectRetainedListener(listener)
// 通知观察者(在moveToRetained中)
onObjectRetainedListeners.forEach { it.onObjectRetained() }
2. 策略模式
不同组件的监听策略不同,通过InstallableWatcher接口统一:
→ ActivityWatcher:使用ActivityLifecycleCallbacks策略
→ FragmentAndViewModelWatcher:使用FragmentLifecycleCallbacks策略
→ ServiceWatcher:使用Hook ActivityThread策略
→ RootViewWatcher:使用Hook WindowManager策略
每种Watcher的install/uninstall逻辑完全不同
但对外提供统一的InstallableWatcher接口
→ 新增监控类型只需实现新的Watcher,不影响已有代码
3. 工厂方法模式
HeapDumper接口定义了Dump操作的抽象:
→ AndroidDebugHeapDumper:使用Debug.dumpHprofData()
→ 可以替换为自定义实现(如KOOM的fork方案)
→ 解耦Dump策略和使用方
4. 模板方法模式
ContentProvider的onCreate作为模板方法:
→ AppWatcherInstaller定义初始化模板
→ MainProcess和LeakCanaryProcess是两种不同的初始化流程
→ sealed class实现,编译时确定子类
5. 建造者模式
AppWatcher.Config通过DSL风格的建造者构建:
AppWatcher.config = AppWatcher.config.copy(
retainedObjectTracker = ...,
watchDurationMillis = 5000
)
→ Kotlin的data class + copy()天然支持建造者模式
→ 不可变对象设计,线程安全
6. 门面模式(Facade)
AppWatcher和LeakCanary类是门面:
→ 对外只暴露少量简洁API
→ 内部协调ObjectWatcher、HeapDumpTrigger、Shark等复杂组件
→ 降低使用者的学习成本
# 5.2 sealed class的巧妙使用
LeakCanary使用Kotlin的sealed class实现类型安全的层次结构,这是非常优秀的代码设计:
// 自动初始化的sealed class设计
internal sealed class AppWatcherInstaller : ContentProvider() {
// 主进程初始化
internal class MainProcess : AppWatcherInstaller()
// LeakCanary独立进程初始化
internal class LeakCanaryProcess : AppWatcherInstaller()
override fun onCreate(): Boolean {
val application = context!!.applicationContext as Application
AppWatcher.manualInstall(application)
return true
}
// ContentProvider的其他方法返回默认值(空实现)
override fun query(...) = null
override fun getType(...) = null
override fun insert(...) = null
override fun delete(...) = 0
override fun update(...) = 0
}
// 设计精妙之处:
// 1. sealed class限制了子类范围(只有MainProcess和LeakCanaryProcess)
// 2. 在Manifest中根据进程选择不同的ContentProvider子类
// 3. 利用ContentProvider的自动创建特性实现零配置
// 4. internal修饰符防止外部继承和使用
// LeakTrace中对泄漏状态的sealed class建模
sealed class LeakingStatus {
object YES : LeakingStatus()
object NO : LeakingStatus()
object UNKNOWN : LeakingStatus()
}
// 引用链节点也使用sealed class
sealed class LeakTraceReference {
data class InstanceFieldReference(val declaringClassName: String, val fieldName: String)
data class StaticFieldReference(val declaringClassName: String, val fieldName: String)
data class ArrayReference(val index: Int)
// ...
}
// 优势:
// → when表达式编译时检查分支完整性
// → 比enum更灵活,每个子类可以有不同字段
// → 比普通继承更安全,限制了子类范围
# 5.3 委托模式与扩展函数的运用
// noOpDelegate() — 委托模式的精妙运用
// 只需关注onActivityDestroyed,其他回调不需要实现
private val lifecycleCallbacks =
object : Application.ActivityLifecycleCallbacks by noOpDelegate() {
override fun onActivityDestroyed(activity: Activity) {
reachabilityWatcher.expectWeaklyReachable(
activity, "${activity::class.java.name} received Activity#onDestroy()"
)
}
}
// noOpDelegate()的实现:使用动态代理创建空实现
inline fun <reified T : Any> noOpDelegate(): T {
val javaClass = T::class.java
return Proxy.newProxyInstance(
javaClass.classLoader,
arrayOf(javaClass)
) { _, _, _ ->
// 所有方法返回默认值
} as T
}
// 设计优势:
// 1. 避免实现大量不需要的回调方法(ActivityLifecycleCallbacks有7个方法)
// 2. by关键字实现接口委托,比Java的匿名内部类更简洁
// 3. 只override需要的方法,代码意图更清晰
// 4. 通用的noOpDelegate可以用于任何接口
# 5.4 KeyedWeakReference的设计
// KeyedWeakReference — 核心数据结构设计
class KeyedWeakReference(
referent: Any, // 被监控的对象
val key: String, // 唯一标识(UUID)
val description: String, // 描述信息
val watchUptimeMillis: Long, // 开始监控时间
referenceQueue: ReferenceQueue<Any> // 关联的引用队列
) : WeakReference<Any>(referent, referenceQueue) {
@Volatile
var retainedUptimeMillis = -1L // 确认泄漏的时间(-1表示未泄漏)
companion object {
@Volatile
@JvmStatic
var heapDumpUptimeMillis = 0L // 最近一次HeapDump时间
}
}
// 设计思考:
// Q: 为什么需要key字段?
// A: WeakReference被回收后,referent(被监控对象)变为null
// 无法通过对象本身来标识是哪个引用
// 需要key作为唯一标识在watchedObjects Map中查找
// 同时ReferenceQueue中的引用也需要通过key来匹配
// Q: 为什么retainedUptimeMillis用@Volatile?
// A: 该字段可能在多个线程间读写:
// 检测线程设置值,主线程/分析线程读取值
// @Volatile保证可见性
// Q: 为什么使用uptimeMillis而不是System.currentTimeMillis?
// A: uptimeMillis不受系统时间修改影响
// 避免用户手动改时间导致判断逻辑出错
# 5.5 Clock抽象与可测试性设计
// LeakCanary的时间抽象 — 可测试性的典范
fun interface Clock {
fun uptimeMillis(): Long
}
// 生产环境使用真实时钟
val realClock = Clock { SystemClock.uptimeMillis() }
// 测试环境使用可控时钟
class FakeClock : Clock {
var currentTime = 0L
override fun uptimeMillis() = currentTime
fun advance(millis: Long) { currentTime += millis }
}
// 使用方式:
class ObjectWatcher(
private val clock: Clock,
private val checkRetainedExecutor: Executor,
...
) {
fun expectWeaklyReachable(watchedObject: Any, description: String) {
val watchUptimeMillis = clock.uptimeMillis()
// ...
}
}
// 设计优势:
// 1. 依赖注入:通过构造函数注入Clock,不硬编码系统调用
// 2. 可测试性:测试时注入FakeClock,精确控制时间流逝
// 3. fun interface:SAM接口,可用lambda创建,极简
// 4. 这是整洁架构中"依赖倒置原则"的体现
# 5.6 Shark引擎中的图遍历算法设计
Shark内存图遍历的设计亮点:
1. 两次遍历策略(Two-Pass Strategy)
第一次遍历:只建立索引
→ 记录每个对象的ID和文件偏移量
→ 使用紧凑的数据结构(如LongToLongMap)
→ 内存开销极小
第二次遍历:按需读取
→ BFS搜索时需要某个对象的引用信息
→ 通过索引seek到文件对应位置
→ 读取该对象的字段信息
→ 避免将整个堆加载到内存
2. 优先级队列的BFS
不是简单的BFS,而是考虑引用类型的优先级:
→ 强引用优先于软引用
→ 软引用优先于弱引用
→ 保证找到的路径是"最强"引用链
→ 这才是真正导致泄漏的原因
3. 已知泄漏的过滤
Shark内置了已知的Android Framework泄漏列表:
→ 如某些ROM的InputMethodManager持有Activity引用
→ 这类泄漏不是App的问题
→ Shark自动标记为"Known Library Leak"
→ 帮助开发者聚焦于自身代码的问题
4. 懒加载的对象图(Lazy HeapGraph)
HeapGraph不预先构建完整的对象关系图:
→ 每个HeapObject只在被访问时才解析其字段
→ 使用Sequence(懒序列)遍历字段引用
→ 大幅减少内存占用
→ 适合在移动设备上运行
# 06.方案基础设计
# 6.1 整体架构图
LeakCanary整体架构:
┌─────────────────────────────────────────────┐
│ 应用层 │
│ Activity / Fragment / ViewModel / Service │
└────────────────────┬────────────────────────┘
│ 生命周期回调
┌────────────────────▼────────────────────────┐
│ Watcher层 │
│ ActivityWatcher / FragmentAndViewModelWatcher│
│ ServiceWatcher / RootViewWatcher │
└────────────────────┬────────────────────────┘
│ expectWeaklyReachable()
┌────────────────────▼────────────────────────┐
│ ObjectWatcher │
│ WeakReference + ReferenceQueue │
│ watchedObjects Map / 泄漏判定逻辑 │
└────────────────────┬────────────────────────┘
│ 泄漏确认
┌────────────────────▼────────────────────────┐
│ HeapDumper │
│ Debug.dumpHprofData() / hprof文件生成 │
└────────────────────┬────────────────────────┘
│ hprof文件
┌────────────────────▼────────────────────────┐
│ Shark分析引擎 │
│ HprofParser / HeapGraph / BFS搜索 │
│ 引用链分析 / 泄漏签名 / 分组 │
└────────────────────┬────────────────────────┘
│ 分析结果
┌────────────────────▼────────────────────────┐
│ 展示层 │
│ Logcat / Notification / LeakActivity │
└─────────────────────────────────────────────┘
# 6.2 UML设计图
核心类关系:
AppWatcher
├── ObjectWatcher(核心监控器)
│ ├── watchedObjects: Map<String, KeyedWeakReference>
│ ├── queue: ReferenceQueue<Any>
│ └── expectWeaklyReachable() / checkRetainedCount()
│
├── ActivityWatcher(Activity监听器)
├── FragmentAndViewModelWatcher(Fragment+ViewModel监听器)
├── ServiceWatcher(Service监听器)
└── RootViewWatcher(RootView监听器)
HeapAnalyzer
├── SharkHeapGrowthDetector
├── HeapDumpTrigger
├── AndroidDebugHeapDumper
└── HeapAnalyzerService
Shark
├── HprofReader(hprof文件读取器)
├── HprofIndex(内存索引)
├── HeapGraph(对象图)
├── PathFinder(BFS路径查找)
└── LeakTraceObject(引用链节点)
# 6.3 关键流程图
关键流程 - 从Activity销毁到报告输出:
Activity.onDestroy()
↓
ActivityWatcher.onActivityDestroyed(activity)
↓
ObjectWatcher.expectWeaklyReachable(activity, desc)
├── 生成UUID作为key
├── 创建KeyedWeakReference(activity, key, desc, queue)
└── watchedObjects[key] = reference
↓
延迟5秒执行 moveToRetained(key)
├── removeWeaklyReachableObjects() → 清理已回收对象
└── watchedObjects中是否还有该key?
├── 没有 → 已回收,正常结束
└── 有 → retainedUptimeMillis设置当前时间 → 通知泄漏
↓
HeapDumpTrigger.checkRetainedObjects()
├── 泄漏计数 < 阈值? → 发通知 + 等待
└── 泄漏计数 >= 阈值 → dumpHeap()
↓
Debug.dumpHprofData(file) → 生成hprof文件
↓
HeapAnalyzerService.analyze(heapDumpFile)
├── Shark解析hprof
├── BFS搜索最短引用链
└── 生成LeakTrace
↓
展示结果(Logcat + Notification + UI页面)
# 6.4 接口设计图
核心接口设计:
interface ReachabilityWatcher {
fun expectWeaklyReachable(watchedObject: Any, description: String)
}
interface InstallableWatcher {
fun install()
fun uninstall()
}
interface HeapDumper {
fun dumpHeap(): DumpHeapResult
}
interface OnObjectRetainedListener {
fun onObjectRetained()
}
// ObjectWatcher实现了ReachabilityWatcher
// 各种Watcher实现了InstallableWatcher
// AndroidDebugHeapDumper实现了HeapDumper
# 6.5 模块间依赖关系
模块依赖关系:
leakcanary-android(主模块)
├── leakcanary-android-core(核心逻辑)
│ ├── leakcanary-object-watcher(对象监控)
│ │ └── leakcanary-object-watcher-android(Android平台适配)
│ └── shark(hprof分析引擎)
│ ├── shark-hprof(hprof文件解析)
│ ├── shark-graph(对象图构建)
│ └── shark-android(Android平台适配)
└── leakcanary-android-process(可选,多进程分析)
plumber-android(Android平台修复库)
└── 已知的Android框架泄漏的自动修复
# 07.其他设计说明
# 7.1 性能设计优化
为什么LeakCanary不能用于线上?
- 每次内存泄漏都会生成并解析hprof文件,容易引起手机卡顿
- Debug.dumpHprofData()会锁堆(Stop-The-World),应用冻结数秒
- 多次调用GC,可能对线上性能产生影响
- hprof文件较大(10-30MB),信息回捞困难
- Shark分析过程消耗较多内存和CPU
可能的线上优化方案:
- 根据手机信息设定内存阈值M,已使用内存小于M时只记录泄漏信息,不生成hprof
- 当引用链路相同时进行去重
- 不直接回捞hprof文件,选择回捞分析结果
- 将已泄漏对象存储在数据库中,同一泄漏只检测一次
- 使用KOOM的fork+COW方案避免锁堆
# 7.2 稳定性设计
LeakCanary在稳定性方面做了多方面考量:
稳定性设计:
1. 异常兜底
└── 所有Hook操作都有try-catch保护
└── 分析失败不会导致应用崩溃
└── Shark分析异常会输出错误信息而非Crash
2. 内存保护
└── Shark使用按需加载,避免OOM
└── hprof文件分析完毕后及时删除
└── 限制最大保留的hprof文件数量
3. 线程安全
└── ObjectWatcher的watchedObjects使用synchronized保护
└── ReferenceQueue的poll操作是线程安全的
└── 泄漏计数使用原子操作
4. 版本兼容
└── 不同Android版本的Hook策略不同
└── 对反射调用做版本适配
└── 优雅降级:Hook失败时不影响应用正常运行
# 7.3 灰度设计
在实际项目中使用LeakCanary时的灰度策略:
灰度策略建议:
1. Debug包默认开启
└── 开发和测试阶段全量开启
└── 帮助尽早发现泄漏
2. Release包关闭
└── LeakCanary默认只在debug包中生效
└── debugImplementation 'com.squareup.leakcanary:...'
└── Release包不包含LeakCanary代码
3. 线上灰度(使用KOOM等替代方案时)
└── 先灰度1%用户
└── 观察性能影响
└── 逐步扩大灰度比例
└── 设置采样率控制
# 7.4 降级设计
降级策略:
1. 设备性能降级
└── 低端设备(RAM < 3GB)可以关闭检测
└── 减少监控的组件类型(只监控Activity)
2. 阈值降级
└── 提高泄漏对象触发Dump的阈值(从5提高到10)
└── 增加两次Dump的最小间隔(从60s提高到120s)
3. 分析降级
└── 在主进程内存紧张时推迟分析
└── 使用独立进程分析,不影响主进程
# 7.5 异常设计优化
异常处理策略:
1. Hook失败处理
└── 反射Hook失败时优雅降级
└── 不影响应用正常功能
└── 输出日志供开发者排查
2. Dump失败处理
└── Debug.dumpHprofData()可能抛出IOException
└── 磁盘空间不足时的处理
└── 失败后重新调度下一次Dump
3. 分析失败处理
└── hprof文件损坏的处理
└── 分析超时的处理
└── 内存不足时的处理(中断分析)
关于finalize方法的补充说明:
LeakCanary在触发GC后还调用了System.runFinalization(),这是强制调用已失去引用对象的finalize方法。在可达性算法中,不可达对象也不是立即死亡的,需要经过两次标记过程。第一次标记后会筛选是否需要执行finalize()方法,如果对象没有覆盖finalize()或者finalize()已经被调用过,则视为"没有必要执行",直接回收。调用runFinalization()可以加速这个过程,让判断更加准确。
# 08.线上内存泄漏检测方案
# 8.1 LeakCanary为何不能用于线上
核心原因总结:
LeakCanary不适合线上的原因:
性能影响:
├── GC触发:主动调用GC会导致应用短暂卡顿
├── 堆锁定:dumpHprofData()会Stop-The-World,冻结数秒
├── 分析耗时:Shark分析过程消耗CPU和内存
└── IO开销:hprof文件10-30MB,磁盘读写量大
用户体验:
├── 应用冻结导致用户感知卡顿
├── 前台通知干扰用户
└── 分析过程影响应用流畅性
资源消耗:
├── hprof文件占用大量磁盘空间
├── 分析过程可能导致内存峰值
└── 频繁GC影响内存分配效率
# 8.2 KOOM线上检测方案
快手KOOM(Kwai OOM)的核心创新是利用Linux的fork+COW机制实现无冻结的HeapDump:
KOOM的fork+COW核心原理:
KOOM工作流程:
1. 监控阶段
└── 定期采样Java堆内存使用量
└── 当内存使用量连续N次超过阈值时触发检测
└── 比LeakCanary的对象级监控更轻量
2. Dump阶段(核心优化)
└── 调用fork()创建子进程
└── 子进程继承父进程的完整内存快照(COW)
└── 父进程立即恢复执行(不受影响)
└── 子进程中执行Debug.dumpHprofData()
└── 子进程完成后自动退出
3. 分析阶段
└── 在子进程或后台Service中分析hprof
└── 使用裁剪后的hprof(只保留必要信息)
└── 生成轻量级的分析报告
4. 上报阶段
└── 只上报分析结果(JSON格式)
└── 不上报hprof文件
└── 通过网络上报到后端
fork+COW机制的深入分析:
Linux fork() + COW (Copy-On-Write) 原理:
1. fork() 系统调用:
┌──────────────────────────────────────────────┐
│ 用户空间 │
│ pid_t pid = fork(); │
│ if (pid == 0) { │
│ // 子进程 │
│ } else { │
│ // 父进程,pid > 0 │
│ } │
└───────────────┬──────────────────────────────┘
│ 系统调用
▼
┌──────────────────────────────────────────────┐
│ 内核空间 │
│ │
│ do_fork() → copy_process() │
│ ├── 复制 task_struct(进程描述符) │
│ ├── 创建新的内核栈 │
│ ├── 复制页表(Page Table)← 关键! │
│ │ └── 父子进程指向相同的物理页 │
│ │ └── 页表项标记为Read-Only(触发COW) │
│ └── 设置进程关系 │
│ │
│ COW触发时机: │
│ 父/子进程尝试写入某个物理页 │
│ → MMU检测到写保护 │
│ → 触发页面错误(Page Fault) │
│ → 内核分配新的物理页 │
│ → 复制原始页内容到新页 │
│ → 更新页表,指向新页 │
│ → 恢复进程执行 │
│ │
└──────────────────────────────────────────────┘
COW的核心优势:
┌──────────────────────────────────────────────┐
│ fork前:父进程内存占用 300MB │
│ fork后:父进程 + 子进程 实际物理内存 ≈ 300MB │
│ (只有页表额外开销,~几MB) │
│ │
│ 子进程只做 hprof dump(只读操作): │
│ → 遍历堆对象 → 纯读取 → 不触发COW │
│ → 写入hprof文件 → 写子进程自己的内存 → 不触发COW│
│ → 物理内存几乎不增加! │
│ │
│ 父进程继续正常执行: │
│ → 分配新对象 → 写操作 → 触发COW │
│ → 每次COW只复制1页(4KB) │
│ → 非常分散,性能影响微小 │
└──────────────────────────────────────────────┘
KOOM的完整Dump流程代码分析:
// KOOM fork子进程进行HeapDump的核心代码(简化版)
public class ForkJvmHeapDumper {
public boolean dump(String path) {
// 1. 暂停所有Java线程(短暂)
// 配合fork一起使用,保证内存快照一致性
suspendAllThreads();
// 2. fork子进程
int pid = fork();
if (pid == 0) {
// ===== 子进程 =====
// 恢复子进程中的Java线程
resumeAllThreadsInChild();
try {
// 在子进程中执行HeapDump
// 此时子进程拥有父进程的完整内存快照(COW)
Debug.dumpHprofData(path);
// 分析hprof文件(可选,也可留给Service)
// analyzeHprof(path);
} catch (Exception e) {
KLog.e(TAG, "dump failed in child process", e);
} finally {
// 通过进程间通信通知父进程dump完成
notifyParentProcess();
// 子进程退出
System.exit(0);
}
} else if (pid > 0) {
// ===== 父进程 =====
// 立即恢复所有线程
resumeAllThreads();
// 父进程继续正常运行
// 通过wait或异步回调等待子进程完成
// int status = waitpid(pid, ...);
return true;
} else {
// fork失败
resumeAllThreads();
return false;
}
}
}
为什么要先suspend再fork?
suspend + fork 的组合时序:
原因:保证内存快照的原子一致性
不加suspend:
┌──────────────┐ ┌──────────────┐
│ 父进程 │ │ 子进程 │
│ │ │ │
│ [mutating] │ │ │
│ obj.field = x│──────│─ fork ───────│ 子进程看到的可能是
│ (修改中) │ │ 看到不一致状态 │ 半完成的修改!
└──────────────┘ └──────────────┘
加suspend:
┌──────────────┐ ┌──────────────┐
│ 父进程 │ │ 子进程 │
│ │ │ │
│ ---suspend---│ │ │ 所有线程暂停
│ ↓ │ │ │ 内存状态确定
│ fork() ───│──────│→ 子进程创建 │ 快照一致
│ ---resume---│ │ dump开始 │ 父进程立即恢复
│ [mutating] │ │ │
└──────────────┘ └──────────────┘
suspend时间通常 < 10ms(只复制页表,不复制内存)
vs LeakCanary的dumpHprofData 冻结 2-5秒
hprof文件裁剪优化:
KOOM不是直接使用完整的hprof文件,而是进行裁剪:
hprof裁剪策略(减少文件体积):
完整hprof: 30MB → 裁剪后hprof: 5-8MB
裁剪内容:
├── 只保留实例对象(INSTANCE_DUMP)
├── 只保留对象数组(OBJECT_ARRAY_DUMP)
├── 只保留类定义(CLASS_DUMP)
├── 只保留必需的字符串(STRING)
├── 去除基本类型数组(除非是大数组)
│ └── byte[] 通常占堆的50%+(图片、缓冲区),不需要泄漏分析
├── 去除线程栈信息(非必需)
└── 去除时间戳等元数据
裁剪方法:
1. 在fork子进程中执行dump
2. 使用HprofWriter重写hprof文件
3. 只写入分析需要的Record
4. 大幅减少IO和传输成本
KOOM的内存触发阈值策略:
KOOM的触发策略比LeakCanary更细致:
1. 内存监控数据源:
├── Runtime.getRuntime().totalMemory() - freeMemory() (Java堆使用量)
├── Debug.getNativeHeapAllocatedSize() (Native堆使用量)
└── /proc/self/status → VmRSS (物理内存占用)
2. 阈值设定:
├── 绝对阈值:Java堆 > maxHeap × 85%
│ └── 例如 maxHeap = 512MB → 触发阈值 = 435MB
├── 增长速率:5分钟内增长 > 100MB
│ └── 排除短暂的内存峰值
└── 连续N次超阈值才触发
└── 避免因短暂GC导致误判
3. 采样间隔:
├── 正常状态:每30秒采样一次
├── 内存增长中:每10秒采样一次
└── 内存高位:每5秒采样一次
# 8.3 线上内存泄漏监控体系
完整的线上内存监控体系:
采集层:
├── Java堆内存采样
├── Native内存采样
├── 大对象监控(>1MB的Bitmap等)
├── Activity/Fragment泄漏检测
└── 内存触顶告警
分析层:
├── KOOM离线分析
├── hprof裁剪与压缩
├── 引用链提取
└── 泄漏自动归类
上报层:
├── 采样率控制(按设备、版本、用户分层)
├── 流量控制(非WiFi环境不上报大文件)
├── 去重机制(相同泄漏只上报一次)
└── 数据脱敏
展示层:
├── 泄漏大盘(泄漏率趋势、Top N泄漏)
├── 版本对比(新版本是否引入新泄漏)
├── 详情页面(引用链、影响范围)
└── 告警通知(新泄漏自动告警)
# 8.4 各方案对比分析
内存泄漏检测方案对比:
| 特性 | LeakCanary | KOOM | Matrix |
|---------------|--------------|--------------|---------------|
| 适用环境 | 开发/测试 | 线上 | 线上 |
| 检测方式 | 对象级监控 | 内存阈值触发 | 对象级+阈值 |
| Dump方式 | 主进程直接Dump | fork子进程 | fork子进程 |
| 应用冻结 | 数秒冻结 | 无冻结 | 无冻结 |
| 分析引擎 | Shark | 自研 | 自研 |
| 性能影响 | 较大 | 小 | 小 |
| 接入成本 | 零配置 | 需要配置 | 需要配置 |
| 开源 | 是 | 是 | 是 |
# 09.常见面试题深度解析
# 9.1 经典面试问题
问题1:LeakCanary的工作原理是什么?
回答要点:
1. 自动初始化:通过ContentProvider在Application创建前完成初始化
2. 生命周期监听:注册全局监听器/Hook,感知五种场景的组件销毁时机
3. 泄漏检测:
使用WeakReference + ReferenceQueue机制
对象销毁后创建WeakReference关联ReferenceQueue
5秒后检查引用是否进入队列
未进入则触发GC → 再次检查 → 仍未进入则确认泄漏
4. HeapDump:
泄漏计数达到阈值后调用Debug.dumpHprofData()
生成hprof内存快照文件
5. 分析报告:
Shark引擎分析hprof文件
BFS搜索从泄漏对象到GC Root的最短引用链
输出引用链报告
问题2:WeakReference和ReferenceQueue的关系?
WeakReference指向的对象被GC回收时:
1. GC线程发现对象只有弱引用(无强引用/软引用)
2. 回收该对象
3. 将WeakReference放入关联的ReferenceQueue
4. 应用线程通过queue.poll()获取已入队的引用
LeakCanary利用此机制:
创建WeakReference(obj, queue) → 等待GC
→ poll queue → 弱引用在队列中 → 对象已回收(正常)
→ poll queue → 弱引用不在队列中 → 对象未回收(泄漏)
问题3:LeakCanary如何做到零配置?
通过ContentProvider自动初始化:
1. 在AAR的AndroidManifest中注册AppWatcherInstaller(ContentProvider)
2. Android系统在创建Application时自动创建此ContentProvider
3. ContentProvider.onCreate()在Application.onCreate()之前调用
4. 在onCreate()中完成LeakCanary的所有初始化工作
5. 开发者只需添加依赖,无需任何初始化代码
# 9.2 进阶面试问题
问题4:LeakCanary为什么不能用于线上?KOOM是如何解决的?
LeakCanary不能用于线上的原因:
1. dumpHprofData()会锁堆,应用冻结数秒
2. 频繁GC影响性能
3. hprof文件大,占用磁盘和IO
KOOM的解决方案:
1. 使用fork()创建子进程
2. 子进程通过COW获得父进程内存快照
3. 父进程立即恢复,不受影响
4. 子进程中执行Dump和分析
5. 只上报分析结果,不上报hprof文件
问题5:LeakCanary如何监控ViewModel的销毁?
由于Android Framework未提供ViewModel.onCleared()的全局监听:
1. 在Activity/Fragment的onCreate中
通过ViewModelProvider创建一个自定义ViewModelClearedWatcher
2. 当Activity/Fragment销毁时
ViewModelClearedWatcher.onCleared()被调用
3. 在onCleared()中
通过反射获取ViewModelStore内部的map
遍历所有ViewModel对象
将它们交给ObjectWatcher监控
问题6:Shark相比haha有什么优势?
Shark优势:
1. 按需加载:不一次性加载整个hprof到内存,使用索引+seek方式
2. 两次遍历:第一次建索引,第二次按需读取,减少IO操作
3. 使用Okio:高效的IO库,减少内存拷贝
4. Kotlin编写:更安全的空安全处理
5. 分析速度:比haha快约6倍
6. 内存消耗:远低于haha
# 9.3 学习建议
学习LeakCanary建议按以下顺序:
- 理解基础概念:Java引用类型、WeakReference、ReferenceQueue
- 掌握核心原理:WeakReference + ReferenceQueue的泄漏检测机制
- 学习初始化:ContentProvider自动初始化的设计思想
- 深入组件监听:各种Watcher如何Hook不同组件的生命周期
- 了解分析引擎:Shark如何解析hprof文件并查找引用链
- 扩展学习:KOOM等线上方案的fork+COW优化思路