先聊个真事。早几年我带的一个服务,平时各项指标都正常,结果每到业务高峰,CPU 先飙到 90%,接着 Full GC 频繁到每秒一次,最后节点批量宕机。当时第一反应是流量太大,扩容、限流全上了一圈,问题依旧。后来把堆转储拉下来一看,才发现某个静态 Map 里塞了上百万个已经结束会话的临时对象,GC 根本回收不掉。这种“明明内存没泄漏,却一直在涨”的情况,就是典型的内存泄漏排查场景。很多同学对内存泄漏的理解停留在“对象回收不掉”这句话上,但真到线上定位时,连从哪下手都不知道。
这篇文章就围绕内存泄漏这件事展开,先讲清楚泄漏的本质和几种最常见的产生模式,再重点对比主流的检测工具,包括 JVM 系的 MAT、JProfiler、async-profiler,以及 Android 系的 LeakCanary、Android Profiler 等,最后给出一套从监控、堆转储到引用链分析的完整排查链路。无论你是做后端服务、中间件,还是客户端开发,只要跟 JVM/ART 打交道,这篇文章都能帮你建立一套自己的泄漏排查方法论。
1. 内存泄漏到底是什么:从对象生命周期说起
1.1 内存泄漏的本质:对象活着,代码却永远用不到它了
先做一个定义上的澄清。很多人把内存泄漏和内存溢出搞混,这两个问题虽然经常一起出现,但本质完全不同。
内存溢出的意思是“内存确实不够用了”,你申请一块内存,但堆里已经塞满,GC 又回收不出空间,于是直接抛 OutOfMemoryError。内存泄漏的意思则是“有一部分内存被某个对象占着,但这个对象已经没有任何业务价值,而且 GC 还认为它‘活着’”,导致这块内存永远无法被回收。多个泄漏累积,最终把堆挤爆,就会触发内存溢出。
所以内存泄漏的完整定义应该是:程序中存在一些对象,它们已经不会再被任何业务代码访问和使用,但 GC Root 到它们的引用链依然存在,导致 GC 在可达性分析时判定它们“仍在使用”,从而不回收。用个生活化的比喻:你租了一个仓库,里面堆满了早就卖不出去的旧货,但仓库的登记系统一直显示“这两排货架还在被使用”,于是你永远不能退租、不能把空间腾出来。货是死货,空间却一直占着。
这里的关键词是“GC Root”。JVM 的 GC 会从一组根对象出发,沿着引用链去遍历,凡是被遍历到的对象都标记为存活。GC Root 包括线程栈上的局部变量、静态变量、JNI 引用、活跃的 Thread 对象等。只要某个对象还能被这些根沿着引用链触达,它就不会被回收。内存泄漏的本质,就是这条引用链本身是“多余”的,但程序没有把它断开。
1.2 为什么内存泄漏比崩溃更可怕
内存泄漏很少让你立刻崩溃,它更像一种慢性病。项目早期根本感觉不到,跑几周甚至几个月后,内存曲线开始缓慢爬升,然后在某个时间点突然失控。这个时间点往往是业务高峰,因为高峰期的对象分配速率高,泄漏对象增长更快,堆被快速填满,触发频繁 Full GC,然后 CPU 飙高、系统吞吐量暴跌、请求超时,最终以进程被 OOM Killer 干掉或 JVM 抛 OutOfMemoryError 收场。
更麻烦的是,内存泄漏的问题往往要等“复现条件”满足才爆发,而这些条件在生产环境才出现。比如某个缓存只在特定用户触发特定接口时才会写入,平时测试根本走不到。所以内存泄漏的排查成本往往很高,因为你没法用一个固定的用例去稳定触发它。
还有一个容易被忽略的点:内存泄漏会掩盖其他问题。当堆里堆满了不可回收对象,GC 的压力会让整体性能下降,你可能会误判成 CPU 瓶颈、锁竞争、数据库慢查询等,排查方向完全跑偏。这也是为什么我说内存泄漏比崩溃更可怕——崩溃是显性的,你马上会去看;泄漏是隐性的,它潜移默化地拖垮整个系统,还会把你的排查注意力带偏。
1.3 不同类型的内存泄漏:从常驻型到瞬时型
按泄漏对象的特点,内存泄漏大致可以分三类,分类有助于快速缩小排查范围。
第一类是常驻型泄漏。泄漏的对象生命周期很长,比如缓存、静态集合、单例对象。这类泄漏的特征是内存占用稳定增长,重启进程后归零,但随着运行时间推移不断走高。定位方法比较成熟:拉堆转储,找那些“实例数异常多、但业务上不该存在”的大对象。
第二类是瞬时型泄漏。泄漏发生在一次操作或一个请求内,比如每次请求都 new 一个对象并注册监听器,但请求结束后监听器没移除。这类泄漏的特征是即使并发不高,内存也会随着请求量稳步上升,而且泄漏速率和 QPS 呈线性关系。瞬时型泄漏的难点在于对象本身很小、很分散,单看堆转储很难一眼看出问题,往往需要用采样对比的方式,找两次堆转储之间增量最大的对象。
第三类是隐式引用泄漏。典型代表是 ThreadLocal、ClassLoader、JNI 全局引用。这类泄漏最隐蔽,因为这对象直接被 GC Root 引用,工具分析时不容易看出“业务上不该引用”这个事实。比如用 ThreadLocal 存储用户上下文,线程池里的线程不销毁,ThreadLocal 的值就一直被线程持有,如果忘记 remove,等于每个线程都永远握着一个对象的引用。
这节想说的是:排查内存泄漏,第一步永远不是着急抓工具,而是先判断它是常驻型、瞬时型还是隐式引用型,判断完之后,工具选型范围和排查路径基本就定了。
2. 内存泄漏最常见的五个产生模式
2.1 静态集合类持有对象
吃内存泄漏亏最多的一个模式,就是静态集合类。代码里经常有人图省事写一个private static Map<String, Object> CACHE = new HashMap<>(),然后往里面 put 数据,却从不考虑什么时候 remove。
这个模式的泄漏机制非常典型:静态变量本身是 GC Root,它引用的 Map 不会被回收,Map 里的 key 和 value 也就永远不会被回收。如果 key 是用户 ID、订单号这类业务主键,且数量无限增长,这个 Map 就会变成无底洞。我在前面提到的那次线上事故,就是一个全局静态 Map 把已退出登录用户的会话对象全缓存住了。
要处理这种问题,核心不是禁止使用静态集合,而是明确集合中元素的“生命周期”。如果是需要长期保留的数据,应该设上限,比如用 Guava 的 CacheBuilder 设置 maximumSize 和过期策略;如果只是想临时传值,用完必须 remove,最好在 finally 块里做清理。另外也可以用弱引用容器,比如 WeakHashMap,它的 key 在失去强引用后会被自动回收,虽然不能解决所有问题,但至少能兜底。
2.2 监听器与回调:注册和注销永远不对称
事件监听、观察者模式、消息订阅,这类设计在 GUI 程序、Android、中间件里都非常常见。泄漏点发生在:一个对象注册到某个全局事件源上,之后这个对象自己已经销毁或不再需要接收事件,但事件源还持有它的引用。
举个例子。Java Swing 的addActionListener,注册之后如果忘了removeActionListener,Swing 内部会一直持有 listener 的引用,哪怕这个界面早就关了。Android 上类似的场景非常多,比如在 Activity 里向某个全局的单例注册了回调,Activity 销毁时没有反注册,结果全局单例一直持有一个已经“死亡”的 Activity 实例。
这类问题的排查特征也很鲜明:泄漏对象持有的往往是一个界面对象、一个回调对象,而且对象数量会随某种操作的次数增长。解决思路其实很简单——保证注册和注销成对出现,最好在同一个生命周期回调里完成。比如 Android 的 Activity,在onStart注册、onStop反注册;Java 服务里用try-finally或AutoCloseable来保证释放。如果你在设计自己的框架,尽量提供与注册对应的反注册接口,并在文档里写清楚调用时机。
2.3 非静态内部类:一个隐藏的外部类引用
这个模式是 Java 和 Android 开发特别容易踩的。非静态内部类(包括匿名内部类)会隐式持有外部类实例的引用,以便访问外部类的成员变量和方法。如果内部类对象的生命周期比外部类长,外部类就会被“连带保住”。
最典型的例子是:在 Activity 里创建了一个 Handler 匿名内部类,Handler 被用于延时消息,消息队列里的 Message 持有 Handler 引用,Handler 又持有 Activity 引用。如果 Activity 已经 finish,但消息队列里的延时消息还没执行完,Activity 就永远无法回收。这也是为什么很多 Android 内存泄漏案例都围绕 Handler 展开。
解决方式有两个方向:一是把内部类改成静态内部类,不持有外部引用,需要访问外部状态时通过弱引用传入;二是检查异步任务的取消机制,确保生命周期结束时任务真正被取消,而不仅仅是“标记取消”。这里要强调的是,静态内部类确实避免了隐式引用,但如果外部对象本身是你的业务主体,要格外小心“为了避免泄漏而引入了新的设计复杂度”这种过度设计。
2.4 资源对象未关闭:堆外与堆内双泄漏
连接、流、游标、文件句柄、Channel,这些资源对象不关闭,会造成两类泄漏。一类是堆内对象泄漏——比如FileInputStream没有 close,它的底层对象和缓冲数组会占堆内存;另一类是堆外/系统资源泄漏——文件描述符、Socket 连接、数据库连接没有释放,系统层面资源被打满。这类问题在长连接、异步 IO 场景下尤其严重。
排查这类问题要综合看两类指标:堆内存曲线可能并不明显上涨,但连接数、句柄数、线程数会持续增长。Linux 下可以用lsof -p PID | wc -l看进程占用文件描述符数量,Java 服务可以定期打印连接池状态。
代码层面最好的实践就是“使用即关闭,关闭必成对”。Java 7 之后的 try-with-resources 是个非常好的工具,它能保证 AutoCloseable 资源在代码块退出时自动关闭,而且支持多资源并列声明。对于自定义资源,务必实现 AutoCloseable 接口,并在 close 里做真正彻底的释放,避免“关了个寂寞”——只把资源标记为关闭,底层连接却没释放,这比不关还隐蔽。
2.5 ThreadLocal 与缓存:隐式引用的重灾区
ThreadLocal 的泄漏机制比较特殊。每个 Thread 持有一个 ThreadLocalMap,Map 的 key 是 ThreadLocal 实例的弱引用,value 是强引用。问题在于:key 被回收了,但 value 还留在 ThreadLocalMap 里,对 value 来说这是一个“key 为 null 但 value 仍被引用”的残留条目。在线程池场景下,线程数量有限且生命周期长,如果不手动 remove,每个线程的 Map 里会积累无数个 value,形成泄漏。
很多人问,为什么 ThreadLocal 的 key 都设计成弱引用了还有泄漏问题?这其实是个取舍:弱引用保证了 ThreadLocal 实例本身能被回收,但 value 的清理必须依赖后续的get/set/remove操作来顺带清理脏条目。如果使用完后不再触碰这个 ThreadLocal,脏 value 就会一直存活。
所以 ThreadLocal 的使用规范比很多人想的更严格:第一,用完必须 remove,最好在 finally 中执行;第二,尽量声明为 static final,避免同一个业务场景创建多个 ThreadLocal 实例;第三,如果是保存请求级上下文,考虑用自动清理的框架机制,或者用作用域更明确的传参方式,而不是所有数据都往 ThreadLocal 里塞。
缓存泄漏的模式与之类似,尤其是不设过期时间、不设大小上限的本地缓存。看起来是“为了性能而缓存”,结果是缓存本身成了泄漏源。解决方式很简单,给缓存明确淘汰策略和上限,像 Caffeine、Guava Cache 这类成熟缓存库都已经实现了容量控制和过期机制,直接用它们比手写 HashMap 缓存安全得多。
3. 常见内存泄漏检测工具全景对比
3.1 工具分类:静态分析、运行时采样、堆转储分析
面对这么多工具,第一步不是挑一款,而是先理解工具分几类。我习惯把内存泄漏检测工具分为三大类,不同类别解决的问题聚焦点不一样。
第一类静态分析工具,比如 Android Lint、SpotBugs、Error Prone、SonarQube。它们不运行程序,直接扫描字节码或源码,用规则匹配已知的坏味道,比如“非静态内部类持有 Activity 引用”“资源未关闭”等。这类工具适合在 CI 里做门禁,能拦截一部分低级错误,但缺点是只能发现规则里有的模式,对业务场景相关的复杂泄漏无能为力。
第二类是运行时采样与监控工具,包括 JFR、async-profiler、jstat、Java Flight Recorder 以及各类 APM 工具。它们关注的是“程序运行期间内存分配和 GC 行为的统计信息”,比如对象分配速率、GC 频率、各代内存占用。这类工具能告诉你系统“正在经历什么”,但通常不能直接告诉你“是谁泄漏了”。
第三类是堆转储分析工具,MAT、JVisualVM、JProfiler、Android Studio 的 Profiler 都属于这一类。核心做法是把 JVM 或 ART 的内存快照导出成堆转储文件,然后分析对象之间的引用关系。这是定位内存泄漏最直接、最有效的一类工具,因为泄漏最终要靠“对象图”来定位:谁强引用了它,为什么回收不掉。
选型的基本原则是:静态分析做前置预防,运行时监控帮助判断泄漏是否存在、什么时机泄漏最严重,堆转储分析负责最终定位根因。三者配合,而不是只靠一个工具。很多新人只学会了 MAT 打开堆转储,却搞不清 JFR 和堆转储的区别,概念完全混在一起,排查效率自然低。
3.2 JVM 体系主流工具盘点
jmap / jstat。这两个是 JDK 自带的最小工具,不装任何第三方软件就能用。jstat 用来观察 GC 行为和内存池占用,命令形如jstat -gcutil PID 1000 10,能每 1 秒输出一次各内存池的占用百分比与 GC 次数。jmap 用来生成堆转储,命令形如jmap -dump:live,format=b,file=heap.hprof PID。需要注意,jmap 生成堆转储时会触发一段较长时间的暂停,因为需要遍历对象头,线上执行前要评估影响。还有一个坑,新版 JDK 把 jmap 的部分能力挪到了 jcmd 上,推荐直接用jcmd PID GC.heap_dump。
Eclipse MAT。Mat 这个工具定位于堆转储分析,核心价值是提供 Dominator Tree(支配树)、Leak Suspects(泄漏嫌疑)报告和 OQL 查询。打开一个 heap.hprof,Leak Suspects 会自动列出几个最大的存活对象和它们的引用链,对快速定位泄漏非常有效。Dominator Tree 则帮你看哪些对象“支配”了最多内存。我在日常排查时,90% 的泄漏根因都能在 Leak Suspects 里直接看到线索,只有遇到很深的引用链时才需要用 OQL 手动查。它是免费工具,内存消耗比较厉害,分析大堆文件时要把 MAT 自己的 -Xmx 调大一些,比如-Xmx4g。
JProfiler。JProfiler 是商业工具,胜在交互体验,不需要手动拉转储就能在 UI 里实时观察对象分配和引用链。它能把对象的分配调用栈记录下来,直接显示哪些代码创建了这些无法回收的对象,这对“瞬时型泄漏”特别有用,因为你能直接看到泄漏对象的出生地。缺点是贵,而且对线上环境的接入成本相对高。
async-profiler / JFR。async-profiler 是一个基于 JVM TI 的低开销采样工具,可以采集分配采样、CPU 采样和 Native 内存。JFR 是 JDK 11 之后内置的飞行记录器,同样支持分配采样。它们最大的优势是开销低,可以长时间挂在生产环境上,记录 Full GC 触发前后的分配热点。很多隐蔽的泄漏都是通过这类采样工具先锁定可疑类型,再转 MAT 精确分析的。
3.3 Android 生态特色工具
Android 场景与 JVM 服务端不完全一样,ART 虚拟机、组件生命周期、界面对象让泄漏问题更普遍,工具也更面向开发者日常开发环境。
LeakCanary。LeakCanary 是 Square 开源的内存泄漏检测库,也是 Android 开发绕不过去的工具。它的机制很聪明:监听 Activity / Fragment / View 等对象的析构时机,当一个对象生命周期结束了还迟迟不被 GC 回收,就自动 dump 一份 hprof,并通过引用链分析直接告诉你“谁还在引用这个本该被销毁的对象”。它最大的价值是自动化,开发阶段把依赖加进来,跑一遍正常流程,如果产生泄漏,日志里直接可以看到泄漏链。正因为它在对象销毁后才检测,所以很适合测试回归阶段使用。
Android Profiler / Memory Profiler。Android Studio 自带的 Profiler 能实时观察应用内存占用,支持 Java/Kotlin 对象采样、分配跟踪和堆转储。它能做内存录制对比,比如进入某个页面、退出页面后再 dump 一次,和进入之前的内存状态做对比,看看哪些对象没有随页面销毁而释放。它的分配跟踪可以显示对象的分配调用栈,对排查瞬时泄漏非常有用,但开启分配跟踪会拖慢应用运行速度,建议小规模复现时使用。
StrictMode。StrictMode 是 Android 系统提供的开发者工具,能检测线程策略和 VM 策略的违规行为,比如不必要的网络访问、未关闭的 Cursor、未关闭的 SQLite 资源、对象没有调用 close 等。它不能直接检测普通对象泄漏,但能拦截“资源未释放”这一类常见泄漏源。适合在开发版的 Application 里开启,把违规信息输出到日志。
3.4 工具选型:不同阶段、不同场景怎么搭配
工具不是越贵越好,也不是功能越多越好,关键是匹配场景。
如果主要做 JVM 服务端,我建议标配组合是:jstat 做日常监控,JFR 或 async-profiler 做低开销采样,MAT 做定点堆转储分析。这套组合是免费的,覆盖了从“监控问题存在”到“定位根因”的完整链路。如果预算充足,JProfiler 能显著提升交互效率,尤其适合团队新人上手。
如果是 Android 客户端,开发期必装 LeakCanary,配合 Android Lint 做静态检查;复现问题时用 Android Profiler 手工录制和对比;排查资源泄漏时在 debug 包开启 StrictMode。测试阶段尤其建议让 QA 用带 LeakCanary 的包跑完整回归,让泄漏在发版前暴露。
还要聊一个观点:任何工具都不能替代“对内存模型的理解”。工具只是帮你把引用链放大,最后判断哪条引用链是“应该断开而没断开”的,还是需要人基于业务逻辑去做决定。遇到大型堆转储,工具甚至有误导的可能,比如它给你列出来一堆大对象,但其中很多对象是由正常业务的缓存机制导致的,这需要你能区分“业务合理的大对象”和“异常泄漏”。
4. 实操:从怀疑泄漏到定位根因的完整排查链路
4.1 第一步:用监控指标确认泄漏,而不是凭感觉
排查泄漏最容易犯的错误,是内存一涨就着急拉堆转储,结果拉下来一个几十 GB 的文件,打开后一头雾水。更合理的第一步,是用监控指标确认“这确实是泄漏”,而不是“只是正常的内存波动”。
需要看的核心指标有三组。第一组是堆内存使用量随时间的变化,如果进程启动后堆外内存占用总是在旧数据之上持续攀升,比如每次业务高峰之后都回不到之前的水位,这就很可能有泄漏。第二组是 GC 指标,包括 Young GC 次数、Full GC 次数、每次 GC 后的堆占用。特别关注 Full GC 之后老年代占用是否仍然持续走高,因为正常情况下 Full GC 后老年代应该显著回落。第三组是分配速率,可以用 JFR 的 Allocation Profiling 或 async-profiler 观察每秒分配多少 MB,以及哪些对象分配最多。
这里我分享一个“水位对比法”。服务运行一周,每周一早上记录一次 Full GC 后的老年代占用。如果周一占用 1.2GB,第二周周一变成 1.8GB,第三周变成 2.4GB,稳定持续上涨,那就基本可以断定存在常驻型泄漏。相比截取瞬时值,这种分段对比的方法能过滤掉业务波动的干扰,判断更加可靠。等确认了泄漏,再进入第二步。
4.2 第二步:正确生成堆转储快照
确认存在泄漏后,就可以拉堆转储了。但生成堆转储有几个需要注意的细节,做不对会导致反复返工。
生成时机很重要。如果泄漏是常驻型的,在内存使用率达到较高水平但还没到 OOM 边缘时 dump 最合适,因为此时大部分泄露对象都已经积累起来了,分析结果最清晰。如果 dump 太早,泄漏对象数量还不够多,参考价值很低;如果太晚,可能 OOM 导致 dump 失败。如果泄漏是瞬时型的,则应该在完成某次操作后立刻 dump,比如点开某个页面、跑完一轮批量任务。
命令方面,JDK 8+ 可以使用jcmd PID GC.heap_dump /path/to/heap.hprof,也可以使用jmap -dump:format=b,file=heap.hprof PID。注意-dump:live参数只 dump 存活对象,会先触发一次 Full GC,虽然文件变小,但可能把候选泄漏对象的“活跃证据”清理掉,分析时容易漏判。建议默认不用 live 参数,dump 全量对象。生成大堆转储时,进程会短暂停顿,线上操作前务必评估业务影响,最好和运维确认窗口。
4.3 第三步:用 MAT 分析 Dominator Tree 定位对象持有者
拿到堆转储文件后,用 MAT 打开。首次打开大文件时,在 Memory Analyzer 的配置文件里把-Xmx调大,比如设置为4g或8g,否则会直接报 out of memory。我见过不少人在这里卡住,以为是工具坏了,其实就是默认堆太小。
打开后的第一站,我建议直接看 Leak Suspects 报告。MAT 会自动分析堆转储中占用内存最大的几个对象集合,并给出对应的引用链,即“GC Root -> 中间对象 -> 泄漏对象”的完整路径。如果泄漏对象比较单一,这个报告基本能直接锁死根因。
如果 Leak Suspects 不够明确,就切到 Dominator Tree,按 retained size(保留大小)排序。这里要理解支配树的概念:如果一个对象 A 的引用链上必须经过 B,而 A 占用的所有内存集合中 B 是“拥有者”,那么 B 是 A 的支配者。排查时重点看 Retained Heap 最大的节点,然后逐层展开引用链,找到离 GC Root 最近的业务对象。一般而言,离 GC Root 越近的节点,越能代表泄漏源头的持有者。
最后配合 Histogram 按类名统计实例数量和 Shallow Heap / Retained Heap,能快速发现“数量异常但业务上不该这么多”的类。这点在 Android 场景尤其好用,比如显示一个页面退出后,页面对应的 Activity 实例数量还大于 1,基本就是泄漏实锤了。
4.4 第四步:从引用链反推代码病灶
工具给的是一条条引用链,真正的挑战是把这条链翻译成代码层面的修复方案。
举个例子。堆转储里看到某个 Activity 实例数量有几十个,MAT 给出的引用链是:
GC Root (Thread) -> HandlerThread -> MessageQueue -> Message -> Handler callback -> InnerRunnable这条链告诉我们,一个消息被投递到了 HandlerThread 的消息队列里,这个消息又持有一个匿名 Runnable,而匿名 Runnable 持有了 Activity 的引用。结合业务代码,如果这个 Runnable 是延时任务,且没有在 Activity 销毁时移除,那最终结果就是 Activity 被消息队列“拖住”无法回收。
修复方案对应地也分两层:一是改变持有关系,比如把内部 Runnable 改成静态类并传入 WeakReference;二是在生命周期销毁时移除还没执行的消息,比如handler.removeCallbacksAndMessages(null)。
这里我想强调一个经验:修复内存泄漏,优先考虑“缩短生命周期保证引用断开”,而不是“用弱引用掩盖问题”。弱引用确实能打破强引用链,但它也引入了一个不确定性——对象可能在任何时刻被回收,业务时机容易出问题。仅仅为了规避泄漏检测而到处用弱引用,是一种掩耳盗铃。
5. 常见问题与排查技巧实录
5.1 堆转储文件太大,MAT 打不开怎么办
大型堆转储经常超过 10GB,MAT 默认配置肯定扛不住。第一步是把 MAT 的启动参数调大,修改MemoryAnalyzer.ini,加上-Xmx8g -Xms4g。如果还是打不开,可以考虑两条路。
一条是改用 jhat 或者 JVisualVM,它们对文件大小的容忍度稍好,但交互能力弱。另一条是先用jmap -dump:live只保留存活对象,文件大小通常能缩小很多,但这意味着会先触发 Full GC,可能会“洗掉”一部分瞬时泄漏线索。所以更推荐的方式是:在 dump 时用jcmd GC.heap_dump,同时控制对象总量,比如触发场景时限制并发量,让 dump 文件相对可控。
还有一种做法是分两次 dump,对比增量。比如记录下第一次 dump 后某对象实例数的基线,运行一段业务负载后再 dump,然后对比 Histogram 差异,直接看“增长最快的对象”。这种方式处理大文件更有效率,不用总想着把全量文件塞进 MAT。
5.2 为什么明明感觉有泄漏,却抓不到现场
这种挫败感每个人都经历过:监控里内存涨得很明显,但 dump 下来却找不到“杀手对象”。原因往往有三个。
第一,dump 的时机不对。泄漏对象可能在业务低谷期已经被 GC 回收掉了(瞬时型泄漏尤其如此)。解决方法是先通过监控确认泄漏的高峰窗口,确定 dump 时机,而不是随手 dump。第二,对象太小、分散在大量实例中。每个泄漏对象只占几 KB,但数量达到几十万,单看 retained size 排名根本排不上。这种情况要用 Histogram 按实例数排序,找“实例数异常”的类。第三,泄漏发生在堆外。比如 DirectByteBuffer、mmap 文件等,堆内 dump 根本看不到。要定位堆外泄漏,要用 NMT (Native Memory Tracking) 或 pmap 看进程内存分布。
我自己的习惯是,先看一眼堆内老年代是否持续增长,再用 NMT 看 native 内存是否增长,两个方向一起排查,避免在错误方向上死磕。
5.3 长存对象和频繁 Full GC 怎么区分
有些情况下,内存持续增长并不是泄漏,而是正常的缓存机制或内存碎片化。比如一个设计合理的 LRU 缓存,它可能长期保持在高水位,但不会无限增长,这种不能算泄漏,只是内存被“合理”占用。
区分方法很简单:观察 Full GC 之后的内存水位。真正的泄漏,Full GC 之后内存的下降非常有限,因为泄漏对象被强引用链保住了,GC 无法回收;而正常的高内存使用,Full GC 之后会出现明显的断崖式下降。另外可以看 Full GC 的次数和耗时,如果 Full GC 频率持续上升,不断扫描大堆,但回收效果越来越差,这几乎就是泄漏的典型信号。
这里也要提醒一句,Java 14 之后引入了 ZGC,Android 的 ART 也有自己的 GC 策略,不同 GC 对老年代的处理方式不同,指标解读略有差异。无论是哪个 GC,核心思路一致——看 GC 之后内存是否回到正常水位,而不是看“当前占用高不高”。
5.4 各工具实测中的踩坑速查
最后把日常折腾这些工具时踩过的坑集中记一下,按工具分类,算是给后来人省点时间。
jmap:不要在生产高峰期用 jmap dump,会 STW(Stop The World)。如果必须 dump,先用 jstat 确认 GC 频率不高的时候再操作。新版 JDK 建议用 jcmd,它更安全、输出路径更灵活。
MAT:打开大文件前一定先调整自身 -Xmx;Leak Suspects 报告适合快速定位,但别完全依赖它,复杂对象图必须用 Dominator Tree 手动看;OQL 是终极武器,语法类似 SQL,过滤对象非常方便。
LeakCanary:它只在 debug 构建下推荐开启,release 版不要带。因为 dump 动作本身有一定开销,还会追加一些额外代码依赖。如果测试机型内存很小,LeakCanary 的 dump 可能失败,要让测试回归环境尽量接近真实用户配置。
Android Profiler:开启 Allocation Tracking 后应用运行速度明显变慢,容易触发 ANR。建议在小场景、低并发下做录制,不要跑全流程。
JFR/async-profiler:默认的分配采样是采样,不是全量统计,看到的数据有统计误差。定位热点类型时,误差不影响结论方向;但要精确统计某个对象的分配次数,还得用 JFR 的jdk.ObjectAllocationSample事件或者手动插桩确认。
5.5 在代码层面做“防泄漏设计”,而不只是依赖工具
工具再强,也只是事后补救。真正成熟的团队,会把内存泄漏的防控前移到编码规范里。我在团队里推了三条硬性约定,效果非常明显。
第一,所有集合类必须声明预期的容量上限和清理策略,禁止无上限的静态集合。代码评审阶段我会专门盯静态变量和集合类型,看到static Map几乎必问一句“什么时候 remove”。第二,所有涉及注册/订阅/回调的代码,必须在同一方法块中完成注册与反注册,要么用 try-finally,要么用生命周期回调保证成对出现。第三,全局单例里禁止直接存储请求级、会话级对象,这类对象必须显式传参或放入作用域上下文,用完即清理。
这些约定不是凭空想出来的,都是从一次次泄漏事故里总结出来的规则。工具解决的是一次性的问题,规范解决的是后续所有可能引入类似问题的新代码。
我个人在一次次排查泄漏之后,最大的体会是:内存泄漏这个东西,越早暴露代价越小。开发期一个小时内定位的问题,拖到线上可能要花一周才能复现和确认。所以与其临渴掘井,不如把静态分析、LeakCanary、crash 归因这些自动化手段从一开始就嵌入到日常开发流程里。最后再分享一个小细节:不要只看平均内存,要看“GC 后水位”。一看到平均占用涨了就慌,容易被业务波动误导,把时间浪费在假警报上。真正值得警惕的,是每次 GC 之后那个迟迟不肯降下来的底部水位——那才是泄漏对象藏身的地方。