内存泄漏是最让服务端开发者头疼的问题之一。进程启动时只占几十MB内存,跑了一周之后RSS涨到好几个GB,重启后一切正常,然后又周而复始。这种情况基本可以断定存在内存泄漏,但难点在于:泄漏发生在哪里?是堆上的对象没释放,还是某个全局容器无限增长,或者是线程栈没有回收?本文介绍如何利用Linux自带的pmap工具逐步缩小泄漏范围,并结合其他工具最终定位到代码位置。

先搞清楚进程的内存布局
在用pmap分析之前,需要先了解一个进程的虚拟地址空间大概由哪些部分组成。Linux下用pmap -x pid可以看到进程的所有内存映射区间,每一行代表一段连续的虚拟地址区域,典型的输出类似下面这样:
pmap -x 12345 12345: ./my_server Address Kbytes RSS Dirty Mode Mapping 0000000000400000 916 916 0 r-x-- my_server 00000000012be000 16 16 8 rw--- my_server 00007f3a9c000000 1320 820 820 rw--- [ anon ] 00007f3aa8000000 132 4 0 r---- libc-2.31.so 00007f3aa821d000 1444 956 0 r-x-- libc-2.31.so 00007f3aac000000 132 16 16 rw--- [ anon ] 00007ffd1a9e1000 132 132 132 rw--- [ stack ] 0000000000000000 4 4 0 rw--- [ anon ] total kB 18968 7548 1120</p>
输出中的Mapping列非常关键。显示为具体文件名的行是文件映射,比如可执行文件本身和各种共享库;显示[ anon ]的是匿名映射,不与任何文件关联,通常是malloc从操作系统申请的大块内存、mmap直接分配的区域或者线程栈;显示[ heap ]的是进程堆区,小块内存分配默认走这里。RSS列表示实际驻留物理内存的大小,Dirty列表示脏页大小,分析泄漏时主要盯住RSS持续增长的匿名区间。
还有一个常见误区要提前说明:VIRT很高并不代表泄漏。虚拟地址空间占用几十GB可能是正常的,比如glibc的malloc会预保留大片地址空间。真正需要关注的是RSS,也就是实际占用的物理内存。判断泄漏的标准是:在负载平稳的前提下,RSS随时间单调增长且从不回落。
用重复采样定位泄漏区域
pmap的一次快照说明不了问题,泄漏分析的核心手法是间隔采样对比。写一个简单的脚本,每隔一段时间记录一次pmap输出,观察哪些区间在增长:
#!/bin/bash
pid=$1
while [ $# -gt 0 ] && kill -0 $pid 2>/dev/null; do
echo "===== $(date +%T) ====="
pmap -x $pid | grep -v "r-x--\|r----\|-----" | tail -n +3
sleep 60
done >> /tmp/pmap_log.txt跑上一两个小时后,对比日志中相同Mapping的Kbytes变化。不同区域增长对应不同的泄漏类型,判断逻辑如下表:
| 增长区域 | 典型原因 |
|---|---|
| [ heap ]持续增长 | 小对象泄漏,malloc分配后未free,如忘释放的字符串、链表节点 |
| 匿名映射数量增加 | 大块内存泄漏(超过128KB走mmap),或频繁mmap未munmap |
| 132KB左右的rw匿名块增多 | 线程栈泄漏,线程退出后未join导致栈未回收 |
| 某个so的rw段增长 | 该库的全局数据或静态容器在增长 |
其中线程栈泄漏非常常见又容易被忽视。每创建一个线程,pmap里就会多出一个132KB左右的可读写匿名块,如果线程退出后这块内存没有消失,多半是代码里没有调用pthread_join,或者没有设置线程分离属性PTHREAD_CREATE_DETACHED,导致线程资源滞留。如果看到匿名块以每次132KB的节奏稳定增长,基本可以按这个方向排查。
堆区增长则需要进一步区分两种情况:一种是真正的泄漏(分配后指针丢失,永远无法释放),另一种是内存池碎片(对象已释放但内存没有归还操作系统)。glibc的malloc对小内存做了缓存,free后的内存往往留在堆里不还给内核,表现为heap缓慢增长但增速逐渐放缓趋于平稳。而真泄漏的增速通常和请求量成正比,不会收敛。观察增长曲线的形态,可以先把这两种情况区分开。
结合其他工具精确到代码位置
pmap只能告诉泄漏发生在哪类内存,要定位到具体代码行,还需要动态分析工具配合。如果环境允许重现问题,valgrind是首选:
valgrind --leak-check=full --show-leak-kinds=all ./my_server
valgrind会拦截每一次malloc和free,退出时报告所有没有释放且不可达的内存块,包括分配时的调用栈。需要注意valgrind会让程序慢10到30倍,不适合直接跑生产流量,通常在测试环境用压力工具模拟请求来触发。
生产环境上不了valgrind时,可以用glibc自带的mtrace,或者更轻量的tcmalloc、jemalloc。以jemalloc为例,编译时链接-ljemalloc,运行时通过MALLOC_CONF=prof:true开启采样,之后用jeprof导出堆剖面,直接看到哪条调用路径分配的内存最多且没释放,开销只有百分之几,完全可以在线上使用。
如果怀疑是某个全局容器无限增长,比如缓存没有淘汰策略、map里的key越积越多,还可以用gdb attach到进程直接检查:gdb -p pid之后打印可疑全局变量的size,多次采样对比。再配合pmap确认增长的就是该模块对应的内存,证据链就完整了。实际排查中,建议先用pmap把泄漏归类到堆、线程栈或某个库,再选择合适的工具深入,这样比上来就盲目跑valgrind效率高得多。
最后提醒一点:内存问题修复后,要建立长期观测手段,比如通过/proc/pid/status里的VmRSS做定时采集并绘制曲线,确保泄漏真正修复,而不是因为重启周期变短而掩盖了问题。