前阵子帮一个朋友排查线上服务被"莫名其妙"杀掉的问题,他在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工具就是基于这个概念做统计的。下面这张表能帮你快速对应:
| 指标 | 全称 | 反映的问题 | 典型使用场景 |
|---|---|---|---|
| VSZ | Virtual Size | 虚拟地址空间映射总长 | 判断是否存在超大映射区间 |
| RSS | Resident Set Size | 进程独占+共享的物理页总和 | 日常监控、预估单进程开销(偏乐观) |
| PSS | Proportional Set Size | 按共享比例分摊后的物理页 | 多进程/容器场景下的真实占用评估 |
1.2 共享内存与私有内存的纠缠
RSS 之所以容易"重复计数",核心原因是共享。用 C 写过一个最简单的printf("hello")程序,ldd看一眼就知道,glibc、动态链接器这些基础库几乎每个进程都会映射。假设 libc 占 2MB 物理页,系统里跑 500 个进程,在 RSS 口径下它被计了 500 次,整机 1GB 的"内存消耗"就这么虚增出来了。
除了显式的共享库,还有一个容易被忽略的共享来源——父子进程之间的 copy-on-write 共享。fork()出来的子进程,在最初这一刻并没有复制父进程的物理页,它只是把父进程的页表复制了一份,所有页面都被标记为只读。任何一方尝试写入时,内核才真正复制页面并重新映射,这就是 COW(Copy-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 break,brk把它向上抬,就得到了一块连续的堆区;向下压就释放。优点是一个区间搞定所有小对象,资源开销低;缺点是释放不灵活——从堆中间释放的对象无法立刻还给内核,只能由 malloc 自己管理成空闲链表,这引出了著名的碎片问题。
mmap则完全不同,它每次创建一段独立的虚拟映射,生命周期独立,munmap可以整体释放。glibc 的 malloc 实现里有一条经验阈值:默认超过 128KB(MMAP_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_memory=2,内核会在映射阶段就严格拒绝超量申请,懒分配的空间就要摸着钱包过日子了。
3.3 页表多级翻译与 COW 之后的物理真相
x86-64 上使用四级页表:PGD → PUD → PMD → PTE。每次地址翻译要依次索引这四级,最终拿到物理页框号,再叠加页内偏移。多级结构节省内存的原因很简单:地址空间大部分是空洞,按需创建中间层级,而不是给所有地址都建一张平铺大表。但也因为多级,翻译一次要多次访问内存,所以 CPU 引入了 TLB(Translation Lookaside Buffer)缓存最近用过的翻译结果。
TLB 命中率对性能影响极大,这也解释了为什么大页(HugePage)那么诱人。2MB 的 THP 页面让一次 TLB 覆盖的范围比 4KB 基础页大 512 倍,翻译次数大幅下降。但坏处也很明显:分配大页要连续物理内存,页表回收和碎片整理压力大,某些场景下反而引发延迟尖刺。
再回到 COW。fork()出来的子进程拿到的是父进程页表的拷贝,但所有可写页面被标成只读。父或子任意一方写页面,都会触发保护错误,内核在异常处理里把页面复制一份,再分别给两个进程映射成可写。这套机制让 fork 的执行成本低到与"实际有多少数据"无关,而与"有多少映射项"有关,是绝大多数多进程服务愿意用 fork+exec 模型的前提。代价是:页表复制本身要花时间,进程地址空间越大,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 的快照,是成本最低的监控方式。我自己的做法是每五分钟一次,保留三天,绝大多数疑难杂症都能从历史快照里还原出真相。等真出问题再临时开工具,往往已经晚了。