服务器跑了几天,内存曲线一路向上,重启一下又好了,过几天又涨回来——这是运维最头疼的一类问题。加内存只能把崩溃时间往后推,治标不治本。真正的内存泄漏,是程序申请了内存却不释放,随着时间累积,最终吃掉整台机器,触发 OOM,把关键进程杀掉。
但内存增长不一定是泄漏,这是本文要反复强调的一点。页缓存、内存池、glibc 的 arena、短暂的流量高峰,都会让内存数字上涨,等你排查半天才发现"其实没问题"。这篇文章教你区分"正常的涨"和"泄漏的涨",把排查工具从 top、ps、/proc/PID/status、pmap、smem 一路讲到语言层的 valgrind、JDK 的 jmap/jstat、Go 的 pprof,最后给出处置思路:什么时候该重启兜底、什么时候该找开发定位代码、怎么临时限制内存争取时间。
先分清:什么是内存泄漏,什么只是"看起来涨"
内存泄漏的定义很明确:进程分配了内存,在不再需要它之后,既没有归还给操作系统,也没有归还给内存分配器,导致这块内存在进程生命周期内不可复用。表现出来是进程的物理内存占用(RSS)随时间单调增长,即使业务负载不变。
但下面这些情况,内存"涨"是正常的:
| 现象 | 是否泄漏 | 说明 |
|---|---|---|
free 里 buff/cache 很大 |
不是 | 页缓存,内核可随时回收,是好事 |
| 进程启动后 RSS 快速上升到稳定值 | 不是 | 内存池、JIT 预热、缓存预分配,正常爬坡 |
| 流量高峰时 RSS 涨,峰值过后回落 | 不是 | 正常扩容,只要回落到合理区间 |
| 业务空闲时 RSS 仍持续、单调上涨 | 是 | 典型的泄漏信号 |
| RSS 不涨但堆外内存/匿名页涨 | 是 | 可能是 native 层或 JVM 堆外泄漏 |
Slab 里 dentry/inode 异常大 |
视情况 | 可能是内核对象未释放,属内核侧问题 |
判断的关键是看趋势和关联性:把 RSS 和业务指标(QPS、连接数、并发)画在同一时间轴上。如果业务平稳而 RSS 却一路向上,且从不回落,那基本可以判定是泄漏。如果 RSS 随业务同步涨落,多半是正常的内存使用。
第一层:找到嫌疑进程
先用系统级工具锁定是哪个进程在涨。这里 ps 按 RSS 排序是第一步:
# 按物理内存占用(RSS)降序,看前 15 个
ps -eo pid,ppid,user,%mem,rss,vsz,etime,comm --sort=-rss | head -n 15
注意区分两个字段:RSS 是进程实际占用的物理内存,是判断涨跌的关键;VSZ 是虚拟地址空间,包含大量映射但未真正使用的部分,Java、Go 这类运行时的 VSZ 常常是 RSS 的好几倍,拿 VSZ 说"内存不够"是常见误判。
top 交互式排序也可以,进 top 后按 M 按内存排,按 Shift+E 切换显示单位(KB/MB/GB):
top -o %MEM
如果只是想知道某个 PID 的实时 RSS,直接读 /proc:
cat /proc/PID/status | grep -E 'VmRSS|VmSize|VmSwap|VmData|VmPeak'
# VmRSS : 实际物理内存
# VmSize : 虚拟内存
# VmSwap : 被换出到 swap 的部分
# VmPeak : 历史峰值
想知道 RSS 是不是在涨,就隔一段时间连读几次,或者用 watch、pidstat 看趋势:
# 每 2 秒采一次,连续 30 次,看 RSS 列的变化
pidstat -r -p PID 2 30
# 或者简单地记录到文件做趋势
while true; do echo "$(date +%F_%T) $(awk '/VmRSS/{print $2}' /proc/PID/status)"; sleep 60; done
第二层:拆解进程内存构成
确认是哪个进程后,要看它的内存到底花在哪。pmap 能把进程的内存映射一块块列出来:
pmap -x PID | sort -k3 -n | tail -n 20
pmap -x PID > /tmp/pmap_before.txt # 留存快照
# 隔一段时间再抓一次,做对比
pmap -x PID > /tmp/pmap_after.txt
diff /tmp/pmap_before.txt /tmp/pmap_after.txt
输出里关注 [ anon ](匿名映射,通常是堆和线程栈)和 [ heap ] 两块的增长。如果某个 [ anon ] 块在两次快照之间明显变大,就是嫌疑对象。同时留意内存映射数量,pmap -x PID | wc -l 如果持续增长,说明进程在泄漏映射(常见于反复 mmap 不 munmap)。
smem 的价值在于给出更接近真实情况的共享内存统计,适合一台机器上跑了很多相似进程(比如多个 PHP-FPM worker 或 Tomcat)的场景:
# RHEL / CentOS / Rocky
sudo dnf install -y epel-release
sudo dnf install -y smem
# Debian / Ubuntu
sudo apt update
sudo apt install -y smem
smem -t -k -P sshd
smem -r -k -t | head -n 20
smem -w -k -t # 按用户/命令分组
ps 会把共享库的内存重复计算到每个进程里,导致总和虚高。smem 用 PSS(比例集大小)来摊分共享页,得到更真实的总占用,排查"到底谁吃内存"时更准确。
第三层:语言层的专用工具
系统工具只能告诉你"内存涨了",要定位到代码,得用对应语言的工具。
C/C++:valgrind 与 AddressSanitizer
valgrind 的 memcheck 能追踪每一次内存分配和释放,找出泄漏点:
# RHEL / CentOS / Rocky
sudo dnf install -y valgrind
# Debian / Ubuntu
sudo apt update
sudo apt install -y valgrind
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./your_program
关键提醒:valgrind 会把程序拖慢几十倍,内存占用也大幅增加,绝对不能在生产环境对线上进程用。它的正确姿势是在测试环境复现问题,或者拿一份能重现的输入离线跑。
更实用的做法是编译期开启地址消毒器(AddressSanitizer, ASan),开销比 valgrind 小得多:
gcc -fsanitize=address -g -o your_program your_program.c
./your_program # 退出时会打印泄漏报告
Java:jstat 与 jmap
JVM 内存泄漏要区分堆内和堆外。先看 GC 情况:
# 每秒打印一次 GC 统计
jstat -gcutil PID 1000
# 输出列:S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
重点看老年代 O 的占用率和 FGC(Full GC 次数)。如果 O 长期居高不下、频繁 Full GC 也回收不掉,说明堆内有对象被持续引用,是典型堆内泄漏。接着把堆 dump 下来分析:
# 生成堆转储快照(会暂停应用,注意影响)
jmap -dump:live,format=b,file=/tmp/heap.hprof PID
# 看堆内存概况
jmap -heap PID
jmap -histo:live PID | head -n 30 # 按类统计对象数量,会触发一次 Full GC
拿 /tmp/heap.hprof 用 MAT(Eclipse Memory Analyzer)或 JDK 自带的 jvisualvm 打开,找主导集(dominator tree)里哪个对象吃内存最多,顺着引用链就能定位到代码。
如果是堆外泄漏,jmap 看不到,要看 Native Memory Tracking:
# 启动时加 -XX:NativeMemoryTracking=detail,然后
jcmd PID VM.native_memory summary
Go:pprof
Go 程序自带 pprof,非常方便。先确保程序导出了 pprof 端点(引入 net/http/pprof),然后抓取堆快照:
# 抓取堆内存 profile
go tool pprof http://127.0.0.1:6060/debug/pprof/heap
# 在交互界面里
(pprof) top # 看占用最高的函数
(pprof) list 函数名 # 看具体代码行
(pprof) web # 生成火焰图(需 graphviz)
Go 的 GC 比较激进,抓 heap 时默认是"inuse"视图,看不出增长就对比两次快照的差值。真正稳定泄漏通常是 goroutine 泄漏(协程卡住不退出)或全局 map 只增不减,可以用 go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine 看协程数和阻塞位置。
假泄漏:glibc 的 arena 与 malloc_trim
有一类"泄漏"冤枉了程序代码,其实是 glibc 的内存分配器行为导致的。多线程程序里,每个线程有自己的 arena(分配区),线程申请内存后释放,内存不一定还给内核,而是留在 arena 里备用。结果就是:程序逻辑上没有泄漏,但 RSS 一直不降。
判断方法:看进程的线程数。线程很多、每线程分配过内存的程序(比如某些高并发服务),RSS 容易因为 arena 累积而虚高。glibc 有个环境变量可以限制 arena 数量:
# 限制每个进程最多 2 个 arena
export MALLOC_ARENA_MAX=2
./your_program
还可以用 malloc_trim 主动把空闲内存还给系统。glibc 2.8 以上支持,通过 gdb 调用:
gdb -p PID -batch -ex 'call (int)malloc_trim(0)' -ex detach
调用后观察 RSS 是否下降。如果明显下降,说明之前是"分配器缓存"而非真泄漏。也可以在代码里定时调用 malloc_trim(0)。不过要注意:malloc_trim 在高并发下可能带来短暂的锁开销,别频繁调。
处置:重启兜底、定位根因、限制内存
排查清楚之后,怎么处理,取决于你能不能改代码、能争取多少时间。
临时兜底:重启
最简单直接的办法是定时重启进程,把泄漏的内存"倒掉"。可以用 systemd 定时器、cron,或者进程自身的重启机制:
# 用 systemd timer 每天凌晨重启服务(示例思路)
# 谨慎:重启会造成短暂不可用,确保有健康检查和负载均衡兜底
sudo systemctl restart your-service
重启只是争取时间,不是解决问题。真正要做的,是拿着 jmap/pprof/valgrind 找到的证据去推动修复。
兜底防护:用 systemd 限制内存
在修复之前,可以给进程设置内存上限,让它在越界时被及时回收,而不是拖垮整台机器:
sudo systemctl edit your-service
写入:
[Service]
MemoryMax=2G
MemoryHigh=1.5G
Restart=on-failure
MemoryHigh是软限制,超过后内核会限制其分配并优先回收,尽量压回去。MemoryMax是硬限制,超过会触发 OOM Kill 掉该进程(cgroup v2)。Restart=on-failure让它在被杀后自动拉起,配合健康检查避免脑裂。
改完 sudo systemctl daemon-reload && sudo systemctl restart your-service 生效。注意:限制内存本质是"让它早点死",只适合无状态、可快速重启的服务;有状态服务被杀可能丢数据,要慎重。
内核侧:OOM 保护
如果不想让关键进程被 OOM 杀手盯上,可以调整其 OOM 分数:
# 值越小越不容易被杀(范围 -1000 到 1000)
echo -500 | sudo tee /proc/PID/oom_score_adj
数据库、消息队列这类关键服务可以设一个负数,避免在内存紧张时被误杀。但注意,保护了它,压力就会转移到别的进程上。
一套可复用的排查流程
# 第一步:锁定嫌疑进程
ps -eo pid,user,%mem,rss,etime,comm --sort=-rss | head
# 第二步:确认趋势(业务平稳但 RSS 单调涨 => 高度可疑)
pidstat -r -p PID 5 20
# 第三步:拆解内存构成
pmap -x PID | sort -k3 -n | tail
# 或
smem -P 进程名 -k
# 第四步:按语言层深入
# Java:jstat -gcutil -> jmap -dump -> MAT
# Go:pprof heap/goroutine
# C/C++:ASan 或测试环境 valgrind
# 第五步:确认是否 glibc arena 假泄漏
gdb -p PID -batch -ex 'call (int)malloc_trim(0)' -ex detach
判断逻辑:先确认"涨"是不是真泄漏(看趋势、看业务关联),再判断是堆内还是堆外,再上对应语言工具。别一上来就对生产进程跑 jmap -dump 或 valgrind,前者会 Stop-The-World,后者会拖垮性能。
常见问题
free 显示内存用了百分之九十,是不是泄漏?
先看 available 那一列,再看 buff/cache。Linux 会把空闲内存用作页缓存,free 很小是正常且有益的。判断是否紧张要看 available 和是否触发交换(vmstat 的 si/so),不要只看 used。
进程 RSS 涨但 pmap 看不出明显增长?
可能是共享库或映射被摊分了,改用 smem 看 PSS;也可能是内存被 madvise 标记但未真正触碰。还有一种情况是短暂分配——多抓几次快照对比,单次快照没有参考价值。
用 jmap -dump 会不会把服务搞挂?
jmap -dump 会触发一次 Stop-The-World,暂停时长与堆大小相关,大堆(几十 GB)可能暂停好几秒甚至更久,高并发服务会因此超时。生产上建议:先做主从摘除流量、或在低峰期执行、或配置 -XX:+HeapDumpOnOutOfMemoryError 让它在 OOM 时自动 dump。
定时重启是不是就等于解决了泄漏?
不是。重启只是掩盖问题、争取时间。泄漏的进程重启后还会继续涨,长期看要么重启间隔越来越短,要么某次涨太快来不及重启就 OOM 了。正确做法是重启兜底的同时,把 dump 证据交给开发修复。
怎么区分是 JVM 堆内泄漏还是堆外泄漏?
先用 jstat -gcutil 看老年代和 Full GC:回收不掉是堆内,配合 jmap -histo 找对象。如果堆内正常但 RSS 仍涨,就是堆外(native、direct buffer、线程栈、JNI),用 jcmd VM.native_memory summary 或 pmap 分析。
总结
- 内存泄漏的标志是 RSS 在业务平稳时单调、不回落地增长;页缓存、内存池爬坡、流量涨落都不是泄漏,别误判。
- 自顶向下排查:
ps/top找进程,pidstat看趋势,/proc/PID/status读VmRSS,pmap/smem拆构成,再按语言上jmap/jstat、pprof、valgrind/ASan。 - 生产环境慎用重工具:
jmap -dump会暂停应用,valgrind会拖慢几十倍,务必在测试环境或低峰期操作,优先用 ASan、pprof 这类低开销方案。 - glibc 多线程 arena 会造成"假泄漏",用
MALLOC_ARENA_MAX或malloc_trim验证;确认后 RSS 会明显回落。 - 处置分三层:定时重启兜底争取时间、
MemoryMax/MemoryHigh限制防止拖垮整机、把 dump 与火焰图证据交给开发做根因修复,三者结合才是完整方案。
企业QQ咨询




