那会儿我刚接手一个Java后端服务,它在Windows上的Docker Desktop里跑着。第一周一切正常,到三四天后,容器监控曲线开始一路向上:从刚启动时的800M,慢慢爬到了1.8G。第一反应是WSL2或者虚拟化层的缓存捣鬼,查了一堆系统日志,甚至一度想去调Docker Desktop的内存分配,后来才发现真正的问题出在代码里——内存在被某些对象不断“抱走”而不释放,这就是内存泄露。于是我开始了第一次系统性的内存泄露排查。
这篇文章不打算写成工具手册,更像是我自己加班一周踩出来的“微感受”复盘。里面既有排查步骤,也有当时差点误判的点;既有jstat、jmap、MAT这些常用命令和工具的真实使用场景,也有容器环境下JVM内存判断最容易踩的坑。适合正在负责Java服务、尤其是刚把应用放进Docker里跑的人看,也适合遇到“内存只涨不降、重启就好”类问题但不知道从哪下手的同学。
1. 内存异常的第一现场:从一条报警和一次OOM开始
1.1 先确认是泄露还是不够用:三条命令定基调
很多人在听到“内存泄露”四个字后,第一反应就是直接上工具分析。我劝你先冷静两分钟,用三条命令把局面定个调:到底是内存泄露,还是内存不够用,又或者是容器被系统层面上的内存压力打崩了。这三者的处理方式完全不一样。
free -h dmesg | grep -i "out of memory" | tail top -o %MEM第一条看系统剩余内存。注意used和cached的区别,如果系统可用内存还很宽裕,但某个进程的RSS在疯狂上涨,这大概率是进程级的泄露问题。第二条看内核日志里有没有OOM Killer留下的痕迹,如果你看到“Killed process”之类的记录,那就说明进程是被系统或者cgroup限制给“灭口”的,这时候第一优先级的任务不是分析谁占了多少堆,而是搞清楚为什么内存上限设得这么紧。第三条按内存占用排序,直接看有哪些进程在吃内存。即使你的服务跑在容器里,这三条命令在宿主机层面依然有效;如果宿主机是Windows加Docker Desktop的WSL2后端,也可以在PowerShell里敲wsl -d docker-desktop cat /var/log/kern.log绕一下,但很多情况下直接在容器里看free和宿主机的dmesg已经能确定大方向。
有一条经验我每次都会提醒自己:如果进程内存涨上去之后,过一阵子自己能降回来,那可能只是CPU缓存、JIT编译后的代码空间、或者某些排序场景里的临时数组,不一定是泄露。那种“稳步上涨、从来不回头”的曲线才真正值得警惕。
1.2 服务还在涨,但不知道涨在哪:从容器到进程的缩桶法
确认了进程级别占用在持续上涨之后,下一步要把范围从“容器”缩小到“进程”,再到“内存区域”。我习惯把这个过程叫做缩桶法,因为排查对象就像一组套着放的桶:宿主内存是最外面的桶,容器是中间的桶,Java进程是最里面的桶,堆、元空间、堆外又是更小的桶。哪个桶漏水,就钻到哪个桶里面去看。
docker stats <容器名> --no-stream top -b -n 1 | grep java grep VmRSS /proc/<PID>/statusdocker stats看的是容器整体内存,top确认具体进程,/proc/PID/status里的VmRSS能告诉你进程实际驻留物理内存大小。VmSwap也值得看一眼,如果Swap非常大,说明内存压力已经大到系统开始换页了,这往往是灾难前的信号。
这一步失败率最高的地方在于:很多人只看容器内存就觉得是JVM堆太大,结果把Xmx调小,服务反而更频繁地Full GC。因为进程的RSS是堆、堆外、元空间、JIT代码缓存、线程栈、GC自身开销加在一起的总和,单看堆大小永远解释不了全部现象。缩桶法的意义就是把“整个进程在吃内存”拆成“具体哪块区域在吃内存”,方向对了,后面才不白费功夫。
2. JVM场景下的内存分布:谁在用内存、谁会泄露
2.1 堆、元空间、堆外内存的“出血点”
JVM的内存结构网上有无数张图,但排查泄露时最需要记住的是三条通道:堆内、元空间、堆外。堆里放的是对象实例,GC能管的区域就在这;元空间放类元信息,跟加载的类数量有关;堆外则是DirectBuffer、JIT代码缓存、线程栈之类的地方,GC基本收不动。
你可以把堆看成小区的共享车库,GC是保洁员,如果有人长期占位,保洁员会把它清走;但堆外更像自家的地下室,保洁员不会进来,你忘关灯它就一直亮着。很多线上内存事故真正的出血点并不在堆内,而在被忽略的堆外。典型例子是Netty框架,它用DirectByteBuffer做网络读写缓冲,一旦ByteBuf没有正确release,堆外内存就会被一点一点吃光,表现出来就是进程RSS涨得吓人,但你dump堆文件却什么都看不到。
判断方向的方法其实很快:如果进程RSS猛涨但老年代占用不高,优先怀疑堆外;如果老年代也一路向上,那堆里一定有强引用链没断。这个方向用jstat的gcutil模式很快就能看出来,后面细讲。
2.2 哪些代码最容易把内存“越堆越大”
| 高发场景 | 泄露原因 | 典型症状 |
|---|---|---|
| 静态Map/List缓存 | 只写不删,引用被静态根持有 | 老年代持续上升,GC后不回落 |
| ThreadLocal未remove | 线程池线程存留,value被强引用 | 个别线程对象Retained Heap很大 |
| Netty ByteBuf未release | DirectBuffer堆外堆积 | 进程RSS涨,堆dump看不到大对象 |
| 类加载器困在引用链上 | 自定义ClassLoader未释放 | Metaspace逐步上涨 |
| 连接、流、Channel未关闭 | 底层句柄累积 | 内存和句柄同时上涨 |
看到“静态集合”三个字,很多人会觉得是那种显眼的HashMap缓存。实际我遇到的最多反而是ThreadLocal,它藏得很深,直到你看Dominator Tree才会露出来。还有一个容易被忽略的:定时任务里不断new新对象往集合里塞,任务本身不结束,集合就永远在增长。很多所谓的“缓存”只是只增不减的容器,这就是最常见的根。
private static final ConcurrentHashMap<String, Session> SESSION_CACHE = new ConcurrentHashMap<>();这种代码一旦上线,如果put的时机没有任何上限和清理策略,老年代占用就一定只涨不降。类似的还有日志MDC、请求上下文Context、分页查询缓存等等。所以排查之前,先看一遍代码里有没有这种“永动集合”,能省很多冤枉路。
3. 用jstat和jmap把泄露对象“揪”出来:一次完整实操
3.1 jstat观察GC曲线:先判断是不是GC兜不住
拿到Java进程的PID之后,先启动jstat采样。假设PID是12345,最常用的就是:
jps -l jstat -gcutil 12345 1000 10-gcutil输出的是各区域已使用空间占分配空间的百分比,还有GC次数和累计时间。第一列到最后一列分别对应S0、S1、E、O、M、CCS、YGC、YGCT、FGC、FGCT。你应该重点看O(老年代)和FGC。
还有一个同样的反直觉知识点:FGC频繁不代表堆不够。GC在堆异常增长的情况下会不断尝试回收,每次回收一点又立刻被新对象填满,表现在FGCT上就是平均耗时越来越长。这个信号配合老年代占用曲线看,非常有价值。如果FGC很少但RSS还在涨,方向就转到堆外和元空间去排查。
3.2 jmap生成堆转储:live与非live的取舍
堆曲线确认异常之后,下一步就是留现场——抓堆转储文件。
jmap -dump:live,format=b,file=/tmp/heap.hprof 12345注意live这个参数:它会先触发一次Full GC再dump,目的是把可回收对象清掉,只留下活对象。好处是文件小、分析时干扰少;坏处是会暂停业务线程,线上繁忙时段可能造成明显卡顿。如果你想看真实瞬时的内存占用,不加live参数也行。不过现在高版本JDK更推荐用jcmd代替jmap:
jcmd 12345 GC.heap_dump /tmp/heap.hprof无论用哪个,你都得对dump文件大小有个理性预期。之前我那个RSS到了1.8G的进程,dump下来只有不到400MB,这很正常,因为RSS里还包含不会进入堆的元空间和堆外。dump文件偏小不代表没抓到问题,它只代表堆里“一眼看上去”没有特别夸张的大对象。
还有一点要牢记:抓dump务必避开业务高峰。如果服务确实停不下来,宁可多观察一段时间也不要贸然执行,因为一次面向生产环境的强制Full GC,可能会引发雪崩。
3.3 MAT主导堆转储分析:Dominator Tree才是破案关键
dump拿到手之后就是传统艺能MAT分析了。Eclipse MAT打开hprof,等解析完成后,你不要一上来就盯着自动生成的Leak Suspects报表。那个报表有时候会给出很泛的方向,什么“One instance of java.lang.Thread”之类,看起来像废话。真正可靠的是直接看Dominator Tree。
我整理了一套固定操作流程:
- 切到Dominator Tree视图,按Retained Heap降序排列。
- 找那些自身占用不大,但Retained Heap特别大的对象。
- 右键选择Path to GC Roots,选择exclude weak/soft references,跳过弱引用和软引用,看谁还在强引用它。
- 顺着引用链一路回到根。
Retained Heap这个概念是理解内存泄露的关键。简单说,如果我把这个对象连同它内部引用的一切全部扔掉,GC能释放的内存总和就是它的Retained Heap。我打个比方:一个钥匙串挂着十几把车钥匙,钥匙串本身的Shallow Heap很小,但它Retained的却是十几辆车。所以排查时盯Retained Heap比盯Shallow Heap有效得多。
Leak Suspects可以当辅助看,但主力一定是Dominator Tree。我见过太多案例如同一个模子刻出来的:一个线程对象Retained Heap达到几百MB,展开一看是ThreadLocalMap,Map里躺着一个巨大的对象列表。这个时候整个故事基本就能讲圆了。
4. 一次亲手踩过的ThreadLocal泄露事故:从容器到宿主机内存
4.1 现象:RSS 1.8G,dump出来却只有几十个对象
讲一个我当时遇到的真实案例。服务跑在Docker里,两天多一点,RSS从800M爬到1.8G。jstat看到老年代占用不低,FGC次数持续增加,但FGC结束后内存并没有像预期那样明显回撤。jmap dump下来不到300MB,MAT打开Histogram,全是几十KB的小对象,没有明显的“巨无霸”。
那段时间我盯着Histogram看了半天,心里一度怀疑是堆外内存,顺着Netty和日志缓冲查了一通,还查了JIT代码缓存,都没找到大头。直到我切到Dominator Tree,按线程对象展开,才看到某个名为pool-2-thread-3的线程,Retained Heap达到了上百MB。为什么Histogram里看到的都是小对象?因为大量小对象都被同一个线程的ThreadLocalMap给“汇总”了,从单类视图看当然不显眼。排查内存泄露,不能只看单个对象大小,要看整棵保留树,这个经验我现在每次都会和同事强调。
4.2 排查链路:jstat → jmap → MAT → ThreadLocalMap中的残留引用
完整链路重新过一遍:
- 第1步:jstat -gcutil采样,老年代百分比呈阶梯式上升。
- 第2步:jcmd触发heap_dump,留现场。
- 第3步:MAT打开,Dominator Tree按Retained Heap排序,发现一个线程对象异常大。
- 第4步:展开线程对象内部结构:ThreadLocalMap -> Entry[],其中一条Entry的value是一个ArrayList,里面存着历史请求的上下文对象。
- 第5步:逆推业务代码,找到ThreadLocal.set的位置,确认没有remove。
看到ThreadLocalMap关键字基本就能实锤了。这里解释一下ThreadLocal为什么容易泄露:ThreadLocalMap里的key是WeakReference,也就是弱引用,所以key本身容易被回收;但value是强引用,只要线程还活着,value这条链就永远被揣在兜里。线程池里的线程生命周期很长,GC翻一万次也翻不走这条引用链。
最坑的是,有时候你看到的value未必是业务对象本身,而是一堆char[]、byte[]或者JSON序列化中间产物,非常迷惑。这说明业务对象在写入ThreadLocal之前已经被层层转换,最后留下的全是“尸体”。
4.3 根因与修复:ThreadLocal没有remove
修复其实特别简单,问题就出在业务代码里用了ThreadLocal做上下文缓存,但请求结束时没有清理。线程池线程本来就被复用,请求处理完了,ThreadLocal变量还在线程里躺着,下个请求进来又重新set,老value被覆盖,但那些覆盖对象里引用的大数组、大集合并不会立刻被清除,它们构成了泄露的主要体积。
错误写法:
private static final ThreadLocal<UserContext> CTX = new ThreadLocal<>(); // 请求处理中 CTX.set(context); // 业务逻辑... // 结束,但没有remove正确写法:
try { CTX.set(context); // 业务逻辑 } finally { CTX.remove(); }ThreadLocal的remove必须放在finally里,不是放在业务逻辑末尾。因为一旦抛异常,finally之前的清理代码根本不执行。框架层面如果用了类似MDC的机制,要看框架是否负责清理,特别是异步线程池场景,框架通常不会帮你收尾。
这次修复上线后,我持续观察了三天,老年代曲线终于回落并保持平稳,FGC次数也不再往上蹿。整个排查过程花了一个周末,根因修复只花了十分钟。
5. 容器与宿主机视角:为什么Docker里的内存曲线更诡异
5.1 cgroup内存限制与JVM不感知的经典矛盾
把Java服务跑进Docker之后,内存问题又多了一层迷惑性:JVM不一定能正确看到容器限制。容器限制靠cgroup实现,但老版本JVM或者没开启UseContainerSupport的情况下,JVM是根据宿主机物理内存来计算默认堆大小的。在高内存宿主机上跑小容器,JVM以为自己内存很多,给了很大的-Xmx,结果内存刚涨过容器上限,进程就被内核杀了,日志里是OOMKilled。
这种问题最典型的表象是:容器内存到不了宿主机上限,但进程无端消失。这时分析方向根本不是代码泄露,而是JVM启动参数没跟上容器限制。
Windows上Docker Desktop走的是WSL2,内存统计更隐晦。WSL2有自己的一套Linux虚拟机,虚拟机内部还有页面缓存逻辑,所以你在容器里free看到的used和Windows任务管理器里的数字往往对不上号。排查时千万别死磕数字对比,重点抓趋势:某条曲线是不是一直在单向上涨。
5.2 让JVM“看到”容器限制:两条关键启动参数
JDK 8u191及以上版本,以及JDK9以后,都可以用百分比参数来让JVM感知容器限制:
-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0配合完整参数段:
JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -XX:MaxMetaspaceSize=256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumps"UseContainerSupport在新版本里默认开启,然后在cgroup限制下,JVM会按容器内存而不是宿主机内存来估算堆上限。MaxRAMPercentage设置成75%是我个人比较常用的值,留一部分给堆外、元空间、线程栈和系统缓存。如果你的项目里已经有显式的-Xms和-Xmx,我建议统一改成百分比方式,避免两套逻辑互相打架。之前我那个服务补上这两个参数之后,堆曲线稳定了不少,不会再一启动就按宿主机的内存规模去精心规划堆空间。
5.3 从宿主机的角度复核:free、dmesg、以及RSS快照脚本
排查期间我还养成了一个很笨但很有效的习惯:把进程RSS和GC信息每分钟记录一次,落盘去对比。你觉得自己记住了曲线?真看日志才能判断是不是持续上升,记忆完全靠不住。
我写了一个简单的快照脚本,丢给Linux服务器直接跑:
#!/bin/bash PID=12345 while true; do echo "$(date +%F_%T) $(grep VmRSS /proc/$PID/status | awk '{print $2}')" >> /tmp/rss.log jstat -gcutil $PID 1 1 | tail -1 >> /tmp/gc.log sleep 60 done跨天之后把两个日志拿出来看,就能看到老年代占用和RSS是否存在不可逆的斜率。如果服务器环境不允许多装监控组件,这组朴素的命令就是最低成本的监控。
6. 平时就该养的排查习惯:监控、指令与兜底预案
6.1 日常监控指标与阈值经验
与其每次等内存报警再去现场抓包,不如日常就把指标盯住。我常用的监控项和参考阈值整理如下:
| 指标 | 常用命令 | 经验阈值 |
|---|---|---|
| 老年代使用率 | jstat -gcutil | 持续稳定高于80%要警惕 |
| Full GC次数 | jstat -gcutil | 短时间内FGC连续增长 |
| FGC平均耗时 | jstat -gcutil的FGCT / FGC | 平均一次超过1秒,要有动作 |
| 进程RSS | grep VmRSS /proc/PID/status | 持续上升且不回落,跟踪48小时 |
| Metaspace占用 | jstat -gcutil的M | 持续增长优先查ClassLoader |
阈值只是参考,关键要看趋势。内存问题从来不是“今天有没有爆”,而是“按这个斜率下去,什么时候必然爆”。年轻代GC频繁还能忍,老年代占用一直不回头,就必须启动排查流程了。
6.2 Linux系统排查指令清单
长期排查下来,真正用得最多的Linux命令其实是下面这些:
| 场景 | 命令 | 说明 |
|---|---|---|
| 查看系统内存 | free -h | 关注available |
| 进程内存排序 | top -o %MEM | 快速找TOP进程 |
| 瞬时内存变化 | vmstat 1 5 | 看swpd和free变化 |
| 按进程内存统计 | pidstat -r 1 | 更细的进程维度 |
| 内核OOM日志 | dmesg | grep 'out of memory|killed process' |
| 进程内存详情 | cat /proc/PID/status | VmRSS / VmSwap |
| JVM堆统计 | jstat -gcutil PID | 持续观察 |
| JVM堆转储 | jcmd PID GC.heap_dump | 优先于jmap |
| 在线诊断 | arthas dashboard/memory | 不重启可看实时内存 |
不需要把这些命令全部背下来,记住能“看趋势”和“留现场”的就够了。dmesg看内核级事件,free看系统可用量,/proc/PID/status看进程RSS,jstat看JVM内部,这一套组合基本覆盖了90%的排查场景。
6.3 兜底预案:重启确实有效,但要用对方式
内存泄露排查里,重启永远是最快的“临时止血”,但也是最容易毁现场的操作。我的做法是:重启前先强制留一次dump和GC日志快照,把当时的jstat输出保存下来;然后重启服务,观察内存爬升速度。如果重启后几小时又回到高位,那就不用怀疑了,必然是结构性泄露,和“一次性加载”无关。
确保启动参数里有这两个:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dumpsOOM时自动留dump,能省去很多“事发时没现场”的痛苦。我后来还习惯给每个JVM容器固定命名,保证dump文件不会存到容器临时层里随容器重建一起丢掉。
说个微感受:在内存泄露面前,最怕的不是复杂,是“看一眼觉得没问题”。我第一次排查花了整整一个周末,后来把所有步骤固化成模板,半小时就能锁定嫌疑区间。希望这篇也能帮你在面对那些一路上涨的内存曲线时,少走几条弯路。