线上 Java 崩溃里 OOM 的占比一直不低。我们去年下半年拉过一次数据,OOM 加上跟内存相关的 ANR 大概占到两成出头,但真正能从崩溃栈里看出泄漏点的不到一成——因为 OOM 的堆栈永远指向那次失败的 allocation,而不是那个忘了注销的监听器、那个没解绑的 View、那个静态 Map 里越堆越多的 Context。要定位这类问题只有两条路:在本地把它复现出来,或者在线上把堆的状态带回来。LeakCanary 是第一条路上事实上的标准工具,KOOM 是第二条路上工程化程度比较高的一个方案。这篇就把这两者摊开对比,重点讲清楚一个问题:为什么在“线上监控”这个场景里,我们把主力从 LeakCanary 换成了 KOOM,以及换完之后省下了什么、又付出了什么代价。
1. 先把“泄漏检测”和“线上监控”拆成两件事
很多人做技术选型时会把这两个词混着用,导致对比维度从一开始就错位。LeakCanary 和 KOOM 都能告诉你“这里有个泄漏”,但它们被设计出来要解决的问题并不在同一个层面上。搞清楚这个差别,后面的取舍就都是顺理成章的。
1.1 LeakCanary 的原始目标:让开发者在工位上把问题看明白
LeakCanary 的核心机制其实很朴素:用一组带 key 的弱引用去盯住那些理论上应该被回收的对象,配合ReferenceQueue感知它们什么时候被真正回收。如果一个 Activity 销毁后过了设定的观察窗口(默认 5 秒)还没被回收,就把它标记成“疑似 retained”,再叠加上一个阈值(默认累积到 5 个疑似对象)才真正触发一次堆转储。转储之后用 Shark 解析 hprof,找出从 GC Root 到这个对象的最短强引用链,最后以一条人能读懂的 Leak Trace 呈现出来。
这套设计的每一个环节都指向同一个目标:信息完整度优先。它要给你的是“谁持有谁、为什么没断开”的完整链路,所以你打开通知就能直接去改代码。为了这个目标,它愿意付出几秒钟的卡顿、愿意在设备上生成一个几百 MB 的 hprof 文件、愿意在设备上跑一遍相当吃 CPU 和内存的分析。在开发机上、在测试同学的手机上,这些代价完全值得。
1.2 线上监控要的是另外三样东西
线上环境里,“完整”反而是最不重要的。真正卡住你的是这三件事:
第一是低打扰。用户不会因为你正在做内存诊断就容忍一次两秒的白屏。哪怕只是一次滑动掉帧,在核心页面上的代价也可能被放大成一次流失。
第二是可聚合。线上一次灰度出去,同一处泄漏可能在几千台设备上同时发生。你需要的是“这是一个问题,影响了 1200 台设备,集中在某个版本”,而不是收到 1200 份互相独立、各写各的的堆分析报告。
第三是可归因。线上拿到的数据必须是能对上版本的:哪个 app 版本、哪个机型、哪个系统版本、哪个业务入口。脱离这些维度的泄漏报告,修起来依然要靠猜。
这三条要求,恰好是 LeakCanary 的默认形态最不擅长的地方。它的架构里,堆转储和分析全都在业务进程内完成,数据也不落库、不上报,天然就是“单机、即时、面向个人”的工具。
1.3 真正的分岔点在两个位置:dump 在哪执行、分析在哪执行
把这两个工具的所有差异归结起来,其实就是两个坐标:
| 坐标 | LeakCanary | KOOM |
|---|---|---|
| 堆转储(dump)执行位置 | 业务进程内 | fork 出的子进程 |
| 堆分析执行位置 | 业务进程内(Shark) | 服务端/离线,端上只做裁剪 |
只要这两件事都发生在业务进程里,无论怎么优化参数,你都无法绕开“暂停时间长、内存峰值高、数据带不走”这三个物理限制。反过来,只要把 dump 挪出主进程、把分析挪出设备,剩下的问题就都变成了工程问题而不是原理问题。KOOM 的价值,基本上就在这两次“挪动”上。
顺带说一句,LeakCanary 官方也意识到了线上场景的需求,后续版本提供了面向 release 包的能力,可以按需触发 dump 并接入自定义的分析服务。这个方向是对的,但它的底座仍然是进程内 dump,最耗时的那个环节并没有变。所以我的判断是:开发期用 LeakCanary,线上用 KOOM,这不是二选一,而是分工。
2. 把 dump 搬到子进程:KOOM 的 fork 机制到底省下了什么
要理解 KOOM 为什么快,得先知道一次堆转储的时间到底花在哪。很多人以为 dump 就是把内存“复制一份”,实际上完全不是——它是一次全堆遍历加序列化,是相当重的操作。
2.1 一次堆转储的时间账本
以 ART 为例,一次完整的 hprof dump 大致要做这些事:
- 挂起所有线程(SuspendAll)。这一步本身很快,但必须等到所有线程都到达安全点,如果有线程正在执行长循环或者持有锁,父进程就得等。
- 遍历堆。从各个 Space(RegionSpace、ImageSpace、ZygoteSpace、LargeObjectSpace 等)逐个扫描对象,读取对象头、类信息、字段值。一个中等规模 App 的存活对象数量在几十万到上百万量级。
- 序列化写文件。每个对象都要按 hprof 格式写出 tag、id、类名、字段,还要处理字符串去重、类结构记录、GC Root 记录。这一步是纯 IO 加 CPU。
- 恢复所有线程(ResumeAll)。
这里面的时间跟堆大小基本是正相关的。粗略的量级感受是:一个堆占用一两百 MB 的进程,中端机型上一次完整 dump 的挂起时间在两秒上下,堆更大的话三到五秒也不稀奇。而且这期间不只是卡顿,进程的 RSS 还会明显上涨——因为要构造输出缓冲、要写文件,物理内存占用会往上冲一截。
这就解释了为什么很多团队试过把 LeakCanary 直接放到线上灰度包里,跑了一两天就撤了:崩溃率没降,ANR 和卡顿投诉先上来了。
2.2 fork 加写时复制:把“写”这件事挪出主进程
KOOM 的核心手法是fork()。这个系统调用会创建一个子进程,子进程的地址空间在逻辑上跟父进程一模一样,但物理内存页是共享的——只有当某一方尝试写入某个页时,内核才会真正复制一份出来,这就是写时复制(Copy-on-Write)。
于是整个流程可以变成这样:
- 父进程先
SuspendAll,保证此刻堆的状态是一致的、不会有一半的对象刚分配一半没填完; - 父进程
fork()出子进程,然后把子进程的地址空间视图继承过去; - 父进程立刻
ResumeAll,业务线程继续跑,用户感知到的停顿只有挂起和 fork 这两步,量级在百毫秒以内甚至更低; - 子进程慢慢去做遍历、序列化、写文件这些脏活累活。它在写的过程中会不断触发缺页复制,但那部分是子进程自己的内存开销,跟业务进程的响应性没关系。
这个设计最妙的地方在于:它把“耗时”和“阻塞”解耦了。总耗时可能并没有变短,甚至因为 COW 的缺页开销还会略微变长,但业务线程被阻塞的时间从秒级降到了百毫秒级。对线上监控来说,这才是关键指标。
需要强调的是,挂起那一步不能省。如果在没挂起的情况下 fork,父进程的堆还在变,子进程拿到的就是一份撕裂的快照——可能出现指针指向一个还没构造完的对象,遍历的时候直接崩。所以“短挂起 + fork”是一个整体,不能只取一半。
2.3 在子进程里调用 ART 的那些坑
原理讲起来很顺,真做起来最麻烦的地方在于:fork 出来的子进程,地址空间虽然一样,但线程只有一个(调用 fork 的那个)。而 ART 的 dump 逻辑内部依赖大量的锁:mutator lock、heap lock、各种 monitor。如果 fork 的那一刻,父进程里某个线程正持有某把锁,而那个线程在子进程里并不存在,那么子进程去拿这把锁就会永远等下去,直接死锁。
这个问题没有特别优雅的解法,工程上的处理思路大致是:
- 在挂起所有线程之后再 fork,这样所有业务线程都停在安全点,锁的持有状态相对可控;
- 子进程里只允许跑一个线程,避免自己跟自己抢锁;
- 针对不同 Android 版本的 ART 实现做适配,因为 5.x、6.x、8.0、10 之后在堆结构和 hprof 格式上都改过,尤其是新版本 hprof 的头部和记录格式变化;
- 在 dump 之前先做一些“探针”性质的检查,比如堆是否处于可转储状态、可用磁盘空间是否够,避免做到一半失败还留下半个文件。
还有一个容易被忽略的限制是地址空间。32 位进程的虚拟地址空间只有几个 G,堆本身已经占了一大块,fork 再加写 hprof 的缓冲,很容易直接失败。所以这套方案基本只在 64 位进程上开启,32 位设备上一般直接降级或者不监控。这不是妥协,是物理限制。
2.4 暂停时长与失败率的实际观感
从我们自己的体感来说,从“进程内 dump”切到“子进程 dump”之后,最明显的变化是用户侧的可观测异常基本消失了。之前灰度包里偶尔能收到“应用无响应”的反馈,切过去之后这类反馈归零。挂起时间从原来的秒级降到了百毫秒级,这个量级在大多数页面上用户是感知不到的,但如果你正好在做列表惯性滑动,理论上还是可能有轻微掉帧,所以比较稳妥的做法仍然是把触发时机放在页面切换、退到后台、或者应用空闲的时候。
代价是失败率。子进程 dump 在各版本 ART 上的兼容性不如进程内 dump 那么稳,尤其是在一些深度定制的系统上,偶发会出现子进程异常退出、dump 文件不完整的情况。所以接入的时候一定要有兜底:dump 失败就放弃这一轮,不要重试,不要降级到进程内 dump。重试会让问题雪上加霜,而降级到进程内 dump 等于把好不容易省下来的卡顿又还回去了。等下一轮监控周期再说,本来线上监控就是个长期采样的事情,不差这一次。
3. 逐项对照:八个真正影响决策的维度
把机制讲清楚之后,剩下的就是把这些差异翻译成选型时能直接用的判断依据。我按重要性排了一下,下面这张表是我们内部评审时实际用过的一版。
| 维度 | LeakCanary(默认形态) | KOOM | 对线上监控的影响 |
|---|---|---|---|
| 运行环境 | 默认只在 debug 变体生效 | 面向 release 设计 | KOOM 可以直接跟着灰度走 |
| dump 位置 | 业务进程内 | fork 子进程 | 决定卡顿量级 |
| 挂起时长 | 秒级 | 百毫秒级 | 决定用户是否可感知 |
| 内存峰值 | 高,易被系统回收 | 峰值转移到子进程 | 低端机存活率差别明显 |
| 分析位置 | 设备端 Shark | 服务端/离线 | 决定能否跨设备聚合 |
| 单次数据量 | 完整 hprof,几百 MB | 裁剪后的引用链,几十 KB | 决定上报成本能否承受 |
| 覆盖范围 | 以 Java 堆对象为主 | Java 堆、Native 堆、线程、FD | 决定能发现多少类问题 |
| 采样能力 | 无内置采样概念 | 支持采样与阈值配置 | 决定线上打扰面 |
3.1 接入成本与构建变体
LeakCanary 的接入体验几乎是无敌的:加一行debugImplementation,什么都不用配,跑起来就能用。这个“零配置”是它最大的优势,也是它被牢牢锁在开发期的原因——它的默认假设就是“你只在本地用”。
KOOM 的接入要重一些。除了依赖本身的体积,你还需要考虑初始化时机、采样配置、上报通道、服务端解析链路,如果要用 Native 泄漏监控还得处理符号表。粗略估算,一个完整的接入从零到能看到第一份线上报告,工作量大概在两到三天的量级,前提是服务端的解析链路已经有人搭好了。
所以这里的判断标准很简单:如果你的诉求只是“开发阶段别写出泄漏”,LeakCanary 一行依赖就够了;如果你要的是“线上到底哪些页面在漏、漏得有多严重”,那这点接入成本是必须付的。
3.2 运行期开销:CPU、内存峰值与卡顿
LeakCanary 的开销集中在两个瞬间:dump 的那几秒,以及 Shark 分析的那十几秒到几十秒。分析阶段的内存峰值尤其要命,因为它需要在内存里构建对象图、算支配树、找最短路径,这些数据结构本身的体积跟堆里对象数量是同一量级的。在一些内存本身就吃紧的机器上,很容易出现“为了查泄漏把自己搞成 OOM”的尴尬局面。
KOOM 把这两段开销拆开了:dump 的开销转移到子进程,分析的开销转移到服务端。业务进程承担的主要是挂起那一小段,以及 fork 本身带来的页表复制开销。这个开销跟进程的虚拟内存大小有关,进程越大越明显,但总体仍然是可控的。
这里有个实践中的细节值得提一句:不要在应用启动阶段触发 dump。启动阶段本来就是内存和 CPU 的高峰期,这时候 fork 一个子进程,页表复制的代价会被放大,而且这个阶段的堆状态最不稳定。把触发窗口往后放,等首屏渲染完成、用户进入稳定态之后再考虑。
3.3 数据体积与隐私边界
这一条经常被低估,但在真实项目里往往是决定性的。
LeakCanary 生成的是完整 hprof,体积跟堆大小成正比,几百 MB 是常态。这种东西在本地看看没问题,往上一传就是灾难:用户的带宽、服务端的存储、解析的算力,三样都要钱。更要紧的是,完整 hprof 里包含着进程内几乎所有的字符串——用户输入的文本、接口返回的内容、图片的 URL,全都躺在里面。把它原样上传,隐私风险是实打实的。
KOOM 的处理方式是端上裁剪:在子进程里 dump 出 hprof 之后,先在本地解析一遍,只保留从 GC Root 到疑似泄漏对象的那条引用链上涉及的对象和类信息,其余全部丢弃。最终上报的数据量通常在几十 KB 的量级,只有全量 hprof 的千分之一甚至更少。
这个裁剪带来的一个副作用是信息损失——你拿不到完整的堆快照,也就没法做“全堆扫描”式的分析,比如查某类对象的实例总数。但对“定位泄漏点”这个目标来说,引用链本来就是最有信息量的部分,损失可以接受。
3.4 分析能力:设备端精读与服务端归并
Shark 是一个相当成熟的分析引擎,它能算支配树、能找最短强引用路径、能识别常见的已知泄漏模式,而且它的输出是给人看的——Leak Trace 会把整条链路用缩进的形式展开,哪一层是系统类、哪一层是业务类标得清清楚楚。在开发期,这个体验目前没有对手。
KOOM 在端上不做深度分析,只做裁剪和上报,真正的分析在服务端完成。这意味着单看一份报告,它的呈现可能不如 Leak Trace 那么友好。但服务端分析换来的是另一个维度的能力:归并。当同一处泄漏在 500 台设备上发生时,服务端可以按引用链的哈希把它们聚成一条记录,告诉你“这个问题影响了 500 台设备、集中在 3.2.1 版本、TOP3 机型是这几款”。这个视角是设备端分析给不了的。
3.5 覆盖范围:Java 堆之外还有多少坑
线上 OOM 里,Java 堆溢出只是一部分。实际遇到的还有:
- Native 内存泄漏:C/C++ 层 malloc 之后没 free,Java 堆看起来很正常,进程 RSS 一路涨。
- 线程泄漏:不断创建线程不回收,最后
pthread_create失败,抛出来的是一个看起来莫名其妙的 OOM。 - FD 泄漏:文件描述符耗尽,报错形式五花八门。
LeakCanary 的主战场在 Java 堆上的 Activity、Fragment、ViewModel、Service、View 这些对象。KOOM 则把这几类都覆盖了:Java 泄漏靠子进程 dump,Native 泄漏靠 hook 内存分配函数做采样记录,线程和 FD 靠对应的 hook 与计数监控。
从“监控”的角度看,覆盖面本身就是价值。你不可能只监控 Java 堆,然后指望 Native 的问题自己消失。
3.6 采样、灰度与上报成本
线上监控如果全量开启,成本会失控。所以采样是必需品:只在部分设备上开启、只在特定版本上开启、只在满足某些条件(比如前台、电量充足、非低端机)时才触发。
LeakCanary 的设计里没有“采样”这个概念,因为它压根没考虑多设备场景。KOOM 把采样做成了配置项,你可以控制开启比例、单设备每天的最大触发次数、两次触发之间的最小间隔。这几个参数看起来不起眼,但它们直接决定了你的服务端能不能扛住。
我的经验是:采样率不要设成固定值,要跟版本和问题收敛情况联动。新版本刚上线、或者刚修完一批泄漏、或者刚接入监控想摸底的时候,比例可以调高;等报告收敛、问题清单稳定了,就往下压,只保留一个低比例的常态监控。这套动态调整的机制,比一开始就纠结“到底该设 1% 还是 5%”有用得多。
4. 落地配置:从接入到第一份线上报告
理论讲完,说说实际怎么落地。这部分是我认为最容易出问题的地方,因为大部分坑都不在原理层面,而在配置和链路上。
4.1 初始化时机与几个绕不开的配置
初始化的位置建议放在Application.onCreate里,但要注意别跟其他重型初始化抢资源。KOOM 的初始化本身不重,主要是注册监听器、读配置,真正的开销在触发时刻。
配置项大致分几组,我这里给的是结构性示意,具体字段名以你接入的版本为准:
// 示意性配置,字段以实际接入版本为准 KGlobalConfig.init(application, KConfig.Builder() // 开关组:按需开启,不要一次全开 .enableJavaLeakMonitor(true) .enableNativeLeakMonitor(false) // 先关,跑稳了再开 .enableOomMonitor(true) // 采样组:控制打扰面 .setSampleRatio(0.02) .setMaxTriggerPerDay(3) .setMinTriggerInterval(6 * 60 * 60 * 1000L) // 条件组:限定触发场景 .setTriggerOnlyInBackground(true) .setMinApiLevel(26) .setOnly64Bit(true) .build())几个必须说清楚的点:
第一,Native 监控不要跟 Java 监控同时开。Native 监控需要在进程启动早期做 hook,侵入性更强,出问题的概率也更高。先让 Java 这条链路跑通、跑稳、报告能正常解析,再考虑往上加。一次性全开的团队,最后往往是全线回滚。
第二,务必限制系统版本下限。低版本 ART 的行为差异大,fork 之后子进程死锁的概率明显更高。把下限设在 8.0 甚至更高,用少量覆盖率换稳定性,这笔账是划算的。真需要覆盖低版本,那就单独开一个小比例的实验组去试。
第三,只开 64 位。前面说过地址空间的限制,32 位进程上这套方案基本没有意义,强行开启只会增加失败日志。
4.2 采样策略:别用一个固定比例打天下
采样这块我踩过坑。一开始设了个看起来很保守的比例,结果上线第一周报告量还是把解析队列打满了,因为泄漏的触发是聚集性的——同一处泄漏往往在某个页面的某个操作路径上集中出现,一旦有用户走到那条路径,就会连续触发。固定的比例在这种突发流量面前完全不够用。
后来调成了三层控制:
- 设备维度限流:单台设备每天最多触发 N 次,两次之间至少间隔几小时。这一层挡住的是“同一台设备反复踩同一个坑”。
- 版本维度控制:只在灰度版本上开启较高比例,全量版本上压到很低。这一层挡住的是“问题还没确认就全量铺开”。
- 全局配额:服务端侧做一个每日总量上限,超了就自动降级采样率。这一层是最后的安全阀。
有了这三层,即使某一处泄漏突然爆发,你的解析链路也不会被打挂,因为端上已经在源头把量掐住了。
4.3 混淆与符号还原:发版归档是硬性纪律
这一条听起来很无聊,但它是线上监控最容易翻车的地方。
线上包是混淆过的,hprof 里记录的是a.b.c这样的类名。如果发版的时候没有把对应的 mapping 文件归档,你拿到一份线上报告,里面全是一堆单字母,等于白拿。而且这个错误是不可逆的——版本发出去之后,mapping 就再也找不回来了。
我们现在的做法是把 mapping 归档写进发布流水线:打包产物一生成,mapping 自动上传到内部的文件服务,按版本号 + 渠道 + 构建号命名,跟构建记录绑定。解析服务在处理报告之前,先去拉对应版本的 mapping,拉不到就直接标记为“无法解析”而不是强行解析出一堆乱码。
注意:如果你们用的是分渠道、分 ABI 打包,每个产物都有自己的 mapping,别只归档主包的那一份。
除了类名,还有一层是 Native 符号。如果你开了 Native 泄漏监控,堆栈里会有 so 的地址,需要对应的符号表(带调试信息的那种)才能还原成函数名。这个符号表的体积比 mapping 大得多,归档策略要单独设计,通常只保留带符号的关键 so,其余按需。
4.4 一次性把服务端链路打通,别边跑边补
这是我在第一个项目上犯的错。当时客户端先上线了,服务端解析还没做完,结果攒了几万份报告堆在那里,等解析服务上线的时候发现数据格式已经迭代了两版,对不上,只能全部作废重来。
正确的顺序应该是反过来的:先把“报告接收 - 解析 - 归并 - 展示”的链路跑通,用一个手工构造的样本数据验证全流程,然后再让客户端上线。样本数据的构造也不难,本地跑一次 dump,把裁剪后的产物当测试用例喂进去,看看能不能正确还原出调用链。
5. 数据回流之后:怎么把几千条报告收敛成几个待修问题
监控接上了、数据回来了,真正的麻烦才刚开始。不做归并的话,你面对的是几千条看起来差不多的报告,没人愿意读,读了也修不完。
5.1 归并的键怎么选
归并的核心是选一个稳定的“指纹”。我试过几种方案:
| 归并键 | 效果 | 问题 |
|---|---|---|
| 泄漏对象的类名 | 太粗 | 一个Activity泄漏会把几十个不同原因混成一条 |
| 完整引用链哈希 | 太细 | 链路上任何一层变了就是新问题,同一原因可能裂成几十条 |
| 引用链中“第一个业务类 + 持有者字段名” | 比较合适 | 需要能识别哪些类是业务类 |
| 引用链中最后三层业务类 + 泄漏对象类型 | 比较合适 | 对混淆后的类名依赖 mapping |
我们现在用的是第三种为主、第四种为辅:先按“持有泄漏对象的那个业务类的类名 + 字段名”做主键,因为绝大多数泄漏的本质就是“某个对象的某个字段忘了清理”;主键相同但链路不同的,再做一次二级聚类,作为同一个问题的不同变体展示。
这个方案的好处是它能直接对应到代码。你看到主键是某个 Manager 类的某个 List 字段,基本就知道该去哪一行加remove了。
5.2 看报告时先看这三个数
一份归并后的报告,我一般先看三个数字:
- 影响设备数:这个决定了优先级。影响 5000 台的问题和影响 5 台的问题,处理顺序完全不同。
- 单设备累计增长:同一台设备上这个泄漏重复触发的次数。次数高说明它是高频路径,用户的真实体感更差。
- 机型与系统版本分布:如果集中在某几个机型上,那更可能是 ROM 相关的兼容问题,而不是纯业务代码问题,排查方向完全不同。
这三个数之外的东西,比如具体引用链,是用来验证判断的,不是用来做第一轮筛选的。很多团队一上来就盯着引用链看,结果花了两天时间修了一个只影响三台设备的问题。
5.3 从报告到修复的闭环
修完之后怎么验证?这里有个坑:你没法保证修完就不漏了,只能保证这一条链路不漏了。所以修复之后要做两件事:
一是在本地用 LeakCanary 复现一遍原来的路径,确认那条 Leak Trace 不再出现。这是最直接的验证,也是 LeakCanary 在开发期不可替代的地方。
二是在线上等版本发布之后,看同一个归并键下的报告数是否归零。如果还有残留,那说明你的归并键把两个不同原因混在一起了,得回头拆细。
这个“线上发现 - 本地复现 - 修复 - 线上验证”的循环跑顺了,内存泄漏这件事才真正从“靠运气”变成了“可管理”。
6. 什么时候我仍然会把 LeakCanary 摆在第一位
说了这么多 KOOM 的好话,得把话讲完整。LeakCanary 在我们的工程体系里从来没被移除过,它只是被限制在了开发期。
6.1 开发期定位这件事,LeakCanary 目前还是最好的
Shark 的分析质量和 Leak Trace 的呈现方式,在“我要马上知道这行为什么漏了”这个场景下,依然是效率最高的。它的零配置接入、失败即通知、点击跳转到源码的能力,构成了一个非常短的反馈闭环。你改一行代码,跑一次,几秒钟后就知道对不对。
KOOM 做不到这一点,因为它的价值建立在“服务端”和“采样”之上,这两个前提在本地开发时都是负资产。你总不能在本地开个服务端来解析自己的堆。
6.2 两者并存的构建配置
实际做法很简单,用不同的构建变体隔开:
dependencies { // 只在开发和内部测试包里带上 debugImplementation("com.example.leakcanary:leakcanary-android:2.x") // KOOM 跟着 release 走 releaseImplementation("com.example.koom:koom-java-leak:2.x") }有一类场景需要两个都开:灰度包。灰度包本身是 release 变体,常规做法是只带 KOOM。但如果你想在灰度阶段同时拿到一份本地可读的详细报告,可以给灰度包单独做一个变体,把两个都带上,并且把 LeakCanary 的触发条件收紧(比如只在充电且息屏时触发),避免影响灰度用户的体验。
6.3 一份可以直接抄的判断清单
最后给一个我们内部用的判断表,如果你正在纠结,对着填一遍基本就有答案了。
| 你的情况 | 建议 |
|---|---|
| 项目刚起步,还没上过线上监控 | 先用 LeakCanary 把开发期的习惯养起来 |
| 线上 OOM 率高,但不知道漏在哪 | 上 KOOM,先把 Java 泄漏这条链路跑通 |
| 只有一两个页面偶发卡顿,怀疑内存问题 | 本地用 LeakCanary 复现,别急着上线上方案 |
| 已经有完善的崩溃平台和数据链路 | KOOM 的接入成本会低很多,可以上 |
| 团队没有服务端资源做解析 | 先别上 KOOM,用 LeakCanary 的 release 形态 + 自建简单解析 |
| 主要问题是 Native 内存涨 | KOOM 的 Native 监控是目前少数可选项,但要做好长期调优的准备 |
| 应用体积敏感,不能加太多依赖 | LeakCanary 只在 debug 变体里,对线上包体积没影响;KOOM 要评估 |
我个人在实际操作中的体会是,这类工具的价值从来不在“能不能发现问题”,而在“发现问题到修掉问题之间那段路有多短”。LeakCanary 把这段路缩到了几秒钟,代价是只能在开发机上跑;KOOM 把这段路铺到了线上,代价是需要一整套服务端链路和长期的采样调优。两者都不是银弹,但放在各自该在的位置上,配合起来用,才是把内存问题真正管住的样子。
如果非要给一个起步建议:先把 LeakCanary 用扎实,让团队里每个人都对“什么叫引用链”有直觉;等到线上 OOM 数据开始影响核心指标的时候,再考虑接 KOOM,并且第一版一定只开 Java 泄漏、只开 64 位、只开高版本、采样压到最低。跑两周,看看报告质量和服务端承压情况,再决定要不要扩。急着一次性全开的,大概率会在第二周回滚。