拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

深度解析Linux /proc/[pid]/smaps:进程内存分析的核心工具

深度解析Linux /proc/[pid]/smaps:进程内存分析的核心工具

作为一个常年跟Linux底层打交道的从业者,我一直觉得/proc文件系统是内核给用户态留下的后门。而/proc/[pid]/smaps这个文件,又是后门里最值得细看的那一扇。它不是像top、free那样给你一个笼统的数字,而是把进程的整个地址空间按内存映射段 (VMA) 拆开,把每个映射的物理内存占用、共享情况、匿名私有页、交换分区占用全部摊开给你看。读懂 smaps,才算真正读懂了你的进程为什么会占这么多内存。

这篇内容我打算从 smaps 的文件格式讲起,剖析每个字段的计算逻辑和实际含义,再结合我自己排查过的内存泄漏、共享库虚高、交换分区抖动这几个典型场景,把这套内存分析思路完整梳理一遍。如果你是做服务端开发、嵌入式开发、或者正在做性能调优的工程师,这篇内容可以当作一份可直接抄作业的参考手册。

1. 为什么需要smaps:从一份“诡异”的内存报告说起

在smaps出现之前,靠/proc/[pid]/statm或者ps aux里的 RSS 列来判断进程内存,经常会踩坑。RSS 只告诉你“这个进程在物理内存里驻留了多少页”,但它说不清这些页是怎么来的,也说不上这些页到底是独有的还是跟别人共享的。

1.1 先搞清楚RSS为什么“不准”

我举一个实际例子:你的服务器上开了 3 个 Java 进程,都加载同一个 glibc 或同一份 JVM 共享类库。操作系统在物理内存里只保留一份代码页,然后让 3 个进程的页表都指向这同一份物理页。这时候你去查每个进程的 RSS,会发现 3 个进程的 RSS 都把这个共享库的占用算进去了。三个进程的 RSS 加总,比服务器实际物理内存消耗高出一大截,这个“高估误差”会误导你做扩容或资源规划。

反过来还有一种情况。进程启动时申请了一大块内存,但只写入了其中一两个页面。按 RSS 的统计口径,只有真正被写入的页才会进入 RSS,所以 RSS 会远小于进程“觉得”自己占用的虚拟内存。你以为它内存用得不多,实际上它的页表可能已经膨胀得厉害,遇到内存压力时反而会成为被内核优先回收的目标。

smaps的出现,就是为了把这些模糊口径彻底拆开。它在每个 VMA 段上把内存细化成Rss、Pss、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty这几类,同时给出该段映射的文件路径,方便你判断这段内存是匿名内存还是文件映射,是被换到交换分区还是正在被写回磁盘。

1.2 smaps究竟解决了什么

从实用角度看,smaps 帮我们解决了三类问题:

  • 内存归属判断:每个 VMA 段的Pss会按共享比例均摊物理页,用它来累加进程内存,比 RSS 靠谱得多。
  • 共享库成本分析:通过Shared_Clean/Shared_Dirty和映射文件路径,你能一眼看出哪些.so或.jar占了多少物理页,方便做裁剪或合并。
  • 泄漏定位与内核页表开销评估:通过观察匿名私有内存段(路径显示为匿名,Private_Dirty持续上涨),可以快速定位泄漏点。同时,把 smaps 里KernelPageSize和MMUPageSize做对比,能判断透明大页是否生效,进而评估 TLB 压力。

到这里,你应该已经明白 smaps 不是给新手看热闹的,它是真正做内存治理的基础设施。下面我把它每一个字段的含义和计算方式拆开讲。

2. 手把手看懂smaps:每一个字段都不是白给的

/proc/[pid]/smaps的格式是一系列连续的内存映射块。每个块的结构分两部分:第一行是映射的地址范围和属性,后面若干行是统计字段。下面我用一次实际抓取的数据做例子。

2.1 映射头的完整解读

00400000-00452000 r-xp 00000000 08:01 3670243 /usr/bin/ls Size: 336 kB KernelPageSize: 4 kB MMUPageSize: 4 kB Rss: 312 kB Pss: 236 kB Shared_Clean: 312 kB Shared_Dirty: 0 kB Private_Clean: 0 kB Private_Dirty: 0 kB Referenced: 312 kB Anonymous: 0 kB LazyFree: 0 kB AnonHugePages: 0 kB ShmemPmdMapped: 0 kB Shared_Hugetlb: 0 kB Private_Hugetlb: 0 kB Swap: 0 kB SwapPss: 0 kB Locked: 0 kB THPeligible: 0 VmFlags: rd ex mr mw me dw

第一行00400000-00452000 r-xp 00000000 08:01 3670243 /usr/bin/ls,我来逐段拆解:

  • 地址范围:起始地址到结束地址,单位是字节。两个地址相减,就是这个 VMA 的虚拟内存总大小,对应下面的Size字段。
  • 权限位:r读、w写、x执行、p私有(private)、s共享(shared)。这一位决定后续字段的统计分组方式。特别要注意的是,这里的p和s跟文件系统里的“共享库”概念不完全一样,它描述的是 VMA 的映射属性,而不是文件本身的属性。
  • 偏移量:00000000,表示该 VMA 在映射文件中的起始偏移。对于匿名内存,这个值通常为 0。
  • 主设备号和次设备号:08:01,表示文件所在的块设备。如果映射不来自磁盘,会显示00:00。
  • inode:3670243。为 0 时通常表示匿名映射。
  • 文件路径:/usr/bin/ls。匿名映射(例如mmap没有指定文件描述符,或程序堆/栈)会显示为[heap]、[stack]、[vdso]等中括号标记,也有什么都不显示的纯匿名段。

2.2 核心8个字段的含义与关系

下面这些字段是排查时最常用到的一组,我逐一说明:

  • Size:该 VMA 的虚拟内存大小,等于地址范围相减。这个值跟你用mmap申请多少有关,但不代表真实占用物理页。
  • Rss:Resident Set Size,该 VMA 当前驻留在物理内存中的页总大小。它的特点是,不管你这页是被独享还是被共享,只要页表项有效并指向物理页,就算进来。
  • Pss:Proportional Set Size,按共享比例分摊后的驻留内存。假设某物理页同时被 2 个进程映射,那么每个进程的 Pss 只算半页大小。这是评估单进程实际内存成本最合适的口径。
  • Shared_Clean / Shared_Dirty:共享页里“干净”和“脏”的部分。Clean 是指物理页内容和文件磁盘内容一致,可以随时被回收且无需写回磁盘;Dirty 则表示页内容已被修改,在回收前需要先写回磁盘。这类页主要来自文件映射的共享库、tmpfs 文件等。
  • Private_Clean / Private_Dirty:私有页的干净/脏统计。Private_Dirty 是内存回收时成本最高的那一类,因为它是当前进程私有的,又跟文件没有对应关系(或者即使有,也已经改脏)。进程堆、栈、匿名映射产生的内存,最终大都落在这里。
  • Swap:被换出到交换分区的大小。进程被换出的页越多,这个值越大。如果 Swap 长期增长,说明物理内存吃紧,断崖式换入换出会导致性能雪崩。
  • Anonymous:属于匿名映射的页大小,不包含文件映射页。这个字段能帮你区分“纯数据内存”和“文件缓存页”。
  • Locked:被mlock锁定的页大小。锁定后这些物理页不会被换出,适合对延迟极度敏感的关键数据结构。

2.3 容易被忽略的字段

除了上面这些主数字,smaps 里还有几个字段,排查特殊问题时特别好用:

  • KernelPageSize / MMUPageSize:前者是内核页大小,通常是 4KB;后者是 MMU 实际使用的页大小。如果系统启用了透明大页(THP),你会在AnonHugePages里看到大于 4KB 的统计,此时KernelPageSize保持 4KB,但实际映射会利用 2MB 大页。这两个字段能帮你判断透明大页是否真的生效,而不是只看/sys/kernel/mm/transparent_hugepage/enabled里写了个always。
  • AnonHugePages:匿名大页占用。如果这个值在持续上涨,说明 THP 正在把多个 4KB 页合并成 2MB 大页。副作用是会造成少量内存超分配,系统内存紧张时可能引发不必要的 swap。
  • Referenced:最近被访问过的页大小。内核在做页回收时会优先回收未被Referenced的页。你看到Referenced远小于Rss,说明这段内存近期很少被访问,是内存回收时的首选目标。
  • VmFlags:这是一个 VMA 标志的汇总,常见的有rd(可读)、wr(可写)、ex(可执行)、mr(可映射)、dw(已被写脏?实际是VM_SHARED之外的标志)、lo(已锁定)、sf(只读文件映射)。排查段属性异常时,这个字段能帮你快速判断。

注意:不同内核版本 smaps 字段可能略有差异。比如 4.18 内核之后LazyFree和ShmemPmdMapped等字段才更完整地暴露出来。若遇到字段缺失,先确认内核版本,不要急着怀疑数据异常。

3. 一场真实的排查实录:谁动了我的内存

光知道字段含义不够,还得落到实战。这里我用之前处理过的一个服务端进程内存持续增长案例,完整走一遍 smaps 排查链路。

3.1 先抓大块头:快速定位内存大户

接到告警说某进程 RSS 已经涨到 12GB,而且还在继续涨。第一件事不是去看业务代码,而是先抓内存大头。我先执行以下命令,把该进程所有 VMA 按 Rss 降序排列:

grep -E "Rss|^[0-9a-f].*" /proc/12345/smaps | paste - - | awk '{print $NF, $0}' | sort -nr -k1 | head -20

这里我把Rss字段所在的整行和 VMA 头行拼成一行,再按Rss数值排序。输出里出现了一个[anon]段,Rss 高达 6.8GB,另一个/usr/lib/x86_64-linux-gnu/libfoo.so的映射也占了 1.4GB。看到[anon]段巨大,基本说明不是文件缓存问题,而是纯业务数据内存。

提示:paste - -的作用是每两行合并成一行,因为 VMA 头行和随后的属性行在原始输出中是相邻的。这里假设你的 smaps 字段顺序比较稳定,用Rss所在行和上一行做配对是常用操作。

3.2 从PSS到sum:识别共享库成本

第二步是看共享库的 Pss,因为 RSS 会把多个进程共同加载的 .so 算重复了。

awk '/^[0-9a-f]/{path=$NF} /^Pss/{pss+=$2; if ($2 > 100000) print path, $2}' /proc/12345/smaps | sort -k2 -nr | head

这轮输出帮我筛出了 Pss 超过 100MB 的映射段。意外发现一个libfoo.so的 Pss 只有 800MB,但它的 RSS 是 1.4GB。说明有近一半的页是跟其他进程共享的。如果按 RSS 做资源隔离,你可能会高估该进程的成本,进而加机器或加内存,白白浪费预算。

继续深挖时我发现,这个libfoo.so的Private_Dirty也有 300MB。按理说共享库代码页应该是干净且可共享的,除非它运行时在写自己的数据段,或者加载器把这个库的部分页面标记为私有写时复制。于是我去查这个库的初始化逻辑,果然发现有一个全局 C++ 对象构造时分配了大块缓存,而且没有按MAP_SHARED方式映射。这就是共享库 Pss 偏高的隐藏原因之一。

3.3 定位泄漏点的完整流程

回到最大的[anon]段,下一步要判断这是堆内存、线程栈、还是 mmap 出来的匿名映射。继续看 smaps:

7f2c6ceef000-7f2cc8e00000 rw-p 00000000 00:00 0 Size: 12582912 kB Rss: 6710886 kB Pss: 6710886 kB Shared_Clean: 0 kB Shared_Dirty: 0 kB Private_Clean: 0 kB Private_Dirty: 6710886 kB Anonymous: 6710886 kB Swap: 0 kB

这个段完全由Private_Dirty和Anonymous组成,没有跟任何文件关联。结合地址范围大小 12GB、Rss 6.7GB,基本可以判断是业务代码用mmap或malloc产生的大块堆内存。

为了确认是malloc还是mmap,我又去读了/proc/12345/maps中同地址段的权限标志,并对比了VmFlags:

VmFlags: rd wr mr mw me ac

ac表示该区域可被自动收缩(area is accountable),也是mmap出来的匿名私有的典型标志。再结合进程业务特征,最终确认是某个消息队列的环形缓存没有及时释放。修复后观察同一匿名校验段的 Rss,从 6710MB 逐渐下降到稳定值 2.1GB,问题收尾。

实操心得:如果怀疑是 C++/Rust 这类手动内存管理语言的泄漏,抓匿名段变化比看堆大小更准确。因为malloc分配的大块缓存可能长期持有内存但不等于泄漏;真正该关注的是匿名段Private_Dirty的持续上升趋势,而不是单次快照。

4. smaps_rollup与实用脚本

手动盯着 smaps 看很快会累,因为它有多少 VMA 就有多少块。现代内核提供了聚合视图/proc/[pid]/smaps_rollup,一次就能拿到整个进程的汇总数据,省去自己逐段累加的麻烦。

4.1 为什么需要聚合视图

smaps_rollup是 Linux 4.14 之后引入的。它的出现是因为许多监控工具会频繁读取smaps,而smaps文件内容可能长达几百上千行,每次读取都有内核遍历 VMA 列表的耗时。smaps_rollup在内核侧统计后输出,读取更快,且对系统扰动更小。比如 Prometheus 的process_resident_memory_bytes就是从它拿的数据。

4.2 两个实用命令脚本

快速看一个进程总内存:

cat /proc/12345/smaps_rollup

输出如下:

12345-7f2cc8e00000 ---p 00000000 00:00 0 Rss: 7812388 kB Pss: 6345210 kB Shared_Clean: 1529112 kB Shared_Dirty: 104539 kB Private_Clean: 72 kB Private_Dirty: 6188664 kB Referenced: 7631980 kB Anonymous: 6329080 kB Swap: 0 kB

这里Pss比Rss小,说明进程确实共享了一批文件页。生产环境脚本判断内存是否异常,可以直接监控Private_Dirty + Swap的组合,如果两个都在涨,内存压力基本跑不掉。

排查多进程服务时,我更推荐按 Pss 排序看所有相关进程,而不是只看单个进程 RSS:

for pid in $(pgrep -f myservice); do pss=$(grep -E '^Pss:' /proc/$pid/smaps_rollup | awk '{s+=$2} END {print s}') echo "$pid $pss" done | sort -k2 -nr

这样能快速找出“实际物理占用成本”最高的进程,减少因为共享页面带来的误判。之前做容器内存水位评估时,我就是用这个脚本判断哪些副本该缩容、哪些该扩容。

4.3 用smaps反推page cache过高

还有一种典型情况:进程 RSS 不高,但整机内存被 page cache 吃满,问你进程为什么看起来内存占用很低但系统 docker 容器一直 OOM。此时光看单进程 smaps 不够,要看系统整体缓存池。smaps 能做到的是帮你筛选出某个进程是否把大量文件页映射进了自己的地址空间却长期不访问。你只需看Shared_Clean和Referenced的相对关系:如果Shared_Clean大但Referenced小,说明映射了文件页但没有频繁访问,这种页可以安全回收,而不用急着给容器加内存。

5. 常见问题与避坑指南

smaps 用多了,自然会有几个高频坑。我整理了一张速查表,你以后排查可以直接对照。

5.1 常见问题速查表

现象重点字段判断思路
进程 RSS 持续偏高但业务无变化Shared_Clean/Referenced可能只是文件缓存被重复映射,不是内存泄漏,优先看Pss和Private_Dirty
多进程共享库重复计算PssPss 已按共享比例分摊,做容量规划用 Pss 加总
交换分区频繁换入换出Swap、SwapPss若 Swap 非零且持续增长,说明物理内存压力大,优先定位Private_Dirty最大的段
匿名内存段快速膨胀AnonHugePages、Private_Dirty检查 THP 合并是否加剧了内存占用,亦可排查业务侧大块按需分配
透明大页配置了但没生效KernelPageSize、MMUPageSize、AnonHugePagesAnonHugePages 始终为 0 说明 THP 未生效,检查分配器配置和碎片程度
段权限不对导致mmap失败VmFlags用VmFlags判断当前 VMA 是否支持可写映射,是否被锁定
应用内存显示比容器 limit 还大Rss加总口径确认是多个进程共享了物理页,还是 cgroup 统计中的 file cache 偏差,结合smaps_rollup核对

5.2 实操心得

以下几点是我在长期排查里沉淀出来的经验,很值得单独说说。

第一,趁早养成“用Pss评估单进程、用Private_Dirty定位泄漏”的习惯。RSS 适合做系统总览,但一旦涉及多进程和共享库,RSS 加总只会制造恐慌。Pss 也不是万能的,它只做比例分摊,不反映内存碎片和页表开销。所以我会分场景使用:容量规划看 Pss,泄漏排查看 Private_Dirty/Swap,内核页表开销看 VMA 数量和 KernelPageSize。

第二,不要只看一次快照。内存问题的本质往往藏在变化趋势里。建议每隔 30 秒采样一次 smaps_rollup 和关键匿名段,画成趋势线。如果某段Private_Dirty呈线性增长,基本可以断定有循环分配没释放;如果只是瞬间冲高后回落,多半是大块缓存或临时缓冲区导致。

第三,小心 THP 对统计口径的干扰。开启透明大页后,AnonHugePages会让Rss出现“虚胖”,但这不代表真的使用量变大,只是页从 4KB 粒度变成 2MB 粒度。内存紧张时,回收一个大页比回收 512 个小页更困难,因此大页反而不利于内存回收。如果你在容器场景碰到诡异的 OOM,可以考虑在容器内关闭 THP 再观察对比。

第四,smaps 是统计视图,不是内存归属的最终裁定。内核页表、slab 内存、以及同一物理页被重复映射(比如mmap同一个文件多次),都不会按你以为的方式完整归因到某个进程。遇到全局内存波动,打一套smem、/proc/meminfo、/proc/pagetypeinfo组合拳会更全面。

5.3 smaps 文件过大时的应对措施

如果 VMA 数量特别多,比如重度使用mmap的应用,smaps可能达到几 MB。每次cat完解析,CPU 开销不低。这时候我建议:

  • 优先用/proc/[pid]/smaps_rollup拿常规监控指标。
  • 定位到可疑大段后,再针对地址范围做小范围抓取,比如用sed -n '/7f2c6ceef000/,/^$/p' /proc/12345/smaps只打印一段映射块。
  • 使用smem工具时,要留意它对smaps的解析方式,部分版本需要逐行遍历,对大数据量 VMA 会有明显延迟。

注意:访问/proc/[pid]/smaps_rollup时最好使用pread或一次性read,避免多次小规模读造成内核态和用户态切换开销。脚本里频繁cat大文件并不是好习惯。

6. 高级用法:smaps与容器内存、OOM排查结合

现在很多人已经很少直接看裸进程,而是面对容器和 cgroup。smaps 的价值在这里依然成立,只是换了一套组合打法。

6.1 容器内怎么看真实内存占用

容器内看到的内存占用,来自/sys/fs/cgroup/memory/memory.usage_in_bytes或者 cgroup v2 的memory.current。但这个总和包括 page cache,并不等于进程真正的匿名内存成本。真要看某个业务进程在容器内占了多大物理内存,可以到容器内执行/proc/[pid]/smaps_rollup,或者把宿主机上对应的 PID 找出来,再解析 smaps。

我曾经处理过一个 Java 服务跑在 4GB 限制的容器里,每次到晚上就 OOM。从 cgroup 看memory.usage确实接近 4GB,但smaps_rollup里Shared_Clean只有 30MB,Private_Dirty高达 3.2GB,Swap为 0。这基本排除了 page cache 占满的假象,确认是 Java 堆或堆外内存持续增长。后来定位到是 DirectByteBuffer 没有及时回收,用 smaps 的匿名段趋势完美捕捉到了增长曲线。

6.2 如何判断 OOM 是发生在进程内还是系统级

有一种坑:进程明明还活着,但日志里出现Killed process ... total-vm之类的系统级 OOM 记录。此时要区分是系统全局内存不足,还是 cgroup 限制触发回收。smaps 能帮你看的就是进程的真实驻留和共享情况。如果Pss加总远小于整机内存,但 cgroup 却一直回收,那大概率是文件缓存导致 cgroup 内存统计过大。而如果Pss加总确实接近物理内存总量,就要查是不是有哪个进程像第 3 节那样在疯狂膨胀匿名段。

用 smaps 的SwapPss字段,也能大致判断进程在被 OOM killer 选中前后的内存压力。SwapPss表示该 VMA 换出页面的比例分摊值,容器环境里经常用来估计“如果再压一点,还要换出多少”。我在做核心服务的内存代际预留时,会把这个值当作一个安全水位信号。

6.3 从 smaps 反推堆内存配置是否合理

对 C/C++ 服务,或者 Rust 服务,直接用 smaps 的[heap]段和[anon]段的Private_Dirty来评估堆参数。比如你给 JVM 设置了-Xmx4g,但 smaps 里只看到 1.5GB 匿名段,这说明 JVM 堆还未增长到位;如果超过-Xmx也不奇怪,因为还有 JIT、Metaspace、DirectBuffer 等不在-Xmx范围内。用 smaps 做“堆外内存”的排查,可以说是最快捷的入口之一,因为Shared_Dirty和Private_Dirty的分布会把堆外内存在匿名段和文件映射段里的实际占用暴露出来。

实操技巧:对于 Go 进程,重点看[heap]段的Rss和Private_Dirty变化,判断 Go runtime 的堆缩放是否合理;对于 Java 进程,关注所有匿名校验段的Private_Dirty之和,才能覆盖堆外内存。

7. 结尾留一点小私货

我在实际使用中发现,很多人都知道cat /proc/pid/smaps | grep Pss,但真正把 Pss、Private_Dirty、Swap 组合起来分析问题的却不多。给一个最后的小建议:写脚本统计时,先做一次smaps_rollup定位大方向,再按 VMA 逐段筛选嫌疑段,最后结合业务日志验证,这样效率最高。

如果你想给团队做一期内存排查分享,可以拿第 3 节的真实案例改造成一个演练脚本:故意申请一块匿名内存不释放,让学生从 smaps 里找出哪个 VMA 在增长,然后再给一个共享库映射的对照实验。这套思路比单纯讲虚拟内存理论生动得多。

我没有讲什么高深的内核算法,但 smaps 这层基本功,恰恰是很多高级工具和监控平台背后的数据来源。学会它,你在面对任何 Linux 内存异常时,手里就多了一张不会骗人的底牌。

返回列表