第一次独立处理线上OOM告警时,我在凌晨两点干了件蠢事:先重启了服务。告警确实消失了,但第二天监控上那根陡峭的内存曲线、日志里的OutOfMemoryError堆栈,以及唯一一次保留现场的机会,都随着重启一起没了。后来带团队排查内存泄漏的过程中,我慢慢发现大多数OOM事故并不难定位,难的是找对方向、留好现场、用对工具。这一章我会把内存泄漏与OOM排查的完整方法拆开讲,从概念辨析、异常分类、线上操作链路、真实案例到工具的组合用法,尽量让没有经验的同学也能在拿到一次OOM告警后,走完从发现到修复的全过程。
1. 内存泄漏不是OOM,但90%的OOM背后都有它的影子
1.1 先把概念捋直:一个在"积攒",一个在"爆炸"
很多刚接触排查的同学会默认"OOM就是内存泄漏",或者反过来"没有泄漏就不会OOM"。这两种说法都不完整。
内存泄漏,准确描述是"对象本应结束生命周期,却因为引用链仍然存在,导致GC无法把它回收"。GC判断对象是否存活的标准只有一个:从GC Root出发,能不能沿着引用链找到它。能找到,就认为"还活着",哪怕业务上这个对象已经彻底没用了。典型场景就是对象被放进了静态集合、缓存、ThreadLocal里,这些容器对象的存活时间特别长,长到比业务数据的生命周期还长,于是容器里堆积的业务对象越来越多,堆内存被一点点吃掉。
OOM是OutOfMemoryError,JVM在分配内存时发现内存空间无法满足请求而抛出的Error。它和泄漏没有必然的等价关系。我举一个极端例子:一个2GB堆的JVM,业务代码试图一次性读入1.8GB的byte数组,这也会OOM,但此时进程里没有任何泄漏,纯属"一次性胃口太大"。反过来,泄漏引发的OOM一定有个过程:对象缓慢堆积,GC回收不掉,堆占用越来越高,直到某一刻分配新对象时突破上限。
所以拿到任何OOM告警,第一件事不是去翻代码找"泄漏点",而是先判断这个OOM属于哪一种:是"瞬间分配不过来",还是"长期缓慢堆积后的必然结果"。方向错了,后面全是白忙。
1.2 判断方向的三个信号,比任何工具都重要
我判断方向一般只看三样东西:
第一,看监控上堆内存使用率的形态。一个长期缓慢上升、重启后回落、过几天又爬上去的"锯齿上坡",基本就是泄漏,而且大概率有固定的触发路径,比如某个接口、某个定时任务在持续制造不可回收对象。如果堆曲线平时很平稳,某一天某个时间点突然一根直线冲上去,那更可能是大对象一次性分配,比如导出一个超大Excel、全表查询、大报文解析。
第二,看Full GC之后的回收效果。内存泄漏的典型特征是:即使你看到Full GC执行了,老年代占用率也降不下去,因为占着老年代的本来就不是真正可回收的垃圾。GC日志里如果出现连续多次Full GC间隔越来越短、回收比例越来越小的趋势,哪怕还没OOM,也已经在泄漏路上了。
第三,问自己几个问题:OOM发生在什么时候?是高峰期的某个特定操作吗?最近有没有发版?发版后多久开始出现?有一次我排查一个服务OOM,发现每次都是凌晨定时任务跑了十几分钟后开始Full GC,最后定位到一个批处理任务在循环里往静态Map里塞数据,做的又是不可中断的大批量调度,泄漏速度被任务频率放大了好几倍。这类规律性的线索,在动手抓dump之前就能帮你圈定搜索范围。
注意:单次堆内存使用率升高不等于内存泄漏。内存泄漏的判定核心是"业务生命周期结束后,对象是否仍然被引用",而不是"现在内存高不高"。有些中间件本来就吃内存,比如Kafka的broker、ES的fielddata,看着高但曲线平稳,那叫容量设计问题,不叫泄漏。
2. 读懂OOM的六种报错:每一句都指向不同的真凶
OutOfMemoryError不是一个孤立的异常名,JVM会在报错信息里给出具体类型,每一种对应不同的内存区域和根因。我见过不少排查卡壳的工程师,看到"OutOfMemoryError"就一头扎进堆dump,结果根因根本不在堆里,白折腾几个小时。
2.1 Java heap space:堆内分配失败,最直白的一种
这是最常见的形态。报错信息是java.lang.OutOfMemoryError: Java heap space,含义是堆内存中没有足够空间分配新对象。常见触发原因有三种:
- 一次性大对象分配,比如把1GB的文件读成byte数组,或者SQL查询把全表几百万行一次性拉进内存。
- 并发请求量超过堆容量上限,比如堆只有512MB,但QPS冲到很高,每个请求都持有大量中间对象。
- 内存泄漏导致堆被挤占,但如果不是泄漏,堆占用曲线应该是平稳的,只是某一个特定操作触发了超分配。
排查时看具体场景:如果是某一个大报表功能稳定复现,几乎可以肯定是代码层面的分配不合理,应该去优化SQL或分批处理;如果是在业务高峰偶发,优先看QPS、单请求内存开销和堆大小的匹配度。
2.2 GC overhead limit exceeded:GC已经救不了场
报错信息是java.lang.OutOfMemoryError: GC overhead limit exceeded。这是JVM的一个自我保护机制:HotSpot默认启用-XX:+UseGCOverheadLimit,当GC花费了超过98%的时间,但回收到的堆内存不足2%,JVM会主动抛出OOM来结束这种"垃圾回收空转"的状态。
这个报错几乎就是泄漏的"高配版信号"。它不是堆不够大,而是堆里绝大多数对象都无法被回收,GC白忙活。你可以把它理解成一个房间里堆满了垃圾,清洁工一直在干活,但垃圾永远清不掉,最后房东宣布不干了。出现这个报错,别再调大堆内存,调大只是延长等死时间,赶紧抓dump找不可回收对象。
2.3 Metaspace:类的元数据把内存吃满了
JDK8之后,类的元数据存储在Metaspace,报错信息是java.lang.OutOfMemoryError: Metaspace。Metaspace默认没有上限,受本机内存限制,所以它一旦OOM,通常意味着动态生成的类太多了。
什么场景会搞出大量类?热部署框架反复重新加载应用但没有清理旧类加载器、CGLIB/ByteBuddy/ASM动态代理创建了海量代理类、Groovy脚本或JavaScript引擎反复编译代码字符串、自定义类加载器没有正确卸载。这类问题dump堆往往看不出异常,因为真正膨胀的是类元数据,不在堆里。排查时需要关注加载类总数,用jmap -clstats <pid>或Arthas的sc命令统计,定位是哪个ClassLoader、哪类类名大量增长。
2.4 Direct buffer memory:堆外内存,排查难度最高的一类
报错信息是java.lang.OutOfMemoryError: Direct buffer memory。NIO的ByteBuffer.allocateDirect、Netty的堆外缓冲区、Kafka客户端的memory pool,用的都是堆外内存。JVM默认最大直接内存约等于堆大小,由-XX:MaxDirectMemorySize控制。
堆外泄漏难查,是因为普通的heap dump完全看不到堆外对象。你抓一个dump,可能发现堆内非常健康,但进程内存持续上涨。排查思路要换成:如果用了Netty,检查PooledByteBufAllocator是否设置了合理的池化上限;如果是自研NIO框架,重点查DirectBuffer是否在finally里回收,是否存在堆积;还可以配合-XX:NativeMemoryTracking=summary启动JVM,用jcmd <pid> VM.native_memory summary看Native内存的分布,或者用pmap看进程地址空间的增长。
2.5 unable to create new native thread:线程创建失败,别在堆上找原因
报错信息是java.lang.OutOfMemoryError: unable to create new native thread。这个OOM虽然顶着OutOfMemory的名头,但根源是操作系统无法创建新线程了,跟堆内存几乎没有关系。
排查链路依次看:进程内到底创建了多少线程(jstack统计线程数,ps -eLf | grep java | wc -l);操作系统对进程线程数的限制(ulimit -u);系统级全局线程数限制(/etc/security/limits.conf、pids cgroup);线程栈大小-Xss是否设置异常,栈设得越大,能创建的线程数越少。业务侧经常踩的是无界线程池——Executors.newFixedThreadPool没控制拒绝策略,回调线程、异步任务无限创建,最终打满线程上限。
2.6 Requested array size exceeds VM limit:不太常见但很好定位
报错信息是java.lang.OutOfMemoryError: Requested array size exceeds VM limit。JVM对数组对象有长度上限,比如最大约Integer.MAX_VALUE - 8,当代码尝试分配一个超过上限的数组,会直接抛这个OOM。
绝大多数情况是代码bug:byte数组长度计算溢出、读取二进制协议时把长度字段解析成了负数或超大值、ArrayList扩容时int溢出。这种OOM不需要复杂的dump分析,直接从异常堆栈就能定位到具体代码行,重点检查所有涉及长度计算和位运算的地方。
我把六种形态整理成一个对照表,方便团队内部排查时直接查:
| 报错关键字 | 内存区域 | 典型原因 | 优先排查方向 |
|---|---|---|---|
| Java heap space | 堆 | 分配过大、堆过小、泄漏 | 对象分配逻辑、堆大小、dump |
| GC overhead limit exceeded | 堆 | 不可回收对象占满堆 | 泄漏、静态集合、缓存 |
| Metaspace | 元空间 | 动态类生成过多 | 类加载器、代理库、脚本引擎 |
| Direct buffer memory | 堆外 | 直接内存泄漏或配置过小 | NIO/Netty、NMT、MaxDirectMemorySize |
| unable to create new native thread | 线程栈 | 线程数超限 | 线程池、系统线程限制、Xss |
| Requested array size exceeds VM limit | 堆 | 数组分配长度溢出 | 代码长度计算、位运算 |
提醒:不要一看到"OOM"就调大
-Xmx。内存不足时加内存属于兜底手段,但如果根因是泄漏,加再大的堆也只是推迟OOM时间,而且堆越大,重启后恢复的时间越慢,事故影响面反而更大。
3. 线上接到OOM报警后的五步排查链路
OOM排查最怕的不是问题难,而是现场被破坏。我把线上处理流程固定成五步,团队里任何人接到告警都按这个来,减少凭感觉操作。
3.1 第一步:先留现场,再谈修复
看到OOM告警的第一瞬间,不要做任何可能改变进程状态的操作。先做的事按顺序来:
- 打开应用日志,找到
OutOfMemoryError关键字,把完整堆栈存下来。 - 确认启动参数里有没有
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath。如果配了,去指定目录找OOM时自动生成的.hprof文件,这是最干净、最准确的现场。 - 如果没有自动dump,但进程还活着,用
jmap -dump:format=b,file=/data/logs/app.hprof <pid>手动抓。抓取过程中目标进程会暂停,需要确认当前是否是业务高峰期,必要时先摘流量再抓。 - 如果进程已经退出或不可用,保留系统日志和监控截图,把上次自动dump的产物、GC日志、线程快照全部归档。
有一个细节值得强调:OOM后进程不一定会退出。JVM抛出OutOfMemoryError之后,如果没有明确配置-XX:+ExitOnOutOfMemoryError,进程可能还"赖活着",但此时内存状态和OOM瞬间已经不一样了,如果拖太久才抓dump,怀疑点可能已经消失。所以抓dump要趁早,宁可先抓再分析,也别等。
3.2 第二步:用GC日志和监控判断"泄漏"还是"突刺"
拿到现场数据后,先不急着开MAT,先把方向定了。我习惯用jstat -gcutil <pid> 1000观察10到20秒,重点看:
- Full GC次数(FGC)是否在快速上涨。
- 老年代占用(O)在Full GC之后是否仍然降不下来。
- Eden区和S0/S1区的分配回收节奏是否异常紧凑。
如果老年代占用在每次Full GC后都几乎不降,且Full GC间隔越来越短,那么基本可以判定堆里充斥着无法回收的对象——泄漏。如果Full GC次数很少,堆占用在OOM前一直平稳,只是某一次突然冲顶,那么更可能是大对象一次性分配,此时去查那个时间点的业务操作、上游流量、SQL或文件加载逻辑,会比钻dump更有效。
现在很多服务已经接入APM或监控平台,如果你能看到堆内存的日维度曲线,直接把告警时间往前拉一周,观察趋势即可。一根持续上坡的曲线,哪怕没有OOM,也是高级别隐患。
3.3 第三步:用dump分析找到"谁占了大头"
定位到是泄漏路径之后,把dump文件交给MAT或JProfiler。我推荐直接先看MAT的Leak Suspects报告,它会把最可能的泄漏根因按嫌疑程度列出来,不是每次都准,但能帮你在巨大信息量里找到切入点。
然后按顺序做三件事:
- 打开
Histogram,按Retained Heap(保留堆)排序,看哪些类占用了最多的内存。出现业务类名、自定义组件名,说明问题很可能在业务代码;如果全是byte[]、char[]、String,继续往下钻到引用关系。 - 打开
Dominator Tree,看"支配树"上最顶层的对象。这个方法能直接展示"如果回收掉这个对象,能释放多少内存",比Histogram更能反映出容器类对象和集合的占用。 - 右键你怀疑的对象,选择
Merge Shortest Paths to GC Roots或Path to GC Roots -> exclude all phantom/weak/soft etc. references,查看对象到底被谁引用着。这一步是后面反查代码的依据。
3.4 第四步:顺着引用链反查业务代码
引用链上的每一个环节都可能是答案所在。我看到最多的三类:
- 静态集合兜底:对象里存的是业务数据,GC Root指向静态字段,比如
private static Map<String,Object> cache。这种最直接,一眼就能认出是哪段逻辑塞进去的。 - 容器对象持有时长过久:Spring上下文里的单例Bean持有某个List或Map,容量没有上限,业务数据往里堆积。
- ThreadLocal借道线程池:对象挂在
ThreadLocalMap上,而线程池里的线程是GC Root,导致对象和线程同生共死。这种在MAT里会看到线程对象的引用链很长,需要顺着线程名找对应线程池和业务代码。
拿到引用链后,去代码里定位set对象的位置、生命周期、释放逻辑。这里有个经验:不要一上来就怀疑框架中间件,先查业务代码,再查自研公共组件,最后才查第三方依赖。因为业务代码里塞对象进集合、ThreadLocal忘记释放,占了实际泄漏案例的绝大部分。
3.5 第五步:修复、灰度、看趋势,别修完就下结论
修复的常见手段有:静态集合加容量上限和淘汰策略、ThreadLocal在finally块里remove、缓存框架换成Caffeine或Redis、连接使用try-with-resources保证关闭、大对象处理改成分批和流式。
但修完不等于结束。内存泄漏的验证周期不能以小时计,至少要观察3到7天的内存曲线和Full GC趋势。我的习惯是先在低流量节点灰度,观察老年代占用曲线的斜率是否变平,FGC频率是否回落,再逐步放量。如果只是看半小时没事就给结论,很可能掉进"没到水位线所以还没爆发"的陷阱。
4. 实战复盘:一个"平时没事"的ThreadLocal泄漏是怎么被挖出来的
讲一个我实际排查过的典型案例,流程完整,技术含量不算高,但很有代表性。
4.1 现象:服务发版第4天开始频繁Full GC
服务A是个内部查询平台,一直很稳定。某次发版之后,第4天凌晨1点开始Full GC频率明显上升,到中午堆内存被打满,进程直接OOM。监控显示:老年代占用每天在涨,而且涨幅一天比一天大,GC回收后老年代占用率仍然保持在90%以上。这是非常典型的泄漏曲线。
翻看发版记录,改动涉及一个查询接口的优化。开发说只是加了个上下文参数,没动什么内存相关的代码。但从天起算,时间点和发版完全吻合。
4.2 场景四与四的现场:字符串数组与缓存的"障眼法"
这个案例有点特殊,报错关键词是Java heap space,但dump分析后我发现对象大头是大量byte[]和String,继续往里钻,发现它们全被同一个业务对象引用着。那种情况不能光看Histogram,因为任何业务数据最终落到内存里都是String和byte[],关键是要找到真正持有这些数据的外层对象。
于是打开Dominator Tree,按Retained Heap排序,排第一的果然是一个自定义业务类,再往下展开,这个类里有一个Map,Map里存放了海量查询结果。代码review后确认:这段逻辑在业务方法里把查询结果无条件放进了静态Map,key是查询参数拼接的字符串,value是查询结果。原本设计者以为"数据量不大",但实际查询参数组合是无限的,缓存只增不减。
这类案例的处理方式比较简单:把静态Map换成Caffeine,设置最大容量和过期时间,或者数据量确实大就走Redis缓存。核心教训是一样的——本地缓存无上限,就是给内存埋雷。
4.3 ThreadLocal案例:线程池下的"借尸还魂"
另一个高频案例发生在ThreadLocal上。现象、告警方式都和第一个案例高度相似,但dump里看到的引用链更隐蔽:
Thread->ThreadLocalMap->Entry->业务上下文对象
很多同学对ThreadLocal的认知停留在"用完之后会释放",但实际上容器线程池场景下,线程是复用的,ThreadLocalMap里的Entry不会因为一次请求结束就清理。关键点在于:
ThreadLocalMap.Entry的key是ThreadLocal实例,用的是弱引用,但value是强引用。- 线程池里的线程存活时间极长,作为GC Root一直存在。
- 只要线程不死,ThreadLocalMap里的value就会一直强引用着目标对象。
所以说ThreadLocal本身不泄漏,泄漏的是"误用了ThreadLocal并且没有remove"。正确姿势是:
ThreadLocal<Context> ctx = new ThreadLocal<>(); try { ctx.set(buildContext()); // 业务逻辑 } finally { ctx.remove(); }这个案例最后的修复代码只有几行,排查的过程却走了大半天。原因就是第一反应一直在业务缓存上找,忽略了线程引用链。
4.4 这类案例给我们的通用排查顺序
把两个案例放一起,可以得出一个通用规律:出现堆内存泄漏,先按概率排优先级——静态集合、无界缓存、ThreadLocal后置清理不足、连接/IO未释放、容器对象生命周期超长。其中静态集合和缓存占了最多数,ThreadLocal次之,连接泄漏反而在连接池普及后少了很多。
抓dump后不要被String和byte[]充斥的Histogram吓到,Dominator Tree能直达"幕后对象",顺着"幕后对象"的引用链走,基本都有明确答案。
5. 排查工具的"组合拳":jmap、MAT、Arthas这样用才顺手
工欲善其事,必先利其器。排查工具的掌握程度,直接决定了定位OOM的时间成本。我把日常最常用的一套组合和踩坑点整理如下。
5.1 生产环境第一梯队命令,按使用场景选
| 命令 | 用途 | 风险等级 | 使用建议 |
|---|---|---|---|
jps -l | 找到目标JVM进程PID | 无 | 第一步必用 |
jstat -gcutil <pid> 1000 | 实时看GC、堆内存走势 | 无 | 方向判断利器 |
jmap -dump:format=b,file=xx.hprof <pid> | 抓堆dump | 中:会暂停业务 | 摘流量后使用 |
jmap -histo <pid> | 看对象分布 | 低 | 不触发FGC,适合粗筛 |
jmap -histo:live <pid> | 看存活对象分布 | 高:会触发Full GC | 生产环境慎用,高峰禁用 |
jstack <pid> | 看线程栈 | 无 | 排查线程数超限、死锁 |
jcmd <pid> VM.native_memory summary | 看Native内存分布 | 无 | 需启动时开启NMT |
这里必须提醒:很多教程会推荐jmap -histo:live用于分析,但live会强制执行一次Full GC,在内存已经很紧张的进程上可能直接演变成雪崩。我在生产上几乎不用-histo:live,要粗筛对象分布,jmap -histo足够,要精确分析,就直接抓dump离线看。
5.2 MAT的使用:别被Histogram带偏
MAT(Memory Analyzer Tool)是我最常用的dump分析工具。打开一个几百MB甚至几个GB的dump,如果本机内存不够,先改MemoryAnalyzer.ini里的-Xmx,否则MAT自己会OOM。
打开之后不要第一时间进Histogram。很多新手看到Histogram里全是byte[]和String,瞬间不知道怎么继续。我建议按这个顺序走:
Leak Suspects报告:让MAT自动帮你圈定可疑对象,看它的结论和解释。Dominator Tree:在怀疑对象上右键展开,看最顶层的支配者是谁。支配树的价值在于,它按"保留堆"计算,能消解掉String/byte[]带来的干扰,直接展示最外层的容器。Path to GC Roots:从支配者右键找GC引用路径,这条路径就是代码定位的直接证据。
在MAT里看Path to GC Roots时,有一个小细节:默认会排除弱引用,但不排除强引用,所以你能直接看到强引用链。如果链路上出现java.lang.Thread,基本可以断定和线程池、ThreadLocal有关;如果出现类名里的静态字段,比如com.xxx.Processor.cache,那么静态集合的问题就实锤了。
5.3 Arthas在生产环境的几个高光时刻
生产环境不能随意停应用,Arthas这类在线诊断工具的价值就体现出来了。我常用的命令:
dashboard:实时看到内存、GC、线程的概览,判断方向比jstat更直观。memory:直接输出堆、非堆、直接缓冲区等各区域使用情况。heapdump:不需要摘流量就能导出堆快照,虽然也会STW,但比裸用jmap友好。thread -n 3:按CPU占用排序线程,排查线程数超限、线程卡死都很方便。watch:在线追踪某个方法入参和调用次数,在复现阶段能快速确认某个接口是否就是泄漏入口。
Arthas还有一个好处:它不要求目标JVM以特殊参数启动,attach上去就能用。这意味着很多当时没留dump、但是进程还活着的现场,还有补救机会。
5.4 dump之外的思路:前端和其他中间件是同一套方法论
排查思路是可以跨技术栈复用的。前端内存泄漏排查,Chrome DevTools里的Heap Snapshot两次对比、Performance面板的JS堆内存曲线,本质上和MAT的"比较dump找不回收对象"是一样的。Kafka的broker OOM、Netty的堆外泄漏,排查链路仍然是"留现场、看趋势、找最大占用、反查引用关系"。工具不同,方法论一致。
经验:不要等出问题了才学工具。找一台测试环境JVM,人为构造一个静态List塞数据,再手动触发OOM,把整个"抓dump -> MAT分析 -> 定位引用链"流程跑两遍,熟练之后线上再怎么忙也不会慌。
6. 把OOM扼杀在发布之前:监控指标与代码防泄漏清单
排查是事故发生的功课,但对团队来说,更值钱的是让OOM在发布前就失去生存土壤。这一节聊预防层面的落地手段。
6.1 上线前就配好的"兜底参数"
即使预防做得再好,也要假设某次发版还是会出问题。所以启动参数必须在一开始就配齐:
# JDK8 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/oom-%p.hprof -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=64m -XX:+PrintGCDetails -XX:+PrintGCDateStamps# JDK9+ -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/oom-%p.hprof -Xlog:gc*:file=/data/logs/gc.log:time,uptime,level:filecount=10,filesize=64m如果希望OOM时进程直接退出由编排平台重启,可以加-XX:+ExitOnOutOfMemoryError,避免进程在"半死不活"的状态下持续接受流量、持续报错。
HeapDumpPath目录要提前确认写权限和磁盘空间。我见过一次事故:OOM发生时确实触发了自动dump,但磁盘空间不足,dump写到一半失败,现场直接丢失。这个细节太容易栽了。
6.2 监控指标:报警的不应该是OOM本身
很多团队的OOM告警是基于"检测到OutOfMemoryError日志",这已经是灾难发生后的通知了。真正有价值的告警应该前置到内存趋势上。
我建议至少盯住几个指标:
- 堆内存使用率的日趋势和周趋势。
- 老年代占用率,特别是Full GC后的老年代占用率。
- Full GC频率和单次耗时。
- GC后堆回收比例,等于回收掉多少/GC前占用多少。如果这个比例持续走低,就是泄漏前期信号。
- 原文连接:应用RT/P99在Full GC频繁期的抖动情况。
告警阈值不要只配绝对值,比如"老年代大于70%",因为很多服务常年老年代60%也正常。更好的方式是对曲线的斜率做告警:连续7天老年代占用率的日最低值逐日递增,哪怕每日只涨1个百分点,也值得人工关注。内存泄漏从来不是一分钟爆炸的,它会给足你机会,只是很多团队没设这类告警。
举个量化例子,便于和开发、运维对齐严重性:如果每个请求泄漏512KB,QPS是100,那么一天泄漏约4.3GB(512KB × 100 × 86400秒 / 1024 / 1024)。也就是说,4GB堆撑不过一天。这类计算在故障复盘和需求评审里非常有说服力,能让"评估缓存容量""ThreadLocal记得remove"从嘴上说说变成硬性评审项。
6.3 代码防泄漏自查清单,贴在CI门禁旁边
每次发布前,代码评审增加一栏内存安全检查,重点看:
- 静态集合是否有容量上限和淘汰策略;无界Map存入业务数据,必须给出理由。
- ThreadLocal初始化在方法内部,是否保证了finally里的remove;特别关注线程池场景。
- IO、网络连接、JDBC连接是否使用try-with-resources。
- 批量插入/导出是否分批,避免一次性把全量数据载入内存。
- 本地缓存组件是否配置了最大容量和过期时间,换Caffeine或Guava后是否配置了淘汰。
- 异步任务队列是否有界;无界队列在生产者速度大于消费者时,本身就是内存泄漏的温床。
- 动态生成类、脚本编译是否有类加载器回收机制,Metaspace的坑大多来自这里。
6.4 稳定性压测里的"泄漏预演"
最后一个建议:把内存泄漏纳入稳定性压测的通过标准。每次大版本发布前,在测试环境跑48小时稳定性压测,过程中持续记录老年代占用和Full GC次数。
如果48小时内Full GC次数越往后越频繁,老年代占用没有回归,哪怕没有OOM,也要按泄漏预警处理。通常这类问题在测试环境跑24小时以上就会露出端倪,但很多团队的压测只跑半小时一小时,完全不足以暴露内存缓慢增长的问题。把压测时长拉长,是最便宜的线上事故保险。
每次做稳定性压测的时候,我习惯在压测结束后再抓一次heap dump和触发一次Full GC,对比压测前后的对象分布。如果发现压测后静态缓存类、业务收集类的对象数量没有回落,就说明这个场景下存在对象残留,值得在上线前修掉。
结合个人经验说几句:我一般会在每次发版后连续盯三天的内存曲线,不看告警,只看斜率。如果老年代占用第三天比第一天还高,说明有东西没释放,立刻回去翻最近改的代码。这个习惯帮我提前拦下过好几次线上事故,比任何工具都便宜,也比任何经验都可靠。内存泄漏这件事,从来不会只给你一次机会,但也不要幻想着它每次都给你机会。把现场留好,把趋势盯住,剩下的就是把工具用顺、把清单落到实处。