尧图精选

内存泄漏排查实战:原理、五大模式与MAT/LeakCanary工具对比

🕒 发布时间:2026/10/1 5:38:41 📁 来源:尧图网络
先聊个真事。早几年我带的一个服务平时各项指标都正常结果每到业务高峰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 MapString, 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注册之后如果忘了removeActionListenerSwing 内部会一直持有 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 持有一个 ThreadLocalMapMap 的 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,formatb,fileheap.hprof PID。需要注意jmap 生成堆转储时会触发一段较长时间的暂停因为需要遍历对象头线上执行前要评估影响。还有一个坑新版 JDK 把 jmap 的部分能力挪到了 jcmd 上推荐直接用jcmd PID GC.heap_dump。Eclipse MAT。Mat 这个工具定位于堆转储分析核心价值是提供 Dominator Tree支配树、Leak Suspects泄漏嫌疑报告和 OQL 查询。打开一个 heap.hprofLeak 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:formatb,fileheap.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 打不开怎么办大型堆转储经常超过 10GBMAT 默认配置肯定扛不住。第一步是把 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 之后引入了 ZGCAndroid 的 ART 也有自己的 GC 策略不同 GC 对老年代的处理方式不同指标解读略有差异。无论是哪个 GC核心思路一致——看 GC 之后内存是否回到正常水位而不是看“当前占用高不高”。5.4 各工具实测中的踩坑速查最后把日常折腾这些工具时踩过的坑集中记一下按工具分类算是给后来人省点时间。jmap不要在生产高峰期用 jmap dump会 STWStop The World。如果必须 dump先用 jstat 确认 GC 频率不高的时候再操作。新版 JDK 建议用 jcmd它更安全、输出路径更灵活。MAT打开大文件前一定先调整自身 -XmxLeak 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 之后那个迟迟不肯降下来的底部水位——那才是泄漏对象藏身的地方。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →