深入理解 Linux 内核内存管理:从虚拟化到页面回收的完整实战

Linux 内核的内存管理子系统是整个操作系统中最复杂、最精妙的模块之一。它不仅要管理物理内存的分配与回收,还要为每个进程提供一个独立、安全、连续的虚拟地址空间幻觉。理解这套机制,是排查 OOM 杀进程、定位内存泄漏、优化数据库/缓存类应用性能的基础。本文将沿着 "虚拟地址 → 物理页面 → 分配器 → 回收 → 调优" 的主线,系统拆解 Linux 内核内存管理的核心原理与生产实战方案。

一、虚拟内存:进程看到的世界

每个进程都拥有一段巨大的虚拟地址空间。在 64 位 Linux 上,这个空间高达 128TB(用户态)+ 128TB(内核态)。但对进程而言,这些地址并不直接对应物理 RAM——它们需要经过内存管理单元(MMU)和内核页表的翻译,才能变成真正的物理地址。

1.1 为什么需要虚拟内存

虚拟内存解决了三个根本问题。第一,进程隔离:进程 A 的地址 0x400000 和进程 B 的地址 0x400000 映射到不同物理帧,互不干扰。第二,内存超分配:进程可以 malloc 1GB 物理上不一定立即分配,只有在实际写入时才通过缺页中断触发物理页分配(Lazy Allocation / Overcommit)。第三,内存映射统一抽象:文件、设备、匿名内存都通过同一套 mmap 接口暴露,读写文件变成读写内存。

1.2 五级页表结构

x86_64 架构下,Linux 使用四级(早期)或五级(5.0+,LA57)页表翻译虚拟地址。以五级页表为例,一个虚拟地址被拆分为 6 个字段:


| PGD | P4D | PUD | PMD | PTE | 页内偏移 |
|55  48|47  40|39  30|29  21|20  12|11     0|

每一级页表都有自己的基址寄存器指向(PGD 由 mm_struct->pgd 指向),最后一级 PTE 产生物理页帧号(PFN)。五级页表将虚拟地址空间从 256TB(四级)扩展到 128PB(五级),解决大内存机器寻址需求。

实际操作中 Linux 对 PGD/P4D 做了折叠优化——在硬件只支持四级时,P4D 这一级实际只有一个条目,让代码保持统一的五级接口。这个细节在阅读内核源码(arch/x86/include/asm/pgtable.h)时尤为重要。

二、物理页面的分配器

物理内存管理的核心问题是:在有限物理内存中,高效地分配不同大小页面,同时尽量降低碎片化。Linux 通过两层架构解决这个问题。

2.1 伙伴系统(Buddy Allocator)

伙伴系统管理以页面(4KB 或更大)为单位的物理内存。将所有空闲页面按 2 的幂次分组(order-0 到 order-10,即 4KB 到 4MB 连续块)。当请求分配时,从合适的 order 链表中取出一个块;若不够大,就把大块一分为二,取走需要的那另一半。释放时,如果"伙伴"块也空闲,就合并成更大的块。

伙伴系统有两个致命弱点。第一是内部碎片:申请 5KB 必须给 8KB(order-1),浪费 3KB。第二是外部碎片:经过长时间分配释放后,内存被散的 4KB 小块占据,无法分配大块(DMA 设备经常需要连续物理页)。为解决外部碎片问题,Linux 引入了反碎片(Anti-Fragmentation)机制,将页面分为不可移动(MOVABLE)、可回收(RECLAIMABLE)、可迁移(MOVABLE 内核数据)三种迁移类型,让同类页面聚集,降低连续分配失败的概率。

2.2 SLAB / SLUB 分配器

伙伴系统的粒度太粗(最小 4KB),内核中大量需要小于 page 的对象(如 task_struct、inode、dentry)。SLAB 分配器(及其后继 SLUB)就是基于伙伴系统的二级分配器,专门缓存和管理这些内核小对象。

SLUB(自 2.6.23 起作为默认)的核心思想是每 CPU 缓存 + slab 队列:每个 kmem_cache 维护一个 per-CPU 本地 slab 和自由对象链表。分配时优先从 CPU 本地缓存拿,避免加锁;释放时也优先放回本地缓存。只有在本地缓存耗尽时才向 buddy 分配新 slab,或在本地铁链过多时归还给伙伴系统。

SLUB 的精妙之处在于它的数据结构平衡了内存利用率和分配速度。每个 slab 就是一个 page 或多个连续 page,内部将空闲对象的指针直接写在空闲内存里,不需要额外的管理结构。这让管理的内存开销降到接近零。

三、进程地址空间:mm_struct 和 VMA

每个进程通过 struct mm_struct 完整描述自己的虚拟内存空间。其中,mmap 字段指向一个按地址排序的红黑树/链表的 VMA(Virtual Memory Area)节点。每个 VMA 代表一个连续的虚拟地址区间,具有统一的权限(rwx)、映射类型(文件/匿名)和操作函数(vm_ops)。

3.1 malloc 的背后:brk vs mmap

C 库 malloc 在分配小对象(< 128KB 默认阈值)时通过 brk/sbrk 系统调用扩展数据段(program break);大对象则直接通过 mmap 匿名映射获取一块独立 VMA。这个 128KB 阈值可以通过 mallopt(M_MMAP_THRESHOLD)调整。

brk 分配的内存有一个关键特点:因为它只是在扩展数据段的最末端,所以不能单独释放中间的块,只能从末尾收缩。大量小对象分配-释放后容易在 brk 区域产生"空洞"(内存碎片),即使总量足够也无法继续 brk 扩张。这也是为什么长时间运行的进程(如 Redis 内存碎片问题)需要 jemalloc 或 tcmalloc 等替代分配器,它们用 mmap 管理小块内存,避免 brk 带来的碎片。

3.2 缺页中断:虚拟到物理的桥梁

当一个进程访问尚未映射到物理页面的虚拟地址时,CPU 触发缺页中断(Page Fault),进入内核的 do_page_fault → handle_mm_fault 路径。根据触发原因,缺页中断有三种类型:

第一,Demand Paging(按需分页):mmap 了文件但未建立页表映射,访问时真正从磁盘读入页面(文件-backed page),加入页缓存(page cache)。第二,COW(写时复制):fork 时父子共享同一物理页且都标记为只读,任一方写入时触发缺页,内核才真正拷贝一份新页。第三,Demand Zero(零页分配):访问匿名映射但尚未分配物理页时,分配全 0 的物理页填入。

四、页面回收与交换

物理内存不是无限的。当系统空闲页面低于阈值时,kswapd 后台回收线程会醒来,回收不活跃页面,为后续分配腾出空间。

4.1 双 LRU 链表:活跃与不活跃

内核将所有内存页面维护在四条 LRU 链表(Active/Inactive × File/Anonymous)中。当页面被访问时,PG_referenced 标志位置位;在第二次扫描时如果仍被访问,则从 Inactive 提升到 Active。这种二次机会(Second Chance)算法避免了"最近只访问一次就不回收"导致的工作集抖动。

回收时,内核从不活跃链表尾部逐页回收:文件页直接丢弃(因为有磁盘原件可回退);匿名页则写入 swap 设备(交换出去),需要先分配 swap 槽位,产生物理 I/O。这就是为什么 swap 会导致性能断崖——一旦匿名页开始被回收,说明内存已经紧张,而访问这些被换出的页面时又触发缺页,重新从 swap 读回来。

4.2 水位的层次:min / low / high

每个内存区域(zone / cgroup)都有三个关键水位:pages_min、pages_low、pages_high。当空闲页低于 pages_high 时,kswapd 开始工作;降到 pages_low 时,回收变得更激进;降到 pages_min 时,分配者自己必须同步回收(direct reclaim),这对应用而言是性能灾难。因此生产系统应确保内存水位绝大部分时间在 pages_high 之上。

4.3 swap 与 zswap 的权衡

传统 swap 在磁盘上操作,延迟在毫秒级。Linux 4.8 引入 zswap:在内存中维护一个压缩的 swap 缓存。换出页面时不立即写磁盘,而是先压缩存入 zswap 内存池;只有当 zswap 池满或者压缩率太差时,才落盘。这大幅减少了低内存时的磁盘 I/O,适合 SSD 寿命敏感或需要低延迟的容器环境。

五、OOM Killer:最后的安全阀

当页面回收也无法释放足够内存,同步分配仍失败时,内核只能祭出最后一招:OOM Killer(Out-Of-Memory Killer)。它会选择一个"最该死"的进程,通过 SIGKILL 杀掉,释放其占用的全部页面。

oom_badness() 评分公式:进程占用的物理内存(RSS + swap + 页表 + 内核开销)乘以 1000 的 oom_score_adj 作为权重。简单说:占用内存越多、oom_score_adj 越高,越容易被杀。管理员可以通过 /proc/<pid>/oom_score_adj 调整进程的"被杀死优先级"(-1000 表示永不杀)。

在 cgroup v2 中,可以通过 memory.max + memory.oom.group 将 OOM 作用域限制在单个 cgroup 内,避免"杀错进程"。

六、内存压缩与 THP

6.1 透明大页(THP)

现代 CPU 支持 2MB(或 1GB)大页,可以减少 TLB 命中失败率,降低页表遍历开销。Linux 通过 khugepaged 后台线程,将进程地址空间中符合条件的连续小页合并成 2MB 大页。

但 THP 并非银弹:数据库(如 PostgreSQL、Oracle)强烈建议关闭 THP,因为大页分配失败时触发直接 compaction,导致延迟尖刺;Redis 也建议关闭。因此在生产环境中,要根据应用特征选择:MADV_HUGEPAGE(显式大页)适合内存数据库的固定大页区域(libhugetlbfs);THP 默认的"总是开启"策略更适合通用计算负载。

6.2 内存紧凑(Compaction)

长时间运行后,伙伴系统可能无法分配到大块连续物理页。kcompactd 或 direct compaction 将已分配的页面迁移,让空闲块合并。迁移可迁移页(用户空间、可回收缓存页)很简单——只需更新页表指向新物理地址;不可迁移页(内核对象、mlock 的页)成为紧凑的障碍物。这就是为什么迁移类型(MIGRATE_TYPES)的反碎片设计至关重要。

七、生产调优实战

7.1 核心 sysctl 参数

  • vm.swappiness(默认 60):控制匿名页和文件页的回收倾向。设为 0 表示"尽量避免 swap"(除非无文件页可回收);设为 100 表示积极交换匿名页。Redis/数据库通常设为 0 或 1,HPC 计算设为 100。注意:swappiness=0 在某些老内核版本中可能导致 OOM 于 page cache(见 per-zone reclaim 逻辑变化)。
  • vm.dirty_ratio / vm.dirty_background_ratio:控制 page cache 中脏页比例。超过 dirty_background_ratio(默认 10%)时内核后台刷盘;超过 dirty_ratio(默认 20%)时进程写操作被阻塞同步刷盘。高写入负载场景可适当提高 dirty_background_ratio(30%-40%),让 IO 更平滑。
  • vm.min_free_kbytes:强制保留的最小空闲内存。默认值按总内存算的平方根而来,大内存机器(512GB+)这个值会非常大。适当提高(如 1048576 = 1GB)可降低 direct reclaim 概率。
  • vm.overcommit_memory:0 = 启发式检查,1 = 永远允许(适合科学计算),2 = 严格不超出 CommitLimit(Swap + RAM × overcommit_ratio)。数据库建议设为 2 避免 OOM。
  • vm.overcommit_ratio(默认 50):仅在 mode=2 时生效,CommitLimit = Swap + RAM × ratio%。

7.2 cgroup v2 内存控制

cgroup v2 为容器提供了精细的内存控制。memory.max 限制硬上限,memory.high 触发回收压力(异步回收),memory.min 保证最低预留(不会被其他 cgroup 挤占)。在 Kubernetes 中,request 对应 memory.min 或 memory.low,limit 对应 memory.max。

7.3 排查内存泄漏的黄金链路

发现某个进程 RSS 持续增长时,排查流程如下:

  1. 确认趋势:top 查看 RES,或 smem -s rss 排序。
  2. 看 VMA 分布:pmap -x <pid> 或 cat /proc/<pid>/smaps。关注巨大的 [anonymous]、[heap] 或 [stack] 区域。
  3. 看 slab 占用:slabtop。如果 dentry/inode_cache 持续增长,很可能是文件操作未关闭 fd 导致。
  4. 用 valgrind / ASAN:开发环境精准定位泄漏代码行。
  5. 用 bpftrace 跟踪:
    bpftrace -e 'kretprobe:do_anon_vm_fault { @[comm] = count(); }'
    统计每个进程的匿名页异常分配速率。
  6. 看 cgroup 压力事件:memory.pressure 文件,了解回收压力是否持续触发。

八、总结

Linux 内存管理的设计哲学是"延迟与交换":尽可能推迟物理页分配(缺页按需分配),尽可能用交换代替直接拒绝(Overcommit + swap),尽可能让缓存页平滑回收(LRU + 水位)。理解每个环节的触发条件和代价,才能在面对 "OOM 了"、"swap 爆了"、"内存碎片导致分配失败" 等问题时快速定位。

整套知识链路:mm_struct → VMA → 页表 → 物理页面 → Buddy → SLUB → LRU → kswapd → OOM。顺着这条线查下去,90% 的内存问题都能找到根因。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部