Java服务在Docker中内存泄露排查实战:从jstat到MAT
发布时间:2026/10/3 4:15:49 作者:尧图编辑部 阅读量:1,286

那会儿我刚接手一个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未releaseDirectBuffer堆外堆积进程RSS涨堆dump看不到大对象类加载器困在引用链上自定义ClassLoader未释放Metaspace逐步上涨连接、流、Channel未关闭底层句柄累积内存和句柄同时上涨看到“静态集合”三个字很多人会觉得是那种显眼的HashMap缓存。实际我遇到的最多反而是ThreadLocal它藏得很深直到你看Dominator Tree才会露出来。还有一个容易被忽略的定时任务里不断new新对象往集合里塞任务本身不结束集合就永远在增长。很多所谓的“缓存”只是只增不减的容器这就是最常见的根。private static final ConcurrentHashMapString, 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,formatb,file/tmp/heap.hprof 12345注意live这个参数它会先触发一次Full GC再dump目的是把可回收对象清掉只留下活对象。好处是文件小、分析时干扰少坏处是会暂停业务线程线上繁忙时段可能造成明显卡顿。如果你想看真实瞬时的内存占用不加live参数也行。不过现在高版本JDK更推荐用jcmd代替jmapjcmd 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展开一看是ThreadLocalMapMap里躺着一个巨大的对象列表。这个时候整个故事基本就能讲圆了。4. 一次亲手踩过的ThreadLocal泄露事故从容器到宿主机内存4.1 现象RSS 1.8Gdump出来却只有几十个对象讲一个我当时遇到的真实案例。服务跑在Docker里两天多一点RSS从800M爬到1.8G。jstat看到老年代占用不低FGC次数持续增加但FGC结束后内存并没有像预期那样明显回撤。jmap dump下来不到300MBMAT打开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 ThreadLocalUserContext 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:MaxRAMPercentage75.0 -XX:InitialRAMPercentage50.0配合完整参数段JAVA_OPTS-XX:UseContainerSupport -XX:MaxRAMPercentage75.0 -XX:InitialRAMPercentage50.0 -XX:MaxMetaspaceSize256m -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dumpsUseContainerSupport在新版本里默认开启然后在cgroup限制下JVM会按容器内存而不是宿主机内存来估算堆上限。MaxRAMPercentage设置成75%是我个人比较常用的值留一部分给堆外、元空间、线程栈和系统缓存。如果你的项目里已经有显式的-Xms和-Xmx我建议统一改成百分比方式避免两套逻辑互相打架。之前我那个服务补上这两个参数之后堆曲线稳定了不少不会再一启动就按宿主机的内存规模去精心规划堆空间。5.3 从宿主机的角度复核free、dmesg、以及RSS快照脚本排查期间我还养成了一个很笨但很有效的习惯把进程RSS和GC信息每分钟记录一次落盘去对比。你觉得自己记住了曲线真看日志才能判断是不是持续上升记忆完全靠不住。我写了一个简单的快照脚本丢给Linux服务器直接跑#!/bin/bash PID12345 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秒要有动作进程RSSgrep 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日志dmesggrep out of memory|killed process进程内存详情cat /proc/PID/statusVmRSS / VmSwapJVM堆统计jstat -gcutil PID持续观察JVM堆转储jcmd PID GC.heap_dump优先于jmap在线诊断arthas dashboard/memory不重启可看实时内存不需要把这些命令全部背下来记住能“看趋势”和“留现场”的就够了。dmesg看内核级事件free看系统可用量/proc/PID/status看进程RSSjstat看JVM内部这一套组合基本覆盖了90%的排查场景。6.3 兜底预案重启确实有效但要用对方式内存泄露排查里重启永远是最快的“临时止血”但也是最容易毁现场的操作。我的做法是重启前先强制留一次dump和GC日志快照把当时的jstat输出保存下来然后重启服务观察内存爬升速度。如果重启后几小时又回到高位那就不用怀疑了必然是结构性泄露和“一次性加载”无关。确保启动参数里有这两个-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dumpsOOM时自动留dump能省去很多“事发时没现场”的痛苦。我后来还习惯给每个JVM容器固定命名保证dump文件不会存到容器临时层里随容器重建一起丢掉。说个微感受在内存泄露面前最怕的不是复杂是“看一眼觉得没问题”。我第一次排查花了整整一个周末后来把所有步骤固化成模板半小时就能锁定嫌疑区间。希望这篇也能帮你在面对那些一路上涨的内存曲线时少走几条弯路。