上一篇我们把伙伴系统和 slab 分配器的骨架过了一遍,后台留言里问我最多的问题集中在同一个点上:分配出来的这些物理页,到底是怎么进到进程地址空间的?与其零散地回,不如用这一篇“内存管理(2)”把整条链路完整串起来。我会站在阅读 Linux 内核源代码的角度,从一次普通的 malloc 调用说起,跟着一次真实的页面访问,走完虚拟地址映射、缺页异常、页表组装、物理页分配以及内存回收的全程。这篇文章适合两类朋友:一类是正在啃内核源码、被 mm_struct、vm_area_struct、PTE 这些结构体绕晕的学习者;另一类是平时要排查内存问题,想真正搞懂 free、vmstat、/proc/meminfo 背后硬逻辑的开发者。读完你会发现自己再看内存指标时,脑子里不再是一堆孤立数字,而是一幅完整的运行图。
1. 先把主线串起来:从虚拟地址到物理页的完整链路
1.1 为什么这第二部分要从缺页开始讲
很多讲内存管理的文章喜欢从硬件分页机制讲起,第一篇我也是这么安排的。但这次我建议换一个视角:先从进程视角出发,看 malloc 返回之后,程序访问这块内存时,内核到底发生了什么。
关键点在于,malloc 返回一个指针,并不等于物理页已经分配好了。至少在绝大多数 Linux 系统上,标准 C 库的 malloc 大块内存时会走 mmap 系统调用,小块内存则通过 brk 扩展堆区。无论哪种方式,内核在第一时刻只做了一件事:在进程地址空间里建立一个新的 VMA(虚拟内存区域),记录起始地址、结束地址、访问权限这些元数据。真正和物理页产生关联,要等到进程第一次去读或写这块地址时,CPU 触发缺页异常,内核在异常处理路径里完成物理页分配、页表项填充。
这就是虚拟内存系统设计的核心经济性:按需分配。虚拟地址是一个几乎不需要成本的“承诺”,物理页是稀缺资源,内核不会提前兑现。理解了这个基本原则,后面看缺页、看 page fault、看 RSS 变化,就都有了抓手。
1.2 需要先认识的三个关键角色
整条链路里离不开三个核心对象,我习惯把它们称为“账本三件套”。
- struct vm_area_struct:描述一段连续的虚拟地址区间,对应进程地址空间里的一个区域,内核用红黑树加链表双重组织。
- struct page:描述一个物理页帧的元数据,注意它只是物理页的“账本”,不是物理内存本身。
- 页表项 PTE:虚拟地址到物理地址的最终翻译结果,也是缺页异常发生时最先被检查的对象。
这三个角色之间的关系可以用一个现实场景类比:VMA 像地产开发商手里的地块规划图,只说明哪块地打算怎么用;page 像不动产登记簿里每一块土地的信息;PTE 则是具体的门牌号映射,告诉你这栋楼到底落在哪块地上。
在内核源码里,VMA 相关逻辑集中在 mm/mmap.c,struct page 的定义在 include/linux/mm_types.h,页表相关操作在 arch/x86/mm/fault.c 和 mm/memory.c。建议阅读时把这三个文件放在手边,对照着看。
2. 缺页异常:内核把按需分配做到了极致
2.1 缺页类型与处理入口
缺页异常是所有应用内存行为的最终汇聚点。用户态进程访问一个地址,MMU 发现对应的页表项为空、权限不对或者页不在内存中,就会触发一次异常,CPU 进入内核的缺页处理函数。
按原因区分为两类,性能表现完全不同:
- minor fault(小缺页):虚拟地址没有物理页映射,但页的内容可能已经在内存里。比如第一次访问匿名内存、写时复制、页只是表项缺失。处理成本低,通常几十微秒级别。
- major fault(大缺页):数据不在物理内存里,必须从磁盘、SSD 或者其他后端设备读入。比如访问一个尚未加载到 page cache 的 mmap 文件页。处理成本高,可能几毫秒甚至更久。
源码路径大致是:架构相关的异常入口(x86 里对应 exc_page_fault / do_page_fault)调用 handle_mm_fault,后者逐级遍历页表,最终进入 handle_pte_fault,在这里根据 PTE 当前状态分流。不同内核版本命名略有差异,但主干逻辑基本不变。
2.2 匿名页面缺页:从零页到写时复制
当进程访问一段没有文件背景的内存,比如 malloc 出来的匿名区时,缺页处理会走到匿名页分支。内核先看这段 VMA 是否带 VM_WRITE、VM_SHARED 这些标志,然后进入 do_anonymous_page。
第一次读取这段地址时,内核有一个人性化的优化:把整页全零的情况映射到一个全局共享的零页上,并且只赋予只读权限。这样如果进程只是读一读,所有进程都可以共享同一个零页,物理内存一点不浪费。一旦进程要做写操作,就会触发写保护页错误,内核在写时复制路径里分配一个新页,拷贝内容,再更新 PTE,这就是写时复制(COW)技术。
fork 之后的父子进程也走这个机制。fork 时子进程拿到父进程的完整页表副本,但所有可写页的 PTE 都被清掉写权限;任何一方先写,就各自复制出独立的物理页。这样做的好处极其明显:fork 一个大型进程时不需要复制全部物理内存,只复制页表,创建开销和内存占用都会大幅下降。
2.3 文件映射缺页:page cache 与回写路径
访问 mmap 映射的文件区间时,缺页处理会走文件映射分支。以最常见的私有映射为例,第一次读某个文件页,PTE 是空的,内核走到 do_fault,进一步细分为三种情况:
- 缺数据时,通过文件系统的 readpage 方法把磁盘内容读入 page cache,然后建立映射;
- 私有可写映射发生写操作,需要先分配一个匿名页,把文件页内容拷贝进去,之后对这个页的修改就不再回写文件,这就是 COW 语义;
- 共享映射发生写操作,会调用文件的 page_mkwrite 方法,通知文件系统“这个页要被改了”,后续可能涉及日志处理和脏页跟踪。
文件映射很重要的一个特点是多进程共享 page cache。两个进程映射同一个文件时,磁盘上的同一份数据只会有一份物理页缓存,各进程页表项指向同一个 struct page。这就是服务类进程做共享内存读时内存占用看起来很低的原因:大家复用同一批物理页。
3. 物理页分配器:水印、慢路径和碎片
3.1 快速路径里发生了什么
当缺页处理需要真正拿一个物理页时,最终会调用页分配器的核心函数。以常见顺序为例:alloc_pages 最终进入 __alloc_pages_nodemask,先在快速路径里尝试从 zone 的伙伴系统取页。
取页动作的实际执行者是 rmqueue。对 order=0 的单页分配,它优先从当前 CPU 的 per-cpu pageset 里取。每个 CPU 维护自己的空闲页列表,这样多数分配可以不碰 zone 级别的全局锁,并发性能大幅提升。pageset 内部又把页分为热页和冷页:热页表示刚释放、还在 CPU 高速缓存里的页,优先分给同样的 CPU 使用;冷页适用于那些即将被设备读写、反正缓存也要失效的场景。
快速路径通过 get_page_from_freelist 遍历节点的 zonelist,逐个 zone 检查水位。水位检查函数 zone_watermark_ok 会评估当前空闲页减去本次分配所需页数后,是否仍满足目标水印。如果某个 zone 满足,就从它的伙伴系统摘出对应阶的空闲块;找不到更高阶块时,会从高一级阶分裂。
3.2 水印系统:为什么内核宁可留有余量
每个 zone 维护三个关键水位:WMARK_MIN、WMARK_LOW、WMARK_HIGH。它们不是摆设,而是整个内存分配与回收节奏的控制线。
- WMARK_MIN:最低保障线。分配到这条线以下时,只有带紧急标志的分配才允许继续,普通分配必须进入慢速路径。
- WMARK_LOW:低水位线。空闲页低于这条线时,分配路径会唤醒对应节点的 kswapd 内核线程去后台回收;快速路径正常使用这个水位判断可否直接分配。
- WMARK_HIGH:高水位线。kswapd 回收一段时间后,把空闲页补回到这条线以上,才停止扫描。
WMARK_MIN 的基准值来自 vm.min_free_kbytes 这个内核参数。调大它会让系统保留更多物理页不被分配,看起来内存变“少”了,但在网络收包、中断处理这类不能睡眠的分配路径里,这些保留页往往是救命稻草。调过头也会造成浪费,我见过有人把 min_free_kbytes 改成数 GB,业务内存充足但可用内存始终上不去,页面分配频繁进入慢路径,延迟反而恶化。
3.3 慢速路径:直接回收、内存规整与 OOM
如果快速路径在 zonelist 里都找不到满足水印的 zone,就会进入 __alloc_pages_slowpath。这个函数内部干了三件大事。
第一,判断当前 gfp_mask 是否允许回收和迁移。普通内核分配通常带 __GFP_RECLAIM,于是可以尝试唤醒 kswapd,并且等待一段时间。第二,尝试内存规整(compaction)。碎片化会导致高 order 分配失败,规整会把可移动页搬运到 zone 的一端,腾出连续的大块物理内存,这对 THP 分配特别重要。第三,如果规整和异步回收都补不上缺口,只能走直接回收路径,当前线程自己停下来参与页扫描、页写出,直到凑够页面或真正失败。
直接回收是很多“突发卡顿”的根源:业务线程本意只是分配一个页,结果被阻塞在回收路径里,一次分配可能耗时数毫秒甚至更久。最后如果仍然失败,并且分配类型允许,内核进入 out_of_memory,OOM killer 挑选一个进程结束它,释放内存。
3.4 用两个 proc 文件直观察碎片
碎片化一直是伙伴系统最头疼的问题。好在系统提供了直接观察的接口:/proc/buddyinfo 按 zone、按 order 打印空闲块数量,order 越大代表连续页越多;/proc/pagetypeinfo 按迁移类型统计空闲页分布。
迁移类型包括不可移动、可回收、可移动、CMA 等几类。内核把不同属性页分类放置,防止不可移动页长期卡在可移动页区域里,影响大块连续分配。观察这两个文件,如果 order=9 以上的块长期为零,而低阶块很多,说明碎片化比较严重,系统已经很难分配连续大页。
4. slab 与 vmalloc:分配器家族里的两条补充腿
4.1 kmem_cache:小对象为什么不能直接找伙伴系统
如果每次分配文件对象、inode、dentry 都从伙伴系统拿一整页,浪费会非常严重。一个页 4096 字节,存放几十个上百字节的小对象绰绰有余,但每次都拆整页,伙伴系统里的碎片和开销都不可控。
解决办法是 slab 分配器族。内核通过 kmem_cache_create 为常用的对象类型创建专用缓存,比如 struct file、struct inode。每种缓存按对象大小把页切成固定大小的槽位,分配时从一个空闲对象链表取一个,释放时归还给缓存。主流的 SLUB 实现里还加了 per-cpu partial 列表,每个 CPU 优先从自己的局部缓存取对象,避免锁竞争。
查看 /proc/slabinfo 可以看到每种缓存分配了多少对象,其中 active_objs 是真实被使用的对象数量,num_objs 是缓存已切出来的对象总量。很多人看到 free 命令里内核占了很多内存,就以为是异常,其实很大部分是 slab 缓存里的可重用对象。它们不算泄漏,如果内存紧张,内核会从各缓存回收空闲对象。
4.2 vmalloc:非连续不等于低效
vmalloc 解决的是另外一类需求:分配一段虚拟地址连续、但物理地址不需要连续的较大区域。它把虚拟地址空间里某一段区间映射到若干分散的物理页上,靠页表把碎片拼起来。
代价在两方面:一是每次 vmalloc 都要为这段区间建立独立的中间页表级和最终页表项,开销比 kmalloc 大很多;二是页表不连续会让 CPU 的 TLB 命中率下降。所以 vmalloc 适合模块加载、帧缓冲、某些驱动和 IOMMU 场景的内存,不适合对性能敏感的热路径。
/vproc/vmallocinfo 列出了当前所有 vmalloc 区域的大小、调用者信息。如果怀疑“内核虚拟地址空间被耗尽”,这个文件是最直接的排查入口。
5. 内存回收:当内核开始翻旧账
5.1 LRU 组织:active/inactive 与 file/anon 的双重划分
内存不够时,内核要把不常用的页挪出去。这里按 LRU(最近最少使用)思想组织页面,但实现并不是一条简单链表。每个内存节点维护多条 LRU 链表:inactive_anon、active_anon、inactive_file、active_file,还有 unevictable 列表。
为什么要分成 active 和 inactive 两层?因为单层 LRU 容易误杀:一个页刚被扫描过,下一瞬间又被访问,如果直接回收,就会产生一次 major fault,可能比回收省下的内存代价更大。两层设计允许刚触达过的页先进 inactive,经过一轮扫描仍活跃再加到 active,只有 inactive 尾部的页才容易被回收。文件页和匿名页分开则是因为两者回收代价不同,匿名页可能还要写回 swap,文件页直接丢弃干净页即可,内核需要区分对待。
5.2 kswapd 后台回收与直接回收的现场
回收动作主要由两个角色执行。第一个是每个内存节点上的 kswapd 内核线程。当分配路径发现某 zone 空闲页低于 WMARK_LOW 时,会唤醒节点对应的 kswapd 去回收,目标是把空闲页补回到 WMARK_HIGH 以上。kswapd 通过 shrink_node 遍历节点里的 LRU 链表,逐页判断引用计数、脏页状态,能回收的直接回收,需要写回的交给回写机制。
第二个角色是调用者自己执行直接回收。发生在 kswapd 来不及补水位、分配请求又急需内存时。直接回收最明显的特征是分配延迟抖动,并且 CPU 使用率会升高,因为回收路径本身在消耗 CPU。观察 /proc/vmstat 里的 pgscan_direct、pgsteal_direct、pgscan_kswapd、pgsteal_kswapd 这几项,能很快判断回收压力来自后台线程还是前台分配路径。
5.3 从指标反推系统状态
内存压力可以从几个文件同时读取,互相对照定位。
- /proc/meminfo:MemAvailable 是内核综合估算的“真正可分配”内存,它考虑了预留水位和回收成本。free 命令里显示的 available 字段就来自这里。
- /proc/vmstat:关注 pgfault、pgmajfault 的增量,如果 pgmajfault 持续陡增,说明大量页被换出或页面缓存被过度回收。
- /proc/pressure/memory(PSI):会输出 some、full、avg10、avg60、avg300 几组数值,full 指标高说明有进程长时间等待内存,属于严重压力。
还有一个常年被误读的参数 vm.swappiness。它不是内存换出开关,而是调整匿名页和文件页在回收时的扫描权重。把它调成 0 也不代表不 swap,只是让内核优先回收文件页;真想禁用 swap 得用 swapoff。
6. 动手观测:一次缺页异常全程直播
6.1 一个最小复现程序
为了把上面的概念落到现场,我写了一个 1GB 匿名内存按页触达的程序,每 4KB 写一个字节,确保触发真实的逐页缺页。
#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <sys/mman.h> #include <unistd.h> int main(void) { size_t len = (1UL << 30); /* 1 GiB */ char *p = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) { perror("mmap"); exit(1); } fprintf(stderr, "mapped at %p\n", p); for (size_t i = 0; i < len; i += 4096) { p[i] = 0x5A; } munmap(p, len); return 0; }编译后运行前,先确认系统里没有过大内存压力,避免被自动回收干扰。这个程序会在一两秒内完成 26 万多次缺页操作,非常适合抓现场。
6.2 用 perf 和 tracepoint 抓取现场
内核的 perf 工具可以直接统计缺页事件。运行:
$ perf stat -e page-faults,minor-faults,major-faults ./demo在一台普通机器上的示意输出大致是:
Performance counter stats for './demo': 262,146 page-faults 262,146 minor-faults 0 major-faults全部是 minor fault,因为匿名页不需要读磁盘,每次分配一个全新的零页即可。major fault 为 0 符合预期。如果想看得更细,可以列出与缺页相关的 tracepoint:
$ perf list | grep page_fault不同内核版本的事件名略有差异,有的是 exceptions:page_fault_user,有的是 page-faults。用 perf stat 跟着事件名直接采集即可。
6.3 为什么第二次访问会快很多
如果把循环改成两次触达,第一次仍然会有全量 minor fault,第二次则几乎不会再有缺页,因为 PTE 已经建立,物理页已经映射。这正是缓存的意义:页表中已经写好的映射关系让后续访问不需要再进内核异常路径。
用观测数据说话,可以在循环里读取 /proc/self/status 的 VmRSS 值。第一次触达过程中 RSS 持续爬升,最后停在接近 1GB 的位置;第二次触达时 RSS 不再变化。这里面就能直观理解 RSS 的含义:它不是程序申请的虚拟内存大小,而是实际映射并驻留的物理页总量。
7. 常见问题与排查实录
7.1 内核占用内存越涨越高,到底是谁在申请
有一次我排查某台机器,free 命令里 buff/cache 项并不高,但是内核内存项持续爬升。第一步先看 /proc/slabinfo 和 slabtop:
$ slabtop -o按 Active 排序后发现 kmalloc-512 缓存对象数异常高,active_objs 持续增长不回落。继续用 /sys/kernel/debug/slab/kmalloc-512/alloc_calls 打开追踪,看调用栈,定位到某个内核模块在每次创建连接时申请了 512 字节的临时对象,但由于错误路径没有释放,对象越攒越多。这类问题靠顶层指标很难看到,必须逐层下挖到 slab 缓存粒度。
7.2 内存明明够,进程却被 OOM 杀
整机内存还有一半剩余,但某业务进程被 OOM killer 结束。这种场景现在非常普遍,原因大多不在整机,而在 cgroup 限制。当进程运行在 cgroup v2 的 memory.max 约束下,该 cgroup 内部内存用量触顶时,内核会对这个 cgroup 触发 memcg OOM,杀掉其中得分最高的进程,而整机可能完全无压力。
排查时会发现 dmesg 日志里明确写着 memory cgroup 相关的 OOM 信息,而不是正常的整机级 out_of_memory。这类问题核对业务的 memory.max 设置就能确认。解决方向是适当抬升限制、拆分实例,或者在业务层做更平滑的内存高水位反压,而不是把注意力放在调整机内核参数上。
7.3 我踩过的几个内核内存调参坑
把 vm.min_free_kbytes 调大,本意是防止紧急分配失败,结果系统内存大量被“锁死”,业务进程反而陷入更频繁的慢速分配。后来我意识到,保留水位改变的是分配策略,不是内存泄漏,调参前必须先量化业务真实内存模型。
把 vm.drop_caches 当救火命令用,也只是权宜之计。echo 3 到 drop_caches 能清掉干净页缓存、dentry 和 inode 缓存,但它既不回收匿名页,也不消除真正的内存压力来源。内存紧张时先看回收指标,比盲目刷缓存更有意义。
最后还有 swappiness。它并不像很多人以为的那样设成 0 就完全不 swap,它影响的只是扫描时的侧面权重,真正要禁绝对 swap 必须 swapoff。在内核内存管理问题上,任何参数都不是拍脑袋调出来的,都要有监控数据做支撑。
说到这,我个人的体会是:排查内存问题,不要总盯着 free 一列数字发呆,先在 /proc/vmstat 里看 pgfault、pgmajfault、pgscan_direct、pgsteal_direct 这些计数怎么滚动,判断问题是集中在分配路径还是回收路径。这篇内存管理(2)把缺页、分配、回收串到了同一张地图上,往后你再遇到任何内存相关诡异现象,至少知道该往哪条路径里钻了。