前阵子帮一个朋友排查线上服务被莫名其妙杀掉的问题他在top里看到进程 RSS 才 600MB机器内存剩余还有 3GB结果进程还是被系统Killed了。这种内存账对不上的诡异场景我在过去十年里遇过不下七八次每一次往深里挖最后都会回到同一个主题——Linux 进程内存管理。这篇【译】文章改自社区里流传了很久的一篇经典英文分享我没有逐字直译而是按自己实际调试时的理解重新组织了内容每一小节都补了实测注解和踩坑经验。你不需要是内核专家只要写过 C/C、或者被线上内存问题折磨过读起来就都会有收获。花二十分钟把这套体系理清楚以后再遇到内存相关的疑难杂症至少知道该从哪儿下手。1. 为什么进程的内存数字总是对不上账1.1 VSZ、RSS、PSS 三种口径三种真相很多人第一次看ps aux的输出会被 RSS 和 VSZ 两个字段搞懵。RSS 是 Resident Set Size常驻内存集指进程当前真正占用的物理内存页VSZ 是 Virtual Size指进程映射的虚拟地址空间总大小。后者是个很虚的数字它把进程所有映射的区间长度加在一起不管这些区间有没有真正分配物理页。举个最典型的例子一个进程用malloc(10GB)申请了一块超级大的内存但只写了最前面的 1MB。VSZ 会老老实实加上 10GB而 RSS 只记录实际触碰过的页面可能只有几 MB。你在ps里看到 VSZ 十几 GB、RSS 几百 MB不代表系统马上就要爆内存它只是说明地址空间里画了很大一张饼。反过来也一样如果只看 RSS又容易低估共享带来的水分。PSS 是 Proportional Set Size按共享比例折算后的常驻内存。两个进程都映射了同一份 100MB 的共享库每个进程的 RSS 里都各算 100MB而 PSS 会按共享进程数分摊每人算 50MB。从整机真实占用的视角看PSS 是最接近真相的指标smem工具就是基于这个概念做统计的。下面这张表能帮你快速对应指标全称反映的问题典型使用场景VSZVirtual Size虚拟地址空间映射总长判断是否存在超大映射区间RSSResident Set Size进程独占共享的物理页总和日常监控、预估单进程开销偏乐观PSSProportional Set Size按共享比例分摊后的物理页多进程/容器场景下的真实占用评估1.2 共享内存与私有内存的纠缠RSS 之所以容易重复计数核心原因是共享。用 C 写过一个最简单的printf(hello)程序ldd看一眼就知道glibc、动态链接器这些基础库几乎每个进程都会映射。假设 libc 占 2MB 物理页系统里跑 500 个进程在 RSS 口径下它被计了 500 次整机 1GB 的内存消耗就这么虚增出来了。除了显式的共享库还有一个容易被忽略的共享来源——父子进程之间的 copy-on-write 共享。fork()出来的子进程在最初这一刻并没有复制父进程的物理页它只是把父进程的页表复制了一份所有页面都被标记为只读。任何一方尝试写入时内核才真正复制页面并重新映射这就是 COWCopy-On-Write机制。所以在 fork 之后的一小段时间里父子进程的 RSS 几乎完全重叠你看到的两倍内存其实是虚假的。1.3 一个最小实验亲眼看到数字变化纸上谈兵不如动手。在 shell 里做一组小实验比读任何文档都直观# 启动一个常驻进程用 cat 占住一个管道 $ sleep 1000 [1] 12345 # 查看该进程的内存状态 $ cat /proc/12345/status VmPeak: 12300 kB VmSize: 12300 kB VmLck: 0 kB VmRSS: 2400 kB VmSwap: 0 kB再看地址空间分布$ pmap -x 12345 12345: sleep 1000 Address Kbytes RSS Dirty Mode Mapping 000055b3e8c00000 12 12 0 r---- sleep 000055b3e8c03000 4 4 0 r-x-- sleep ... 00007f8b1bca9000 106 100 0 r-x-- libc-2.31.so 00007f8b1bec9000 8 8 8 rw--- libc-2.31.so ...注意看 libc 有两个相邻映射前面一个r-x--是代码段RSS 基本等于文件大小后面一个rw---是数据段它按需加载Dirty 列告诉你哪些页被写过了。这套信息在后面排查泄漏时特别有用。2. 虚拟地址空间进程眼中的整块内存2.1 虚拟内存解决的三个核心问题进程内存管理的地基是虚拟内存。一句话概括每个进程都独享一份连续的、从 0 到某个上限的虚拟地址空间内核通过页表把它翻译成零散的物理页。这套设计一次解决了三个问题。第一个是隔离。没有虚拟内存的话进程 A 和进程 B 的物理地址天然互相可见一个野指针就能踩坏别的进程的数据这在一个多任务操作系统里是不可接受的。有了页表翻译进程 A 的地址 0x1234 和进程 B 的地址 0x1234 可以指向完全不同的物理页互不干扰。第二个是简化链接与加载。可执行文件的代码段、数据段、BSS 段在文件里的偏移各不相同如果没有虚拟内存链接器要把这些段凑到一个连续的物理地址区间里非常痛苦。而虚拟地址空间的布局是固定的链接器只需要按约定的地址摆放段加载器做页表映射即可。第三个是懒分配与超卖。虚拟内存允许先画饼后买单——映射一个区间不需要立刻分配物理页等进程真正读写时才通过缺页异常补上。这在服务器场景下意义巨大后面第 3 节会专门展开。2.2 经典布局从代码段到栈一个 64 位 Linux 进程的虚拟地址空间从低地址到高地址大致长这样text代码段只读可执行的机器指令通常从固定基地址开始加载。rodata只读数据比如字符串字面量、switch 跳转表。data已初始化数据段带初值的全局变量、静态变量。bss未初始化数据段没有显式初值的全局/静态变量映射为匿名页默认填零。heap堆从 BSS 段末尾开始向上增长由brk系统调用调节边界malloc的很多小对象请求都从这里走。mmap 区域共享库、文件映射、大块匿名映射都放在堆和栈之间的区域增长方向不固定由内核管理。stack栈从高地址向下增长。每次函数调用压栈、局部变量分配都会触到新的页面。vsyscall/vvar 等内核辅助映射极其接近地址空间顶部用于用户态安全地发起系统调用。堆和栈相向生长中间留出的大片空洞就是 mmap 区域的领地。理解了这张图你再看/proc/[pid]/maps里那一长串十六进制区间心里就有底了。cat /proc/self/maps可以看自己的布局注意找[heap]和[stack]对不上图的话回来再读一遍这一段。2.3 用户空间与内核空间的边界虚拟地址空间不是全部留给用户态的。在 64 位系统上典型的分法是把高位的 128TB 划给内核低位的 128TB 留给用户进程中间还有大片未规范地址区域用于防御未初始化指针。内核空间是所有进程共享的同一份映射也就是你cat /proc/kallsyms时看到的那堆符号地址。为什么用户态代码不能直接访问内核空间因为页表里对应的页被标记了内核特权级别supervisor bit用户态访问会触发保护错误。但也正因如此每次系统调用都要从用户态切到内核态借由内核的入口机制完成权限提升再经过地址空间切换返回。现代 CPU 的 syscall/sysret 指令就是为了让这条路径尽量短。3. 从 malloc 到物理页分配链路的三个关卡3.1 brk 与 mmap 的分工逻辑malloc是用户态函数它本身并不直接分配物理内存而是向内核申请扩大地址空间映射。内核提供两种途径brk和mmap。brk是最古老的方式含义是调整程序断点位置。堆的顶部边界叫 program breakbrk把它向上抬就得到了一块连续的堆区向下压就释放。优点是一个区间搞定所有小对象资源开销低缺点是释放不灵活——从堆中间释放的对象无法立刻还给内核只能由 malloc 自己管理成空闲链表这引出了著名的碎片问题。mmap则完全不同它每次创建一段独立的虚拟映射生命周期独立munmap可以整体释放。glibc 的 malloc 实现里有一条经验阈值默认超过 128KBMMAP_THRESHOLD的大块分配直接走mmap小块走brk维护的堆。原因是大块分配如果埋在堆里一旦释放它造成的前后空洞可能永远无法弥合碎片化会越来越严重独立映射则可以干净利落地撤销。这里有个反直觉的点malloc返回指针的一刹那内核并没有给你真正的物理页。它只做了两件事——在进程的地址空间里安插了一段 VMA虚拟内存区域并把页表留空。真正的物理页分配发生在你第一次读写那块地址的时候通过缺页异常完成。3.2 缺页异常懒分配的真正开关当进程访问一个尚没有物理页支撑的虚拟地址时CPU 会触发 page fault操作系统接管后执行do_page_fault。针对用户空间映射的缺页内核要判断这次访问是否合法地址是否落在某个 VMA 区间内、权限是否匹配比如对只读页执行写操作。合法则进入分配流程非法则直接给进程发 SIGSEGV。合法分配也有两种口味。如果是匿名映射的首次访问内核从伙伴系统拿一个清零页塞进页表就算完事这被称为minor fault因为不需要磁盘 IO。如果是文件映射的首次访问则需要把磁盘上的内容读进来被称为major fault耗时往往高几个数量级。你可以用ps -o minflt,majflt看到两者的累计次数majflt 持续增长通常意味着文件读写路径有问题。懒分配给系统带来的最大好处是malloc 的虚拟空间再大只要进程不碰它就不会占用物理内存。很多服务启动时按最大可能配置预留了缓存区实际使用远低于预估值这在虚拟内存体系下是完全无痛的。但注意如果同时开了vm.overcommit_memory2内核会在映射阶段就严格拒绝超量申请懒分配的空间就要摸着钱包过日子了。3.3 页表多级翻译与 COW 之后的物理真相x86-64 上使用四级页表PGD → PUD → PMD → PTE。每次地址翻译要依次索引这四级最终拿到物理页框号再叠加页内偏移。多级结构节省内存的原因很简单地址空间大部分是空洞按需创建中间层级而不是给所有地址都建一张平铺大表。但也因为多级翻译一次要多次访问内存所以 CPU 引入了 TLBTranslation Lookaside Buffer缓存最近用过的翻译结果。TLB 命中率对性能影响极大这也解释了为什么大页HugePage那么诱人。2MB 的 THP 页面让一次 TLB 覆盖的范围比 4KB 基础页大 512 倍翻译次数大幅下降。但坏处也很明显分配大页要连续物理内存页表回收和碎片整理压力大某些场景下反而引发延迟尖刺。再回到 COW。fork()出来的子进程拿到的是父进程页表的拷贝但所有可写页面被标成只读。父或子任意一方写页面都会触发保护错误内核在异常处理里把页面复制一份再分别给两个进程映射成可写。这套机制让 fork 的执行成本低到与实际有多少数据无关而与有多少映射项有关是绝大多数多进程服务愿意用 forkexec 模型的前提。代价是页表复制本身要花时间进程地址空间越大fork 越慢这也是为什么有人在超大进程里改用 vfork 或 posix_spawn。4. 进程内存观测与排查实战4.1 /proc/[pid]/maps 到底在说什么每次排查内存问题我第一个打开的都是/proc/[pid]/maps。它有七列格式是地址范围 权限 偏移 设备号 inode 路径权限里的 r/w/x/p/s 分别代表读、写、执行、私有、共享。p表示私有映射写时复制s表示共享映射直接反映到文件或被其他进程可见。偏移和 inode 帮你判断这段映射对应哪个文件的哪个位置。举个例子看到这样一行7f5a2c400000-7f5a2c601000 r-xp 00000000 08:01 12345 /usr/lib/x86_64-linux-gnu/libfoo.so它表示 libfoo.so 的代码段被映射到7f5a2c400000起始的区间文件偏移从 0 开始权限为可读可执行、私有。同一个库在多个进程里出现地址通常相同这正是共享映射的意义——不只共享物理页连虚拟地址都尽量对齐方便 TLB 共享。排查时会发现一个现象同一个二进制文件在 maps 里出现很多行而且权限各不相同r--p、r-xp、r--p紧接着rw-p。这是 ELF 加载的常规操作每个 PT_LOAD 段独立映射。r-xp是代码段rw-p是数据段中间那个r--p可能是只读的元数据。别把它们合并理解为重复加载。4.2 smaps 里的四类页才是内存的真账本maps只给你看地址范围和权限想知道每段映射到底占了多少物理内存要看/proc/[pid]/smaps。它把每段映射的账算得清清楚楚。我第一次认真读 smaps 时印象最深的是这几行放一起的对比Rss: 2400 kB Pss: 1200 kB Shared_Clean: 800 kB Shared_Dirty: 400 kB Private_Clean: 0 kB Private_Dirty: 1200 kB Swap: 0 kBPrivate_Dirty是最需要盯住的行——它表示这段映射里进程私有的、且被写过脏的页面。对于堆和栈来说Private_Dirty 就是真正的肉身因为它既不能被共享抵消也不能通过回收文件缓存补回来。匿名映射的 RSS 里属于 Private_Dirty 的部分才是你为进程自己付的内存账单。Shared_Dirty则常用于判断共享内存段有没有被写脏。如果两个进程通过共享内存通信双方的 smaps 里同一段映射都会有 Shared_Dirty 计数但物理页只有一份。所以从整机角度看应该以 Pss 或去重后的 Rss为准而不是简单相加。4.3 一步步定位内存泄漏分享一个我反复使用、成功率极高的三步滑坡排查法第一步抓基线。对可疑进程连续记录/proc/[pid]/smaps里的 Private_Dirty 总和每隔 5 分钟一次画一条趋势线。如果持续单边上涨基本可以放弃内存只是没还回 OS的幻想几乎可以断定是有真实泄漏。第二步缩小范围。用pmap -x PID | sort -k3 -n按 RSS 排序找出增长最快的映射段。如果是[heap]增长问题大概率在 malloc 层如果是某个.so的匿名映射增长可能是该库内部的缓存如果是文件映射增长一般是没做munmap或缓存了文件内容不释放。第三步用工具抓现场。堆泄漏的首选是 valgrind 的 memcheck虽然慢但精准指出每一块未释放但失去引用的内存是在哪一行申请的。线上环境跑不动 valgrind就退而求其次用 gdb 或 profiler 定期malloc_info导出分配 arena 的信息对比 chunk 总数变化。还有一招很实用watch -d cat /proc/loadavg没用你要看的是grep -E heap|anon /proc/[pid]/smaps配合-d高亮变化。内核自己也会占内存排查服务器整体内存不足时别忘了看/proc/meminfo里的 Slab 字段和/proc/slabinfo某些驱动或内核对象泄漏的迹象在那里非常明显。5. 内存压力下的裁决回收、swap 与 OOM 选择5.1 页的两种回收路径cache 先死匿名后死当系统内存吃紧时内核不会立刻随机杀人它有一套优先级明确的回收机制。第一波受害者是 page cache也就是文件读写的缓存页。这些页的内容来自磁盘脏了可以写回干净的直接丢弃重新读回来即可。内核维护着 LRU 链表把活跃与不活跃的页分开管理回收时优先牺牲不活跃缓存。如果文件缓存已经不够压榨了才轮到匿名页。匿名页没有磁盘备份回收只能靠 swap——把内容搬到交换分区或交换文件里。swap 的代价是极高的 IO 延迟所以内核在正常水位时基本不动匿名页只有在 high watermark 之上且分配压力大时才会开启 swap 路径。实际观察内存压力有个简单的窗口vmstat 1里 si/so 两列如果持续非零说明系统正在以秒级频率换入换出这个时候先别急着看哪个进程内存大要先确认是不是有进程在疯狂触碰内存边界再查它的 RSS 和 VmSwap。free命令里 available 列已经是内核帮你算好的还能扛多少的估算值比看 free 列靠谱得多。5.2 OOM Killer谁该死谁豁免当回收速度赶不上分配速度内核会启动终极裁决——OOM Killer。它不是随即抽签而是有一套评分逻辑进程占用的物理页越多、可回收的越少badness 分越高root 进程会稍微降权而最关键的是oom_score_adj这个可调参数默认 0范围 -1000 到 1000。oom_score_adj为 -1000 时进程得到最高豁免OOM Killer 永远不碰它。这个值是关键业务进程的最后保命符。我见过不少线上事故罪魁祸首其实是个无关紧要的日志收集进程但因为它的 RSS 很高被 OOM 选中而真正的主服务反而成了陪葬。如果事先把主服务的oom_score_adj调低把非关键的批处理任务调高至少能保证要死先死无关紧要的。还有一个值得知道的行为OOM 不只是整机内存耗尽才触发。进程分配物理页时如果 cgroup 的内存限制到了同样会触发 OOM这次的裁决范围是 cgroup 内进程与全局 OOM 相对独立。很多容器应用被杀的现场其实主因是 cgroup 限额而不是宿主机的整体压力。5.3 容器与 cgroup 视角下的内存错觉容器场景下进程的内存账又多了一层复杂性。cgroup v2 的memory.max控制的是进程集合的内存上限统计内容包括 RSS、page cache 等所有的物理页。但注意两个容器共享同一个宿主机的 page cache 页面时每个容器的统计里都会计入这些页——也就是说宿主机侧看到这些页只占一份容器侧看却是双份。另一个常见错觉是容器里free显示的内存余量远小于宿主机。因为容器看到的/proc/meminfo是宿主机的全局视图而 cgroup 的限额不等于宿主机只剩这么多。想真正知道容器内还能不能分配内存应该看memory.current与memory.max的比值而不是容器里 free 的 available 列。排查容器内存超限时要同时看两个维度的数据宿主机侧/proc/meminfo的 Slab、page cache、匿名页总量容器侧的 memory.events 里 oom 计次和 memory.stat 的各类型页分布。很多容器无故被杀的案例查到最后发现是 page cache 被算进了限额程序自身真实占用并不高。6. 想少踩坑记住这几个实测结论6.1 永远不要只信一个指标我在这篇文章里反复强调同一句话单个内存指标必然有盲区。看 VSZ会被超大映射吓到只看 RSS会被共享库重复计数骗到只看整机 free会被 page cache 的已使用状态误导。正确姿势是组合使用先看整机的/proc/meminfo再按进程排 PSS最后落到特定进程的 smaps 看 Private_Dirty 的趋势。多花两分钟换来的是一次线上事故的规避。6.2 THP 和 overcommit 是双刃剑透明大页THP默认开启对内存带宽密集型任务通常有好感但对 fork 密集的服务反而是负担——每次 fork 复制页表都要处理更多的大页条目分配大页时也可能触发更积极的回收。如果你的服务频繁 fork 子进程且内存占用大考虑用madvise模式手动控制只对确认需要大页的区域启用。vm.overcommit_memory同样不是越大越好。默认的 0 模式允许合理的超卖适合大多数工作负载切到 2 模式后系统严格执行地址空间也计入内存的策略数据库类应用经常在这上面翻车——明明物理内存够用mmap却返回 ENOMEM。修改前一定要清楚自己服务的分配模式。6.3 排查内存问题要建立时间线思维最后一句话也是我最想强调的内存问题十有八九是时间问题。单看某一秒的状态所有指标都正常连看五分钟你才能发现某个匿名映射在持续爬升。把观测脚本固定在 crontab 里定期抓取 smaps、meminfo、slabinfo 的快照是成本最低的监控方式。我自己的做法是每五分钟一次保留三天绝大多数疑难杂症都能从历史快照里还原出真相。等真出问题再临时开工具往往已经晚了。