ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

内存泄露排查完整指南:从RSS指标到Java/C++/容器实战

内存泄露排查完整指南:从RSS指标到Java/C++/容器实战 内存泄露可能是我见过的“看起来最像玄学”的一类问题服务明明没崩可用内存却在几个小时后被悄悄吃干净重启之后一切恢复正常跑个半天又开始喘top里的RES只涨不跌一群人围着屏幕猜来猜去谁也说不准锅到底在哪。我这些年排查过Java服务、C组件、容器环境甚至Windows上的桌面应用内存问题前前后后碰了几十次翻过的车不少沉淀下来的方法论和价值判断倒是越来越清晰。这篇就把我做内存泄露排查时最常用的思路、工具组合和踩坑实录整理出来给正在跟内存较劲的你一个可直接上手的参考。这篇文章适合几类读者刚接触后端性能排查的开发者想搞明白“RSS涨了到底是不是泄露”的运维/SRE以及那些线上服务经常莫名奇妙被OOM干掉但没人说得清原因的老手。内容会从“怎么判断内存泄露”讲起带你走完指标采集、工具选型、三大运行时定位、修复验证、线上监控一整条链路最后附上我自己记下的高频翻车案例。你不需要一次性记住所有命令但思路框架一定值得收藏。1. 先别急着下结论什么才算内存泄露1.1 判断泄露前要认准的三个特征很多人一看到内存占用高张口就是“有泄露了”但真正的内存泄露有一条非常朴素的定义程序申请了内存却因为逻辑错误没有释放导致系统可用内存持续被蚕食最终引发卡顿、OOM甚至进程被杀死。翻译成人话就是“借了钱不还而且一直借”不是“瞬间花了一大笔”。判断一个现象是不是内存泄露我一般认准三个特征持续性内存占用不是瞬间的抖动而是随着时间单调上升采样间隔拉长后趋势依旧成立。重启后恢复进程重启后内存回落明显说明“欠债”发生在进程内部如果重启后内存仍然高那可能是机器级问题或其他进程在作祟。与负载相关压测或业务高峰时增长加快空闲时增长放缓。比如某个接口每次调用都会往全局集合里塞对象又不清理那么QPS越高增长斜率越陡。必须强调的是这三点不是绝对判据。有的泄露只发生在特定代码路径业务不触发它就看不出来有的增长不是线性的而是阶梯式跳涨还有的进程有内存回收机制涨到某个阈值后触发GC或缓存淘汰看起来像是“到顶了”但本质上还是在持续制造垃圾。所以别急着拍板先用趋势数据熬一阵子再说。1.2 一眼识别“假泄露”page cache、预分配与平台期我见过最多“假泄露”现场都是被free命令的buff/cache列吓到的。Linux内核会把空闲物理内存拿去当page cache用来加速文件读写这是主动利用而不是故障。你看到free显示used不多、cache很高机器可能运行得好好的。正确的做法是看available列——这是内核估算的、在不触发swap的情况下还能分给新进程的内存它才是“真的还有多少内存”的判断标准。另一种假象是“预分配”。Java进程如果设置了-Xms等于-Xmx启动时就会把堆内存整块圈走RSS一开始就是几个G这不叫泄露叫“提前圈地”。C服务里常见的内存池、连接池也一样它们只是把资源备好不是泄漏。还有“平台期”。应用启动时会预热缓存、加载配置、初始化数据内存涨到某个水位后稳定下来这是正常曲线。真正的泄露是“涨上去就回不来且没有停止的迹象”。所以我的习惯是判断是否泄露永远不要看某一个瞬间的快照要看内存趋势以及内存回不回收得动。2. 排查前的准备先选对工具再动手抓数据2.1 内存指标的坑RSS、VIRT、PSS 哪个才值得盯进入实操前先把指标理清楚否则后面全是在瞎猜。最常用的几个内存指标含义差异很大VIRT / VSZ进程虚拟地址空间大小。它包含了可能从未被触碰的内存映射比如JVM保留的一大片地址、mmap的文件等。VIRT几十G但RES只有几百M太正常了这个指标最不适合判断泄露。RSS / RES进程真实占用的物理内存页总和。它是最常看的指标但有个大坑多个进程共享同一段动态库时这段内存会被每个进程的RSS都算一遍导致重复计算。PSS按比例分摊共享内存后的“实际占用”。smem工具能算出PSS在多进程服务里比RSS更公平。swap / VmSwap已经被换到交换分区的物理页排查时也要留意。还有一个容易忽略的内核层指标free里的used包含了内核slab内存。如果用户态进程都不高但系统可用内存持续下降问题很可能在内核态比如slab缓存异常增长。这一层很多人不熟悉后面我会单独讲。2.2 从系统级到进程级的观测工具组合我不建议上来就用高级工具而是先从系统级到进程级逐层缩小范围。常用的组合如下层级工具/文件作用系统总览free -h、cat /proc/meminfo看MemTotal、MemAvailable、Buffers、Cached、Slab系统趋势vmstat 1、sar -r观察used/free/swap读写变化进程排行top -o %MEM、htop按物理内存排序找嫌疑进程进程详情ps aux --sort-rss、pidstat -r输出RSS的采样序列进程细分/proc/PID/status、/proc/PID/smaps_rollup看VmRSS、RssAnon、RssFile、RssShmem内核内存slabtop、cat /proc/slabinfo检查内核对象缓存是否异常增长动态追踪perf、bcc工具集、strace采样分配热点或系统调用这里提醒一句strace别在生产环境随便挂着抓内存相关的系统调用它的开销很大会把你服务的性能拖垮。要抓就抓短时间的采样或者用perf这类开销更小的动态追踪手段。2.3 动手采集一份“干净的基线数据”看到可疑现象后第一件事不是去翻代码而是把“现场数据”留下的。数据不会骗人而且对比趋势是你后续说服自己、说服同事的最有力证据。采集流程我一般这样走先看整机水位free -h记录available。再看进程排行top -o %MEM把排行前10的PID、RSS、CPU记下来。锁定嫌疑进程后用循环采样记录它的RSS趋势for i in {1..60}; do date %Y-%m-%d %H:%M:%S ps -o pid,rss,vsz,comm -p PID sleep 30 done rss_trend.log如果机器上装了sysstat更推荐用pidstatpidstat -r -p PID 30 60这条命令会每30秒采样一次采样60轮输出进程RSS的变化序列。重点是要留两组数据平稳运行一段时间的基线和压测后/高峰后的数据。没有基线你就不知道当前的高水位是“正常水位”还是“异常水位”。这一步看着笨却是整个排查中最值得花时间的环节。3. 通用排查路径从现象定位到分配热点3.1 第一步锁定嫌疑进程别让视线停留在“内存占用高”内存占用高不代表泄露但一定代表某个“受益者”。第一步是把受益者找出来。用top -o %MEM排序通常会看到某个进程独占鳌头但别急着把责任推给它——先在/proc/PID/stat里看进程的启动时间第22个字段单位是jiffies确认它是不是一个“刚重启不久的新进程”。如果一个进程启动才10分钟却已经吃了5G内存这大概率是启动初始化和预热不是泄露反之一个进程跑了三天RSS每天涨一点那才是重点怀疑对象。另外有个经验如果整机的可用内存持续下降但进程级RSS却没有明显增长者问题可能不在用户态进程而在内核slab或者共享内存。这时候要看free -h里used的构成再用slabtop排序看看是不是某个内核缓存异常膨胀。3.2 第二步判断内存类型定位是用户态还是内核态锁定进程后第二步是把“它的内存”拆开看。Linux的/proc/PID/status是一个宝藏cat /proc/PID/status | grep -E VmRSS|VmSwap|VmPTE|VmSize grep -E RssAnon|RssFile|RssShmem /proc/PID/smaps_rollupRssAnon是进程自己分配的匿名内存比如堆上的对象、栈、私有数据。匿名内存持续增长通常就是用户态泄露。RssFile是文件映射占用的内存比如映射了配置文件、共享库也可能是mmap了文件却没有munmap。如果RssFile一路涨要检查是不是有文件映射没释放。RssShmem是共享内存也可能是Tmpfs。多进程架构下的共享内存每个进程都会计入一部分需要结合PSS看。如果用户态进程都正常内存还是持续下降那就进入内核态排查。cat /proc/meminfo里的关键是SReclaimable可回收slab和SUnreclaim不可回收slab。不可回收部分持续增长说明有内核对象没释放常见的是dentry、inode缓存异常或者某个内核模块在泄漏。这时候slabtop -s c按缓存大小排序找出增长最快的缓存名就能顺藤摸瓜。3.3 第三步用分配热点和核验手段坐实泄露点找到内存增长的类型后第三步要落到底层的“分配热点”。这一步根据语言运行时手段不同但思路是相通的如果能拿到内存是怎么被分配的调用栈这个问题就破案了。拿C/C服务举例生产环境直接上Valgrind不现实但可以用jemalloc的prof功能做低开销采样MALLOC_CONFprof:true,prof_prefix:/tmp/jeprof,lg_prof_sample:17 \ LD_PRELOADlibjemalloc.so.2 ./your_app跑一段时间后目录下会生成jeprof.xxx.heap文件再用jeprof --show_bytes ./your_app jeprof.xxx.heap分析能直接看到哪些调用栈分配的内存最多且不释放。这个手法我实测过几次对定位C泄漏非常有效而且采样开销在可接受范围内。如果手头有动态追踪工具比如bcc里的mallocstacks也可以直接对malloc做栈采样但要注意它依赖调试符号和较新的内核版本。实在没有工具退而求其次的办法是“二分法”把服务的一部分功能模块临时关掉观察内存增长速度是否下降。虽然笨但很多时候是最快的破案方式。我一直强调“假设驱动”别漫无目的地翻代码先通过数据缩小范围再对焦代码效率会高得多。4. 按语言运行时拆解Java、C/C、Node.js 的不同排查姿势4.1 Javajmap dump MAT 分析主导对象Java的内存泄露和C不同它很少是“malloc了没free”而是“对象明明没用了却还被引用着GC回收不掉”。常见元凶有静态集合只加不删、ThreadLocal没清理、数据库连接/Statement没关闭、类加载器泄漏导致Metaspace上涨。排查Java内存问题我最常用的路线是先看GC情况jstat -gcutil PID 1000。如果老年代O区持续增长且Full GC后依然下不来大概率有问题。生成堆dumpjmap -dump:live,formatb,fileheap.bin PID用Eclipse MAT打开dump看Histogram里Retained Heap最大的对象再用Path to GC Roots查引用链找为什么没被回收。这里要特别提醒生产环境jmap -dump:live会触发一次Full GC对在线服务有影响。所以能错峰就错峰并且一定要提前给JVM加上-XX:HeapDumpOnOutOfMemoryError参数这样万一OOMJVM会自动把现场dump下来这是最宝贵的“案发现场”。我遇到最多的一种Java“慢泄露”是ThreadLocal在线程池里没做remove()。线程池里的线程是复用的上一次请求放进去的对象引用一直在线程上挂着积少成多堆就被撑满。这种情况MAT里能清楚看到一条链Thread - ThreadLocalMap - Entry.value - 大对象。4.2 C/CValgrind 慢速定位与释放路径审计C/C的内存泄露就很直白malloc/new了没free/delete或者智能指针循环引用。线上不好排查但离线环境有一套很成熟的做法。最常用的是Valgrind的两件套memcheck报告内存错误和“definitely lost”的块。massif记录堆内存随时间的变化曲线分析哪个调用点分配的内存最高。一条典型的massif排查命令valgrind --toolmassif --heapyes --massif-out-filems.out ./your_app ms_print ms.out | head -100ms_print会输出一段时间内堆增长的快照并在顶部列出贡献最大的分配栈。不过Valgrind会让程序慢5到20倍只能用来跑测试场景或小流量别指望挂到生产上。想要更轻量可以给关键路径加上“分配计数”埋点统计malloc次数减去free次数定期打印。如果这个net count只增不减就说明有路径只分配不释放。这也是早期定位C泄漏的土办法在大型项目里特别实用。另外再提一个C特有的坑循环引用。两个shared_ptr互相持有对方会让引用计数归不了零内存就永远释放不了。排查时重点看那些互相引用的对象图该用weak_ptr的地方别犹豫。4.3 Node.js/脚本型heap snapshot 与引用链追踪Node.js这类带GC的语言内存泄露在用户态的表现和Java很像——“对象没被GC因为还有引用”。排查Node问题我一般直接用V8的--inspect能力。带--heap-prof参数启动应用可以按固定间隔自动抓取堆快照node --heap-prof --heap-prof-interval 1000 app.js跑一段时间后文件目录下会生成.heapprofile文件。把它拖到Chrome的chrome://inspect里打开能非常直观地看哪些对象占用的内存一直在涨以及它们的保留路径retaining path。常见的Node内存问题我见过不少闭包不小心捕获了大对象导致外层作用域长时间存活。setInterval创建后没清理回调里不断引用新数据。全局变量缓存了请求上下文量一大就爆炸。事件监听器只加不移除每次请求都留一个监听器。思路和Java基本一致先看总趋势再通过快照对比缩小到某一类对象最后沿引用链找到那个不该存在的“根”。5. 容器和 Windows 桌面环境的另类“泄露”5.1 容器内存上涨是先排查 cgroup 统计还是进程 RSS现代服务大多跑在容器里容器里的内存判断和一个裸机上直接看进程不太一样。cgroup v2的统计口径和RSS并不完全一致典型情况是应用进程RSS不高但容器sacrificed的内存统计一路涨最后还是OOM。原因通常是page cache容器里进程写文件/读文件内核会把文件页算进这个cgroup的memory.current而drop_caches或正常回收又迟迟没发生。排查时先看cgroup统计cat /sys/fs/cgroup/memory.current cat /sys/fs/cgroup/memory.stat如果anon不高但file很高说明page cache是主要来源。这种“伪泄露”的解法不是改代码而是调整业务的缓存策略、减少不必要的文件读写或者评估是否需要手动触发回收。如果anon持续上涨那才是应用自己的问题。另一个容器场景常被吐槽的坑是Java。JVM如果不感知容器限制默认按宿主机的内存来算堆大小很容易把容器内存吃爆。务必加上-XX:UseContainerSupport -XX:MaxRAMPercentage75这类参数让堆大小跟着容器配额走而不是跟着整台机器走。5.2 WSL2 与 vmmem 高内存最大的“假泄露”现场如果你在用Windows开发机打开任务管理器大概率见过一个叫vmmem的进程内存占用好几个G电脑卡到鼠标都飘。这几乎成了“Windows内存泄露”传说的最大来源但真相通常不是泄露而是WSL2的缓存策略。WSL2本质是一个轻量虚拟机它会把Linux的page cache策略带到Windows这一层物理内存多的时候尽量多占用用来加速文件I/O。所以看到vmmem吃内存先别急着怀疑泄露按照Linux的内存判断标准看一遍在WSL里面执行free -h看available是否充足。如果available很大那只是缓存不是真的不够用。如果确认可用内存紧张再考虑限制WSL2的内存使用。在Windows用户目录下创建.wslconfig[wsl2] memory4GB swap2GB保存后执行wsl --shutdown重启WSL再看任务管理器vmmem占用会明显下降。这跟真正的代码泄露无关但对开发机来说同样需要一套“排查内存”的方法论而且能直接解决电脑卡顿问题。6. 修复与验证怎么确认问题真的解决了6.1 修复前后对比压测的正确姿势很多人修完代码重启服务看到内存降了就宣布“搞定”。但内存泄露往往是慢性的重启本身就会让内存回落所以重启后短时间内的“正常”根本不能证明问题解决了。我的验证方法是做“对照实验”修复前和修复后在同一台机器、同一个圧测脚本、同样的负载模型、同样的时长下各跑一轮看三组数据进程RSS的趋势曲线是否从“单调上升”变成“平台期”或“轻微波动”。Java的GC次数和Full GC间隔是否恢复正常。容器的memory.current是否能在压测结束后回落到接近基线。举个例子我之前修过一个Java缓存导致的内存增长修复前60分钟压测RSS从1.2G涨到2.4G斜率稳定修复后同样的60分钟RSS先涨到1.5G然后稳定压测停止后回落到1.2G附近。这种“能回落”才是修复成功的标志。另外修复后的观测时间一定要足够长。有的泄露是每小时只漏几十MB跑一天才能看出来你只压20分钟很容易得出“已修复”的错误结论。6.2 线上预警与监控怎么配置经历过几次内存泄露事故后我最大的心得是内存问题不是靠人工盯top盯出来的而是靠监控和预警兜底。监控指标建议至少覆盖三层指标采集源预警建议系统可用内存node_memory_MemAvailable_bytes低于内存总量20%时warning低于10%时critical进程RSSprocess_resident_memory_bytes持续上涨超过基线30%触发warning容器内存cgroupmemory.current接近memory.max的80%触发warningOOM事件memory.events的oom计数oom计数增加立即告警更高级的做法是加一层“趋势告警”不只是看绝对值而是对最近30分钟的内存采样做线性拟合如果斜率为正且持续上升就说明内存正在被系统性蚕食。很多监控系统支持简单的线性回归或者你可以自己写个定时任务把RSS序列拉出来算斜率。这个比固定阈值灵敏得多尤其适合抓那些“慢慢涨”的慢性泄露。7. 实战踩坑记录与经验备忘7.1 五个高频翻车案例分享几个我真实经历过、或者帮同行复盘时见过的典型翻车现场每个都值得引以为戒案例一压测工具自己泄露锅却让服务背了。我们当时排查一个接口的内存上涨压测脚本每秒钟创建大量对象并暂存在变量里内存全是被压测工具吃掉的。服务端的RSS反而是稳定的。后来把压测客户端的大对象缓存处理掉一切正常。所以排查前别忘了看看“考察者”自己有没有问题。案例二Java Web应用里ThreadLocal未清理。每次请求都会把用户上下文塞进ThreadLocal线程池复用线程导致上下文引用一直不释放堆内存持续缓慢上涨最终在高峰期OOM。要不是MAT把引用链指到ThreadLocalMap光靠jstat根本看不出端倪。案例三C的长连接服务少了释放分支。一个网络库在正常释放路径上做了free但在某个异常分支直接return导致每次连接异常断开都漏几十KB。线上QPS不高时看不出来一到流量高峰内存就起飞。这种“分支遗漏”型泄露靠Valgrind跑全量很难复现反而是静态代码审查效率更高。案例四容器内Java不感知配额。开发者在容器里跑Java默认配置导致JVM按宿主机CPU和内存来设置堆大小服务一启动就吃掉机器一半内存。这不是泄露但效果和泄露一样吓人。后来加上了容器感知参数问题立刻消失。案例五监控脚本内存爆炸。一个用PrometheusNode Exporter拉取指标的脚本在异常情况下不断把样本追加到一个slice里自己先OOM了。这类“监控系统把自己监控没了”的故事听起来荒诞实战里真的会碰到。7.2 排查工具箱和速查表最后把我最常用的一套排查思路整理成速查表遇到内存问题按这个顺序走基本不会跑偏阶段关键命令/工具关注点系统总览free -havailable不是buff/cache内存构成cat /proc/meminfoSReclaimable、SUnreclaim进程排行top -o %MEM增长率不是绝对值进程细分cat /proc/PID/smaps_rollupRssAnonvsRssFile内核slabslabtop -s c不可回收slab是否增长Java堆jstat -gcutil、jmap -dump老年代趋势、Path to GC RootsC/C堆valgrind --toolmassif、jemalloc prof分配栈、net countNode堆--heap-prof、Chrome DevTools保留路径、大对象容器cat /sys/fs/cgroup/memory.statanonvsfile动态追踪perf、bcc mallocstacks分配调用栈我个人在实际排查中最深的一点体会是内存泄露问题真正难的不是某个命令不会用而是面对一堆都在涨的数据时能不能冷静地先确认“涨的到底是什么”。RSS涨可能是对象没释放可能是缓存策略可能是共享内存重复计算可能是page cache没回收甚至可能是监控脚本自己在捣乱。每多学会一个区分口径就少一次白折腾。而且别指望一次到位先用最简单、最笨的记录把趋势画出来再决定要不要上重型工具——这个“先记录再猜测”的习惯帮我避开了至少一半的误判陷阱。如果你正准备排查一个内存问题我的建议是把这篇速查表存下来从第1章的三特征开始对照然后老老实实地采集趋势数据。你会发现当“它到底是不是泄露”这个问题有了答案剩下的基本就是耐心和命令执行了。
返回列表