Linux内存排查利器:/proc/pid/smaps核心字段解析与实战 有一类内存问题会把一个Linux老兵逼到挠头free 报告可用内存只剩几百MBtop 按 RES 排序杀出一个进程数字大得吓人。你点开 pmap -x看到的却是一长串十六进制地址根本读不出信息。我早期排查这类问题也是这样——对着 /proc/[pid]/maps 干瞪眼每个地址区间的权限和文件路径我都认识但它占了多少物理内存、多少是自己私有的、多少可以回收一个都答不上来。直到我真正啃下 /proc/[pid]/smaps才算是第一次摸到了进程内存的纹理哪一段虚拟内存区域占了几个G私有的还是共享的干净页还是脏页甚至有多少换到了swap里全都写得明明白白。如果你是开发、运维或者SRE只要你的服务有一天会吃内存smaps 就该是你的必读文件。这篇就把它从里到外拆一遍顺便附上我用它定位内存异常的真实排查记录。1. 一张smaps长什么样先看VMA骨架再谈统计字段1.1 VMAsmaps文件里每一节的描述单位要读懂smaps先得明白一个概念VMAVirtual Memory Area虚拟内存区域。进程的虚拟地址空间不是一整块而是被内核按地址连续、权限一致、来源相同的原则切成很多段。每一段就是一个VMA比如代码段是一个VMA、堆是一个VMA、栈是一个VMA、每个mmap的共享库又是一个VMA。/proc/[pid]/maps 就是把这些VMA逐个列出来格式大概是这样00400000-00406000 r-xp 00000000 fd:00 2638625 /usr/bin/cat六列依次是虚拟地址区间、权限r/w/x/p/sp表示私有映射s表示共享映射、文件偏移、设备号主:次、inode号、映射文件路径。但maps只告诉你这块区域能读能写不告诉你它实际消耗了多少物理内存。smaps把maps的每一行当成一个小节的标题标题下面跟着十多个统计字段回答这个VMA到底吃了多少内存。想知道一段区域是干什么的看第一行想知道它真的占了多少看下面的字段。这就是smaps设计的核心逻辑按VMA粒度记账。值得多说一句的是第一行里的路径未必都是文件。常见的几种VMA标注包含[heap]代表堆区[stack]代表栈区[anon]代表匿名映射通常是mmap分配的内存也可能是线程栈[vdso]、[vvar]、[vvar_vclock]这些是内核映射给用户态的小块辅助代码还有带完整路径的是文件映射。路径后面出现(deleted)意味着映射的文件已经被删除但映射还在——这是一个非常重要的排查线索后面实战部分我会专门讲。1.2 一个真实条目的逐段解读随便找一个进程比如pidof cat拿到PID之后执行cat /proc/PID/smaps你看到的每个VMA小节长这样00400000-00406000 r-xp 00000000 fd:00 2638625 /usr/bin/cat Size: 4 kB KernelPageSize: 4 kB MMUPageSize: 4 kB Rss: 4 kB Pss: 4 kB Shared_Clean: 0 kB Shared_Dirty: 0 kB Private_Clean: 4 kB Private_Dirty: 0 kB Referenced: 4 kB Anonymous: 0 kB LazyFree: 0 kB AnonHugePages: 0 kB ShmemPmdMapped: 0 kB FilePmdMapped: 0 kB Shared_Hugetlb: 0 kB Private_Hugetlb: 0 kB Swap: 0 kB SwapPss: 0 kB Locked: 0 kB THPeligible: 0 ProtectionKey: 0不同内核版本字段会略有增减但主干就这些。初次接触的人很容易被二十多个数字吓退我建议把它们分成三类记忆第一类是VMA自身属性的统计Size是这个VMA的虚拟内存大小KernelPageSize和MMUPageSize说明页大小情况。第二类是物理驻留与共享分摊的统计Rss、Pss、Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty这一组是最常用的核心指标。第三类是特殊页与杂项Swap、SwapPss、Locked、AnonHugePages、THPeligible、ProtectionKey这些对应透明大页、mlock锁页、内存保护键等高级特性。养成先扫标题行、再看核心统计、最后看特殊字段的习惯smaps就一点也不吓人了。接下来的章节我会把这三组字段逐一拆开讲重点说清楚它们背后的计算逻辑和业务含义。2. 逐项解剖统计字段驻留、共享、脏页与Swap的算法逻辑2.1 Rss和Pss同一个VMA两种记账口径先说最常用的两个RssResident Set Size常驻集大小和PssProportional Set Size按比例分摊的常驻集大小。Rss表示这个VMA里映射了多少页在物理内存中不管这些页是不是也让其他进程共享了。假设一个进程加载了libc.so其中某个物理页同时被三个进程映射那么每个进程的smaps里都会把这一页算进自己的Rss。从单个进程视角看没问题但如果把系统所有进程的Rss加在一起物理内存会被重复计算好几遍——这就是你常常看到所有进程RES加起来远大于free里的used的原因。Pss修正的就是这个问题。它的计算思路是对于每个物理页用它的大小除以映射到该页的进程数然后把每个VMA里的分摊值加总。还是那个被三个进程共享的libc页面内核就会给每个进程只记下三分之一。Pss把进程视角的账转换成了系统资源视角的账在评估共享内存对系统整体内存压力影响时比Rss可靠得多。我处理过不少内存告警一个常用的经验是看进程自身的内存增长趋势用Rss衡量它对整机内存的真实消耗用Pss。两者差值越大说明这个进程的共享映射越多。2.2 Shared_Clean与Private_Dirty四个值的分组账本紧接在Pss后面的四个字段把Rss又精细切分了一遍Shared_Clean: 共享映射页且页面是干净的。所谓干净指页面内容与磁盘上的文件副本一致系统需要内存时可以直接丢弃无需回写。Shared_Dirty: 共享映射页但页面被修改过属于脏页状态回收前必须先写回磁盘。Private_Clean: 私有映射页干净状态。进程可能是通过MAP_PRIVATE映射了一个文件但还没写。Private_Dirty: 私有映射页脏状态必须回写或换出后才能回收。这四个值加起来正好等于Rss。我记这组字段时有个口诀Shared对应多进程共享Private对应本进程独占Clean是随时能丢的干净页Dirty是必须先处理的脏页。排查内存压力时Private_Dirty尤其值得关注因为它往往代表进程真正私有的、动态产生的数据——堆上分配的内存、写时复制后的匿名页大多落在这里。一个常见的坑是看到进程Rss很高以为是内存泄漏结果看Shared_Clean占了绝大部分说明它加载了大量共享库或文件映射的只读页。这些页实际上对整机内存压力很小被回收的风险也低因为随时可以从文件重新读入。如果看到Private_Dirty持续上涨而业务又没增长这才更可能指向泄漏或者缓存失控。2.3 Swap、Anonymous、Referenced、Locked与LazyFree杂项字段各管一摊Swap记录的是这个VMA里有多少页已经被换出到swap交换空间。很多人容易忽略一个细节——页面换出去了但它仍然属于这个VMA地址空间也在只是不在物理内存里。所以排查内存占用时Rss Swap才是这类页面占用的总成本。Anonymous表示匿名页的驻留大小。匿名页指与文件没有任何关联的页面典型来源是堆、栈、写时复制产生的私有页、以及mmap的MAP_ANONYMOUS映射。这个字段对判断进程是不是在搞匿名内存大头很有用。配合Swap看如果Anonymous很高而Swap也很高说明进程占内存后被换出了一部分这种场景下Pss SwapPss能更准确地估计它对系统造成的实际成本。Referenced统计的是最近被访问过的页面数量。内核会定期扫描页表更新引用位所以这个值间接反映了VMA里的页面在近期是否真的被读写了。这个字段不算精确但在区分堆里分配了但没用和堆里分配了并且在用这两类情况时它是很好的辅助判断依据。Locked对应被mlock或mlockall锁定的页面这些页不允许被换出通常用于需要保护敏感密钥或实时性要求高的场景。LazyFree则是内核4.12之后新增的记录通过madvise(MADV_FREE)标记为可释放的页面。这类页面在被内核回收之前进程仍可以读但一旦内核缺页优先把它们干掉。我建议把下面这张表收藏起来排查现场对照着看最方便字段含义排查时的解读RssVMA驻留物理内存总量进程视角的内存占用Pss按共享进程数分摊的驻留量系统视角的真实成本Shared_Clean共享且干净的页多为共享库、文件只读映射可随时回收Shared_Dirty共享且修改过的页需要先回写才能回收Private_Clean私有且干净的页多为映射文件尚未修改Private_Dirty私有且修改过的页堆、匿名数据主要落在这里重点盯防Anonymous匿名页驻留量判断是否堆/匿名内存涨了Swap已换出到交换空间的页常和Rss一起看进程总内存成本SwapPss按比例分摊的Swap量衡量swap对整机的分摊影响Referenced最近被访问的页判断VMA里是否真的有热数据Lockedmlock锁定的页锁定意味着不可换出LazyFreeMADV_FREE标记的页可回收但仍在账上的页这组字段吃透了smaps里百分之八十的信息就已经到你手里了。3. 哪个数字才是真实占用的答案RSS、PSS与共享页分摊的计算3.1 三个进程加载同一份libc的真实账本为了把RSS和PSS的区别说透我算一笔具体的账。假设系统同时运行三个进程A、B、C它们都用mmap方式映射了同一个4MB大小的libc.so文件。内核只在实际访问页面时才会把文件页搬进物理内存这里假设三个进程把所有页面都触达了那么libc.so在物理内存里一共只有4MB系统里只存在一份物理页。但从每个进程的smaps看Rss都把整份libc的4MB记在了自己名下。于是每个进程的Rss约4MB三个进程Rss之和约12MB系统真实物理内存占用约4MB每个进程的Pss约4 / 3 1.33MB三个进程Pss之和约4MB这个例子就是RSS“高估”的原因。你把top里所有进程的RES加起来经常比free里的used高出不少就是因为共享页被重复计数了。换成PSS求和结果会贴近真实物理内存用量。在生产环境里Java、Node.js这类运行时依赖一堆共享库的程序特别明显。我曾经观察过一台跑着12个微服务容器的机器所有容器RSS求和超出实际内存用量近40%但换成PSS求和之后基本吻合。从那以后我在给内存做“按进程归因”时标准动作都是优先看PSS。3.2 PSS的两个统计陷阱精确但不是绝对精确PSS不是银弹它有两个容易被忽略的误差来源。第一个是页面可能被同一个进程映射到不同的VMA里而不是被不同进程共享。比如进程对同一个文件做了多次mmap映射同一个物理页出现在多个VMA的PSS计算中最后在进程内部就被重复计数了。内核在计算PSS时是以VMA为单位逐个统计的它并不去全局查重。因此内核文档里也明确警告过系统所有进程的PSS之和和实际物理页用量之间仍可能有一定偏差。第二个误差来自共享进程数变化的瞬时性。PSS的“按进程数均分”是一个近似模型真实物理页被映射到的进程数可能在一两个毫秒内就变了。你抓取到的smaps快照只能反映那一瞬间的分摊情况。所以PSS适合做趋势观测和粗粒度归因不适合当成绝对精确的账本。还有一个常见误区是拿free里的used值和全部进程PSS之和做等式对比。这肯定对不上因为内核自身的页面、驱动使用的页面、page cache里还没被任何进程映射的页面都不计入任何进程的PSS。PSS之和只能解释“哪些进程消耗了大部分内存”不能解释“整机的内存到底去哪了”。3.3 业务场景里到底该看谁结合我自己的排查经验这三类指标各有主战场判断某个进程是不是内存大户看Rss——它对应top里RES那一列大家习惯上都用这个值横向比较。评估某个进程对整机内存的实际压力看Pss——共享库、共享内存多的程序Pss往往只有Rss的一半甚至更少。排查内存泄漏或异常增长则要盯着Private_Dirty Swap的组合——它们最接近“进程自己搞出来的、别人帮不了忙”的内存成本。顺序也很重要。线上告警一来我通常先按Rss排序找到嫌疑进程再切到smaps看Pss和Private_Dirty判断它到底是真大户还是“表面大户”。这个习惯能避免很多误杀特别是在容器化场景里——两个容器共享同一个只读镜像层时Rss虚高非常常见但Pss会告诉你真实压力远没有那么夸张。4. 实战排查一次真实的内存虚高定位全记录4.1 现场现象RES爬到10GB以上业务流量却纹丝不动某次线上故障监控显示一台8C16G的机器的可用内存掉到了1GB以下连续触发warn级别告警。登录机器后我按惯例先跑了一遍top看到某个内部中间件进程的RES已经爬到11.8GB而这个进程平时的正常水位在5GB左右。更奇怪的是业务调用量并没有明显增长。以“进程突然多占了6GB内存”为起点我开始逐层往下拆。第一步是用/proc/[pid]/status快速确认总账grep -E VmRSS|VmData|VmExe|VmStack|VmLck /proc/pid/status结果里VmRSS是11.8GBVmData高达9.4GB。VmData代表数据段大小基本上是堆和私有匿名映射的天下VmExe、VmStack都很正常。这说明大头确实在堆和匿名映射方向而不是共享库膨胀。这为后续缩小了排查范围。4.2 给每个VMA按Pss排序顺手写个小脚本知道是堆的问题还不够我还想看到底是哪个VMA在吞内存。这时候smaps的“按VMA记账”优势就体现出来了但人工翻几千行VMA不现实。我写了个极简的awk脚本把每个VMA的Pss单独拎出来排序awk /^[0-9a-f]/ { if (vma) printf %8d %8d %s\n, pss, rss, path; vma $0; pss 0; rss 0; path ($NF ~ /^\[/) ? $NF : $NF; } /^Rss:/ { rss $2 } /^Pss:/ { pss $2 } END { if (vma) printf %8d %8d %s\n, pss, rss, path; } /proc/pid/smaps | sort -k1 -rn | head -20最终输出里排在最前面的两个VMA把我彻底看愣了一个是权限rw-p的地址段标注为[heap]Pss约6.3GB另一个是路径带(deleted)标志的文件映射Pss约2.9GB。[heap]占大头符合预判但那个deleted文件映射出现在高位说明事情没那么简单。这里插一句awk脚本里的path提取是把整行最后一个字段拿来用如果路径里带空格会不准确。真实场景里日志文件路径带空格的情况不少见真要严谨建议用第一行最后一个字段即使路径包含空格也通常是完整真实的路径procfs里每行按空格分隔列路径是最后一列但不会包含空格实际上procfs路径会保留空格稳妥起见直接用$NF然后去匹配已知的lib目录或文件名或者交给Python脚本解析更安全。4.3 通往真相的钥匙deleted文件映射我先聚焦那个2.9GB的deleted文件映射。smaps这一小节的标题行大概是这样7f4d2c000000-7f4d44000000 rw-s 00000000 00:1e 3416108 /tmp/event_stream.dat (deleted)权限位rw-s里的s表示共享映射/tmp/event_stream.dat (deleted)表示文件已经被unlink但这个进程还握着映射不松手。再看下面的统计Rss2.9GBPrivate_Dirty接近0Shared_Dirty约2.9GB。这说明进程通过共享映射持续写这个已经删除的临时文件由于文件已经没有目录项写脏的页面无法通过“回写文件”正常回收——你指望内核把数据写到磁盘文件都不存在了写回只会报错。结果就是页面只能一直挂在内存里越写越多直到进程退出才彻底释放。顺着路径我反查进程里的日志或临时代码逻辑发现这个中间件有个bug它会临时创建一个mmap文件用于跨模块传数据正常情况下用完就unmap但这个版本里异常分支忘记调用munmap而文件在创建后的清理任务里被提前unlink于是留下一个“已删除但仍被持续写入”的映射死结。修复方案是把卸载和清理的顺序做对——先停止写入再unmap最后删除文件并加了一层引用计数防止清理线程抢跑。4.4 修复之后结论用一个动作验证修复代码上线后我在另一个窗口持续观察进程内存watch -n 1 grep -E VmRSS|VmData /proc/pid/status进程重启后重新跑业务VmRSS在半小时内稳定回落到4.8GB左右之前的11.8GB水位彻底消失。再看smaps时deleted文件映射的小节已经不在VMA数量也少了十几个。整机可用内存从不足1GB恢复到了9GB以上。这次排查最核心的一步其实就是smaps里那个(deleted)标记。如果没有smaps只看top和pmap我可能还要花大半天怀疑堆泄漏有了smaps直接定位到一个具体文件映射范围瞬间缩到一行代码。这也是我坚持给每个线上故障都保留一帧smaps快照的原因——有时候一次内存告警的证据就藏在这种细节里。5. 高级字段不鸡肋THP、巨页、ProtectionKey与VmFlags5.1 用AnonHugePages判断透明大页是否在吃内存smaps里有一组字段和透明大页THP相关AnonHugePages、ShmemPmdMapped、FilePmdMapped、THPeligible。很多人从来不细看但排查某些诡异问题时这组字段是救命稻草。AnonHugePages表示该VMA里有多少匿名内存是以透明大页通常是2MB形式驻留的。THP是内核为减少页表项数量、提升TLB命中率引入的优化它会尝试把相邻的匿名页自动合并成2MB的大页。听起来很美但在某些内存分配/释放非常频繁的应用里THP反而会加剧内存占用——因为合并之后即使只有一两个字节被修改这一整块2MB大页在内存压力下也可能无法被拆分为细粒度页来回收。有一次排查一个Redis-like服务的RSS异常飙升最后定位到AnonHugePages字段高得反常业务QPS不高但页碎片化严重。我们在服务启动时显式禁用了THP通过madvise(MADV_NOHUGEPAGE)或容器启动参数RSS一下子回落了百分之三十。判断一个VMA是否允许再被THP合并看THPeligible就够值为1表示可以为0表示被排除。5.2 KernelPageSize与MMUPageSize巨页场景下的读数差异KernelPageSize和MMUPageSize平时都是4kB平平无奇一旦进程用了hugetlb巨页这两个数就会变得很有信息量。KernelPageSize是内核实际使用的页大小MMUPageSize是硬件MMU管理内存时使用的页大小。在普通4KB页面系统里两者都是4096在用hugetlb的进程里KernelPageSize会显示为2048kB2MB甚至1048576kB1GB。通过smaps里Shared_Hugetlb和Private_Hugetlb这两个字段你能清楚看到hugetlb页在这个VMA里摊了多少。这类大页一旦由进程通过hugetlbfs映射就不会像普通匿名页那样被回收或换出它们对整机内存容量是明确的、刚性的占用。排查“内存free看着还有但某个进程无法获得巨页”的问题时逐进程看Shared_Hugetlb与Private_Hugetlb之和能准确找出谁在霸占巨页资源。5.3 ProtectionKey和VmFlags被大多数人忽略的末行信息ProtectionKey列出的是一串十六进制形式的保护键值。这是x86的MPKMemory Protection Keys特性允许用户态程序对不同内存区域做细粒度权限隔离。绝大多数业务进程这里都是0但如果你的服务使用了pkey_alloc、pkey_mprotect这类syscall做安全加固排查权限异常时就需要对照这个字段确认键值和VMA的对应关系。smaps某些版本还会额外输出一列VmFlags例如rd wr mr mw me dw sd。它其实是对最前面权限位的补充说明rd和wr表示可读可写mr、mw表示mmap支持读、写me表示可执行映射dw表示该VMA是可疑的被写脏映射不对dw表示写了但还没同步到文件sd则是软dirty标记常用于内存快照与迁移场景。这行字段平常用处不大但你想确认某个VMA是否有写时复制、是否允许按需换出时能提供比权限位更细的描述。这些高级字段平时安静地躺在smaps里但真遇到“内存看起来够却报错”“进程内存明明不高却触发OOM”这类疑难杂症它们往往能提供常规视野之外的第二个突破口。6. 线上监控与脚本化采集为什么不要频繁cat整个smaps6.1 smaps的读取成本不是免费的午餐smaps好归好但不能像cat /proc/loadavg那样随便高频调用。每次读取smaps内核都要遍历目标进程的所有VMA为每一个VMA汇总页面统计信息还要持有进程地址空间的相关锁。如果一个进程有几万个VMA一次smaps读取就会让内核花费不少时间你监控脚本跑一次也许没事但如果每5秒抓一遍全机器几百个进程的smapsCPU开销立刻就能看到。我见过一个监控脚本事故运维为了做“进程内存趋势大屏”每分钟对所有容器触发一次awk解析smaps业务高峰期CPU直接被监控进程吃掉了不少核心。结论是smaps适合按需排查不适合作为常规高频监控项。6.2 高版本内核的解法smaps_rollupLinux 4.14之后内核引入了一个更轻量的节点/proc/[pid]/smaps_rollup。它把整个进程所有VMA的smaps统计直接汇总成一份输出字段和smaps完全一致但不再逐段罗列。对监控来说体量小好几个数量级读取开销也下降不少。我后来把线上内存采集脚本全面切到了smaps_rollup再用smaps做精细定位。基本的节奏是每30秒采集一次smaps_rollup里的Rss、Pss、Private_Dirty、SwapPss做趋势当趋势触发阈值时再即时抓一帧完整smaps去解析具体VMA。这样既有宏观趋势又能保留现场细节采集成本还低了一大截。不过要注意smaps_rollup把PSS汇总后会有微小的精度损失因为每个VMA的共享页分摊比例不同直接加总后可能和逐段计算的PSS之和存在几KB的偏差。对告警判断毫无影响但如果你需要做精确到KB的内存证明还是得自己逐VMA解析smaps。6.3 一份按Pss排序的进程采集脚本下面这个脚本可以在不影响线上性能的前提下把全机进程按Pss排个序是定位“谁在买内存”时的好帮手#!/bin/bash # usage: pss_rank.sh for pid in $(ls /proc | grep -E ^[0-9]$); do if [ ! -r /proc/$pid/smaps_rollup ]; then continue fi pss$(awk /^Pss:/{print $2} /proc/$pid/smaps_rollup) rss$(awk /^Rss:/{print $2} /proc/$pid/smaps_rollup) if [ -n $pss ] [ $pss -gt 0 ] 2/dev/null; then cmd$(tr \0 /proc/$pid/cmdline 2/dev/null | cut -c1-80) [ -z $cmd ] cmd[kernel]/$pid printf %8d %8d %s\n $pss $rss $cmd fi done | sort -k1 -rn | head -30这个脚本里我特意用了smaps_rollup而不是smaps就是为了降低采集开销。需要注意两点一是要处理权限问题很多僵尸进程或属于其他用户的进程读不了smaps脚本里用可读判断跳过了二是cmdline为空时通常表示内核线程或正在退出的进程显示[kernel]/$pid避免漏统计。如果需要在容器环境里跑建议把它编译成辅助sidecar并且用cgroup的memory.stat做交叉验证——容器视角的匿名内存和page cache统计和smaps的进程视角互补能发现更多问题。6.4 smaps不是唯一工具和status、cgroup、numa_maps的正确配合最后的建议是别让smaps单打独斗它和另外几个内核接口是分工协作的关系。/proc/[pid]/status里的VmRSS、VmData、VmSwap是从smaps汇总出来的快速视图适合先看总账。/proc/meminfo和/proc/pagetypeinfo反映整机内存水位与碎片情况适合判断“是单进程吃内存还是全机性内存衰退”。/proc/[pid]/numa_maps则提供NUMA视角能看到哪些VMA里的页散落在哪个NUMA node上对多路服务器排查跨node访问延迟问题很有价值。cgroup的memory.stat补齐了容器视角它统计cgroup内所有进程的整体内存还能区分匿名页、page cache、内核栈等且支持memory.current做准实时水位判断。我的习惯是三层联动smaps_rollup做进程趋势smaps做单点定位cgroup memory.stat meminfo做整机与容器校验。三层交叉验证之后得出的结论基本上都能站得住脚。最后分享一个亲测好用的小习惯排查内存问题之前先顺手记下当前时间点和业务流量。很多进程的内存本来就在动态波动光看一帧smaps很容易误判方向把smaps和其他内存指标组合使用才是效率最高的方式。对我个人来说smaps是Linux内存排查中最有“颗粒感”的工具——它不会直接告诉你哪里有Bug但能让你把模糊的“内存不够”变成具体的“哪一段映射有问题”而这一步往往就是解决问题的全部关键。