恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Android内存泄漏排查利器:LeakCanary原理与实战
首页
资讯中心
/
Android内存泄漏排查利器:LeakCanary原理与实战
Android内存泄漏排查利器:LeakCanary原理与实战
发布时间:2026/10/20 20:32:10
简介LeakCanary 是 Android 内存泄漏检测的常用工具这份资源面向需要做性能优化与内存排查的 Android 开发者提供一套可直接运行的 LeakCanary 使用示例 Demo。资源以 Android 工程形式组织共 54 个文件压缩包约 596KB包含 java 源码、gradle 构建脚本、xml 配置文件、png 图片等涵盖 App 模块、依赖配置与资源文件结构清晰便于导入 Android Studio 后直接体验 LeakCanary 的接入和泄漏提示流程。目前已有 1912 人学习下载。通过该示例读者可以快速了解 LeakCanary 的初始化方式、如何监控 Activity 泄漏、如何在泄漏发生时查看堆栈与引用链并在此基础上结合 Android Profiler 等工具建立内存优化的排查思路。适合刚接触内存分析、希望以最小 Demo 验证 LeakCanary 实际效果的中级开发者参考。1. 内存泄漏是个“黑匣子”LeakCanary 怎么把它变成一条可复现的引用链在 Android 开发里内存泄漏是出了名的“黑匣子”线上 OOM 来了日志只有几条 GC 告警看不出是哪次操作漏的想本地复现又往往来回点十几遍也不出现。LeakCanary 就是用来对付这个的——debug 包一启动它就自动盯上 Activity、Fragment 和 ViewModel发现该回收的对象迟迟没被回收先触发 GC 做二次确认再抓一份内存堆快照把引用链翻译成可读路径。这篇笔记从它的 2.x 机制讲起把集成方式、关键参数、误报排查和 hprof 二次分析一次说清。适合刚接手老项目、正在排查内存压力的开发者也适合想把泄漏检测变成日常习惯的人。2. LeakCanary 的检测原理弱引用、二次 GC 与引用链快照2.1 弱引用 ReferenceQueue泄漏检测的最小闭环一个对象有没有被 GC 回收你没法直接问 JVM但可以借助WeakReference和ReferenceQueue做一次“挂号”。弱引用本身不阻止对象回收当 GC 发现对象只被弱引用持有时会把这个弱引用对象塞进关联的ReferenceQueue。所以程序稍后轮询队列就能反推“目标对象已经被回收”。LeakCanary 的ObjectWatcher核心逻辑就是这一套。它在 Activity 的 onDestroy、Fragment 的 onDestroyView 等回调点调用 watch()把一个弱引用挂到监控列表里。如果等待一段时间后对象还活着再主动触发一次 GC对象仍然没有被回收就会进入待确认泄漏列表。// 简化版 ObjectWatcher 思路弱引用 引用队列 class WatchSample { private val queue ReferenceQueueAny() fun track(target: Any) { // 目标被回收时这个 WeakReference 会被 JVM 放入 queue val ref WeakReference(target, queue) // 实际代码里会把 ref 存到自己的监控集合中 println(tracked: $ref) } fun drainGCQueue() { while (true) { val ref queue.poll() ?: break // 能 poll 到说明目标对象已经被回收 println(reclaimed: $ref) } } }这段代码里WeakReference(target, queue)把队列和弱引用绑定poll()是非阻塞的队列为空时返回 null所以用while (true)配合break把已回收引用排空。真实的 LeakCanary 不会在这里 println而是根据“队列里有没有出现这条引用”来判断对象是否已经被回收。这里有个时间窗口问题对象被回收本身不一定是立刻发生GC 的调度、后台线程的存活、系统组件对页面的临时引用都会让回收晚几步。所以 LeakCanary 并不是销毁事件一发生就立刻判断而是会等一个观察窗口常见的默认值是 5 秒左右对应配置里的watchDurationMillis。窗口结束再触发 GC再检查对象是否仍然存活这样能在很大程度上避开生命周期过渡期的误判。2.2 从 retained 到 hprof泄漏判定与最短引用链单靠“弱引用还活着”只能说明对象可能泄漏还不足以证明泄漏。LeakCanary 不会一发现可疑对象就去抓堆因为堆转储是非常重的操作它要暂停 Java 线程做一致快照堆越大越慢做多了反而影响调试体验。所以它会先攒一攒当存活的可疑对象数量到达一定阈值时再触发一次自动堆转储。这个阈值就是maxRetainedObjects默认一般是 5。也就是说LeakCanary 不是“发现一个漏一个就抓堆”而是攒够一批再统一定位。这样既降低了抓堆频率又能把一批相关问题一次性暴露出来。堆转储完成后LeakCanary 会拿到一份 hprof 文件接下来的事交给它是内部的 Shark 堆分析引擎。Shark 会从 GC Root 出发遍历所有可达对象找出“当前还引用着这个可疑对象”的引用路径。GC Root 通常包括静态字段、JNI 全局引用、活跃线程栈等。如果一条强引用链从 GC Root 一直通到泄漏对象那这条链就是 LeakCanary 展示给开发者看的 LeakTrace。一次典型的 LeakTrace 看起来像这样┬─── │ GC Root: static field com.example.GlobalCache │ ├─ com.example.GlobalCache (class) │ ╰ static activity │ ╰ com.example.MainActivity (instance)这不是代码而是报告的文本片段。最上层是 GC Root说明泄漏的源头是一个静态字段中间是持有者类GlobalCache用静态字段activity握着一个 MainActivity最下层是泄漏对象本身。拿到这条路径修复点通常就清楚了要么静态字段不该存 Activity要么存之前应该随生命周期清除。这个阶段常见的认知误区是“看到 LeakTrace 就把最下面的对象删掉”。实际要修的是路径中间那几环也就是真正长时间持有引用的对象。LeakTrace 的价值在于把“谁引用谁”的顺序画出来顺着中间持有者去改往往一次就能解决一个类别的泄漏。2.3 2.x 自动装配与 1.x 手动埋点版本选型怎么定LeakCanary 1.x 时代的接入方式需要手动LeakCanary.install(this)并且要自己拿RefWatcher去 watch 目标对象。这带来两个问题一是 Fragment、ViewModel 这类非 Activity 对象的监控要自己埋点漏一处就漏一类问题二是不同项目接入姿势差别大社区里充满了各种自定义写法。2.x 之后做了一次比较大的调整依赖里加了debugImplementation库内的 ContentProvider 会在 Application 初始化之前被自动拉起Application 里一行代码都不用写Activity、Fragment、View 和 ViewModel 的监控默认就开始工作了。这个自动装配逻辑靠 Manifest 合并把 ContentProvider 注入进去所以 debug 包一启动LeakCanary 就已经在后台等待。// 1.x 时代的典型接法现在基本见不到了 val watcher LeakCanary.install(application) watcher.watch(targetObject)这段代码展示了手动安装的写法重点是为了对比2.x 已经不需要开发者手动调用 install也不需要保存全局RefWatcher实例。如果还在维护 1.x 接法的老工程升级到 2.x 的主要工作是去掉 install 调用然后重新检查自定义 watch 逻辑是否和自动监控重复。选型建议很直接新项目直接用 2.x老项目如果 1.x 用得很老升级时多跑一轮回归重点看原来手动 watch 的对象是不是被新默认配置覆盖了。仓库里如果存在多个进程升级后还要处理子进程问题这个坑在第 4 章会专门展开。3. 集成与配置从 build.gradle 到第一条泄漏报告3.1 依赖引入与安装验证debug 专属别带进 release集成 LeakCanary 的第一步是加依赖。常见做法是放在 debug 源码集下这样正式包不会掺进任何 LeakCanary 逻辑也不用额外维护 no-op 变体。dependencies { debugImplementation(com.squareup.leakcanary:leakcanary-android:2.x) }这里的2.x需要替换成你当前依赖版本对应的实际版本号。debugImplementation保证只有 debug 构建才会把这个库编进去release 包不会出现它的类更不会因调试功能增加包体积或带来性能损耗。如果你用的是 1.x 或自定义编译方式才需要考虑 no-op 变体2.x 的常规接入不需要。依赖加完以后先不写任何代码直接跑一个 debug 包然后查看日志adb logcat -s LeakCanary如果启动过程中能看到LeakCanary is running之类的日志说明自动初始化已经生效。LeakCanary 还会在桌面生成一个名为 Leaks 的应用图标点击它可以查看历史泄漏记录。注意这个图标只在 debug 包出现release 包不会有。提示如果是测试机尽量用干净的调试环境。同一台机器反复装不同测试包历史泄漏记录会混在一起定位问题时要额外花时间去区分是哪一次构建产生的。3.2 AppWatcher 监控范围Activity、Fragment 与 ViewModel 的自动挂接2.x 的泄漏监控分成两层AppWatcher负责盯对象LeakCanary负责在确认泄漏后分析堆。前者主要用于配置“看哪些对象”后者用于配置“抓到堆之后怎么处理”。如果你想调整监控范围一般会在自定义 Application 里改AppWatcher.configclass DemoApp : Application() { override fun onCreate() { super.onCreate() val config AppWatcher.config.copy( watchActivities true, watchFragments true, watchFragmentViews true, watchViewModels true, watchDurationMillis 5000L, maxRetainedObjects 5 ) AppWatcher.config config } }这里的watchActivities、watchFragments和watchViewModels分别控制是否监控 Activity、Fragment 和 ViewModel默认都开着。watchDurationMillis是观察窗口前面提到过默认 5 秒maxRetainedObjects是触发堆转储的阈值。参数调整时最关键的一点是watchDurationMillis不要设得太短。设成 1 秒会让回收窗口内出现大量假阳性设成 10 秒以上又会拖慢问题暴露速度一般 5~10 秒之间比较合适。这段配置只影响 AppWatcher 的监控范围真正抓堆、分析 hprof 的行为由LeakCanary.config控制。日常调试时保持默认配置就够了除非你遇到了太多误报或低端机卡顿才会考虑动后面的参数。3.3 ObjectWatcher 手动监控业务对象单例、回调和 ViewModel 之外的对象自动监控覆盖了 Android 组件但项目里真正容易泄漏的常常是普通业务对象比如一个请求回调、一个自定义管理器、一个被单例持有的监听器。这些对象不会自动被 AppWatcher 监控需要手动交给 ObjectWatcher 处理。class NeedWatch { lateinit var request: Request fun sendRequest() { request Request() request.callback object : RequestCallback { override fun onComplete(data: String) { // 这里如果引用了 Activity泄漏大概率发生在这里 } } // 默认情况下 LeakCanary 不会盯 Request需要手动 watch AppWatcher.objectWatcher.watch(request, Request-${System.currentTimeMillis()}) } }这段代码展示了一个典型的业务对象监控姿势watch()接收两个参数第一个是要监控的对象第二个是便于识别的名字排查时能在 LeakTrace 里直接看到这个标签比如Request-1700000000。名字最好带时间戳或请求 ID这样多条记录能区分是哪一次调用产生的。需要强调的是watch 的含义是“我预期这个对象在接下来会被回收”。如果一个对象本来就是长期存在的比如进程级单例、配置管理器就不应该 watch否则 LeakCanary 会把它识别为泄漏并持续报告。我一般只对临时的、生命周期只到某次回调结束的对象做手动 watch比如一次性请求、临时流式解析器、弹窗控制器。3.4 跑通第一个泄漏用例静态字段造成的 Activity 泄漏为了让整个流程看得见摸得着可以刻意造一个泄漏用例。最常见的泄漏之一就是静态字段持有 Activity。private object GlobalCache { var activity: MainActivity? null } class MainActivity : AppCompatActivity() { override fun onDestroy() { super.onDestroy() // 演示泄漏把即将销毁的 Activity 塞进静态容器 GlobalCache.activity this } }这段代码利用了静态字段的生命周期等同于进程的特点。MainActivity 执行 onDestroy 后本该被回收但GlobalCache.activity还持有它的强引用GC 回收不掉对象就滞留在堆里。这也是很多老项目里“退出页面后内存不减反增”的典型原因。跑起来的流程是打开 MainActivity再退出等 LeakCanary 的观察窗口结束后通知栏出现1 leak found。点开通知会看到类似第 2 章展示的 LeakTraceGC Root 指向GlobalCache然后到activity字段最后到 MainActivity。看到这条链修复方式无非两种用弱引用替换强引用或者在 onDestroy 里主动置空。实际项目里静态引用往往藏得更深比如单例持有回调、回调又匿名引用 Activity但只要 LeakCanary 能把链画出来修复点就不会太难找。这个用例验证完之后记得把GlobalCache.activity this删掉。它只适合做演示不适合留在生产代码里。我一般会把这个流程写进团队新人文档让新成员先亲自动手触发一次泄漏再去看自动监控给出的引用链理解“路径上每一环是什么意思”。4. LeakCanary 避坑指南误报、混淆与多进程的排查记录4.1 误报对象只是晚回收不是泄漏现象退出页面后立刻收到 LeakCanary 通知显示某个 Activity 泄漏但用 Android Studio 的堆分析工具手动 GC 几次后对象其实被回收了。原因LeakCanary 的判定依赖 GC 时机。它默认等 5 秒后主动触发一次 GC如果这时系统刚好还在处理动画结束、Fragment 退出事务或后台线程收尾对象还没有被回收就会被当成可疑泄漏。低端机上 GC 延迟更大这种误报更常见。解决先不要急着改业务代码优先调整观察窗口。把watchDurationMillis从 5 秒拉到 8 秒或 10 秒给系统回收争取更多时间再把maxRetainedObjects从默认 5 调大到 8避免零星一两个可疑对象立刻触发堆转储。如果调完仍然误报再考虑用自定义泄漏监听器把报告降级成日志LeakCanary.config LeakCanary.config.copy( onHeapAnalyzedListener object : OnHeapAnalyzedListener { override fun onHeapAnalyzed(heapAnalysis: HeapAnalysis) { if (heapAnalysis.leaks.isNotEmpty()) { Log.w(LeakCheck, heapAnalysis.toString()) } } } )这段配置把“弹通知”替换成“打日志”适合在批量改造老代码时使用。泄漏信息不再打断开发但仍然会留在 logcat 里。等业务侧清理得差不多了再把默认的监听器恢复回来。做法上要注意onHeapAnalyzedListener是 LeakCanary 提供的接口具体字段名以你接入的版本文档为准不同小版本之间略有差异。4.2 混淆后引用链成乱码怎么保住类名映射现象打开minifyEnabled true的 debug 包LeakCanary 报告的 LeakTrace 里全是a.b.c - d.e.f这样的短类名完全看不出业务含义。原因R8/ProGuard 在构建时对类名和方法名做了混淆堆 dump 里记录的是混淆后的类名LeakCanary 读取出来自然就是乱码。问题常出现在那些为了验证泄漏而临时开启混淆的测试包上。解决最稳妥的做法是 debug 构建不开混淆。如果你一定要在混淆环境下测则在混淆规则中保留业务包名和 LeakCanary 相关类名-keep class com.example.** { *; } -keep class leakcanary.** { *; }这段规则的意思是com.example包下所有类及其成员不被混淆leakcanary 包内部类也保留原名。这样做会让该包下代码体积变大所以只适合 debug 验证不建议直接搬进 release 规则。如果混淆包已经产生了不可读的 LeakTrace还可以在构建产物里找到 mapping.txt按原类和混淆类对照手工还原。依赖里只有一份 mapping 文件时必须保留否则还原无从谈起。4.3 多进程环境子进程也初始化要么重复抓要么漏抓现象App 有:push、:channel之类的多进程结构接入 LeakCanary 后发现每个进程似乎都初始化了一遍有的进程上报了重复的泄漏有的进程干脆不弹通知。原因LeakCanary 2.x 靠 ContentProvider 自动初始化而 ContentProvider 默认会在每个进程启动时都被调用一次。子进程里如果也跑完整的泄漏监控既浪费资源又因为进程间内存独立泄漏分析可能被重复执行。解决在 Application.onCreate 里判断当前进程名非主进程直接关闭监控override fun onCreate() { super.onCreate() val am getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val pid Process.myPid() val processName am.runningAppProcesses ?.firstOrNull { it.pid pid }?.processName if (processName ! packageName) { AppWatcher.config AppWatcher.config.copy(enabled false) } }这段代码通过ActivityManager拿到当前进程名和包名比较不一致就关掉 AppWatcher。注意runningAppProcesses在主进程读取没有问题但在极端多进程场景下有的机型可能返回 null所以稳妥起见可以留一个兜底如果取不到进程名默认当作主进程处理。另一个轻量做法是读/proc/self/cmdline得到的进程名更直接两种方式选一种即可。4.4 dump 阶段卡顿或二次 OOM参数收紧策略现象在低端机或大堆应用上LeakCanary 一进入堆转储阶段应用明显卡顿甚至直接 ANR有的项目还遇到过堆转储过程中自身内存压力过大导致二次 OOM。原因Heap dump 要暂停 Java 线程来保证堆一致暂停时间越长卡顿越明显。大堆应用的一次 dump 可能持续数秒hprof 解析也需要消耗大量 CPU 和内存在低端机上很容易触发系统资源紧张。解决第一把maxRetainedObjects调大减少触发 dump 的频率第二给 LeakCanary 的分析过程设置内存上限让它不要在大堆应用里做全量解析。LeakCanary.config LeakCanary.config.copy( dumpHeapWhenLeaking true, analysisMaxHeapSize 384L * 1024 * 1024, analysisMaxRetainedHeapSize 256L * 1024 * 1024 )dumpHeapWhenLeaking表示确认泄漏后自动抓堆日常调试建议保持 trueanalysisMaxHeapSize限制分析引擎能处理的堆大小上限超过上限会放弃分析而不是硬撑analysisMaxRetainedHeapSize限制 retained 堆的大小避免泄漏对象太多时分析过慢。这两项具体字段在不同版本里略有差异动手前看一眼当前版本依赖里的源码或文档以实际签名为准。低端机场景下我还会把堆转储放到页面空闲时触发避免用户正在滑动或输入时卡顿。5. 把 LeakCanary 用出排查力hprof 二次分析与 CI 接入5.1 hprof 导出后用外部工具复查LeakCanary 给的 LeakTrace 已经能定位问题但有些场景还需要还原现场比如泄漏对象数量极大、引用链很长或者你想确认泄漏对象占了多少内存。这时可以导 hprof 文件出来做二次分析。LeakCanary 抓到的 hprof 文件保存在应用私有目录debug 包可以直接用 run-as 导出adb exec-out run-as com.example.app cat files/leakcanary/xxx.hprof leak.hprof这条命令把应用私有目录里的 hprof 文件拉取到本地。xxx.hprof要替换成实际文件名如果命令行找不到可以先在 Leaks 图标对应的界面里确认文件名或通过adb shell run-as com.example.app ls files/leakcanary/列出目录内容。拿到 hprof 后除了看 LeakCanary 的结论我一般会再看 Dominator Tree确认一个泄漏对象实际霸占了多少内存以及多个泄漏对象之间是否存在同一个根持有者。这样修复时能按“内存影响”排序而不是按出现顺序挨个改。5.2 自定义 LeakListener把泄漏结果变成 CI 的失败信号LeakCanary 默认只在手机端提示没有人盯着手机时泄漏回归很容易被忽略。把泄漏结果输出成可抓取的关键字能让 CI 在跑完 UI 测试后自动判断“这次改动有没有引入新的内存泄漏”。LeakCanary.config LeakCanary.config.copy( onHeapAnalyzedListener object : OnHeapAnalyzedListener { override fun onHeapAnalyzed(heapAnalysis: HeapAnalysis) { val leakCount heapAnalysis.leaks.size if (leakCount 0) { Log.e(LEAK_CI, leak_count$leakCount) } } } )这段代码在每次堆分析结束时统计泄漏数量一旦大于 0 就打一条带固定标识的日志。CI 脚本在测试结束后执行一次 logcat 抓取即可adb logcat -d | grep LEAK_CI如果有输出说明本轮测试存在泄漏构建标记为失败没有输出则视为通过。注意这种方式适合 debug 包和自动化测试机release 包不引入 LeakCanary 相关逻辑所以不会影响线上行为。从那以后我每次接手一个新项目第一周都会先花半天把 LeakCanary 在 debug 包跑通首屏、列表、详情页来回进出三五遍先把 Activity 回收这条主链路看稳再上线改内存问题。这个习惯看起来很笨但遇到线上 OOM 时手里至少有一张能复现的引用链图排查效率完全不一样。希望帮到你。本文还有配套的精品资源点击获取