☰
内存泄露排查完整指南:从RSS指标到Java/C++/容器实战
2026/10/2 8:46:39 网站建设 项目流程

内存泄露可能是我见过的“看起来最像玄学”的一类问题:服务明明没崩,可用内存却在几个小时后被悄悄吃干净;重启之后一切恢复正常,跑个半天又开始喘;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 动手采集一份“干净的基线数据”

看到可疑现象后,第一件事不是去翻代码,而是把“现场数据”留下的。数据不会骗人,而且对比趋势是你后续说服自己、说服同事的最有力证据。

采集流程我一般这样走:

  1. 先看整机水位:free -h,记录available。
  2. 再看进程排行:top -o %MEM,把排行前10的PID、RSS、CPU记下来。
  3. 锁定嫌疑进程后,用循环采样记录它的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,更推荐用pidstat:

pidstat -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_rollup
  • RssAnon是进程自己分配的匿名内存,比如堆上的对象、栈、私有数据。匿名内存持续增长,通常就是用户态泄露。
  • 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_CONF="prof:true,prof_prefix:/tmp/jeprof,lg_prof_sample:17" \ LD_PRELOAD=libjemalloc.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 Java:jmap dump + MAT 分析主导对象

Java的内存泄露和C++不同,它很少是“malloc了没free”,而是“对象明明没用了,却还被引用着,GC回收不掉”。常见元凶有:静态集合只加不删、ThreadLocal没清理、数据库连接/Statement没关闭、类加载器泄漏导致Metaspace上涨。

排查Java内存问题,我最常用的路线是:

  1. 先看GC情况:jstat -gcutil PID 1000。如果老年代(O区)持续增长且Full GC后依然下不来,大概率有问题。
  2. 生成堆dump:jmap -dump:live,format=b,file=heap.bin PID
  3. 用Eclipse MAT打开dump,看Histogram里Retained Heap最大的对象,再用Path to GC Roots查引用链,找为什么没被回收。

这里要特别提醒:生产环境jmap -dump:live会触发一次Full GC,对在线服务有影响。所以能错峰就错峰,并且一定要提前给JVM加上-XX:+HeapDumpOnOutOfMemoryError参数,这样万一OOM,JVM会自动把现场dump下来,这是最宝贵的“案发现场”。

我遇到最多的一种Java“慢泄露”,是ThreadLocal在线程池里没做remove()。线程池里的线程是复用的,上一次请求放进去的对象引用一直在线程上挂着,积少成多,堆就被撑满。这种情况MAT里能清楚看到一条链:Thread -> ThreadLocalMap -> Entry.value -> 大对象。

4.2 C/C++:Valgrind 慢速定位与释放路径审计

C/C++的内存泄露就很直白:malloc/new了没free/delete,或者智能指针循环引用。线上不好排查,但离线环境有一套很成熟的做法。

最常用的是Valgrind的两件套:

  • memcheck:报告内存错误和“definitely lost”的块。
  • massif:记录堆内存随时间的变化曲线,分析哪个调用点分配的内存最高。

一条典型的massif排查命令:

valgrind --tool=massif --heap=yes --massif-out-file=ms.out ./your_app ms_print ms.out | head -100

ms_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:MaxRAMPercentage=75这类参数,让堆大小跟着容器配额走,而不是跟着整台机器走。

5.2 WSL2 与 vmmem 高内存:最大的“假泄露”现场

如果你在用Windows开发机,打开任务管理器大概率见过一个叫vmmem的进程,内存占用好几个G,电脑卡到鼠标都飘。这几乎成了“Windows内存泄露”传说的最大来源,但真相通常不是泄露,而是WSL2的缓存策略。

WSL2本质是一个轻量虚拟机,它会把Linux的page cache策略带到Windows这一层,物理内存多的时候尽量多占用,用来加速文件I/O。所以看到vmmem吃内存,先别急着怀疑泄露,按照Linux的内存判断标准看一遍:

  1. 在WSL里面执行free -h,看available是否充足。如果available很大,那只是缓存,不是真的不够用。
  2. 如果确认可用内存紧张,再考虑限制WSL2的内存使用。在Windows用户目录下创建.wslconfig:
[wsl2] memory=4GB swap=2GB

保存后执行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%触发warning
OOM事件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 Roots
C/C++堆valgrind --tool=massif、jemalloc prof分配栈、net count
Node堆--heap-prof、Chrome DevTools保留路径、大对象
容器cat /sys/fs/cgroup/memory.statanonvsfile
动态追踪perf、bcc mallocstacks分配调用栈

我个人在实际排查中最深的一点体会是:内存泄露问题真正难的不是某个命令不会用,而是面对一堆都在涨的数据时,能不能冷静地先确认“涨的到底是什么”。RSS涨可能是对象没释放,可能是缓存策略,可能是共享内存重复计算,可能是page cache没回收,甚至可能是监控脚本自己在捣乱。每多学会一个区分口径,就少一次白折腾。而且别指望一次到位,先用最简单、最笨的记录把趋势画出来,再决定要不要上重型工具——这个“先记录再猜测”的习惯,帮我避开了至少一半的误判陷阱。

如果你正准备排查一个内存问题,我的建议是把这篇速查表存下来,从第1章的三特征开始对照,然后老老实实地采集趋势数据。你会发现,当“它到底是不是泄露”这个问题有了答案,剩下的基本就是耐心和命令执行了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询