前言:为什么理解Linux仍然重要
在云计算、容器化和边缘计算的今天,Linux 内核几乎无处不在。从你的手机到全球排名前 500 的超级计算机,无一不在 Linux 上运行。而内存管理作为 Linux 内核的核心子系统之一,直接决定了系统的性能、稳定性和响应速度。无论你是系统管理员、后端开发,还是 DevOps 工程师,深入理解 Linux 的内存管理机制都是通往高级技术能力的必经之路。
这篇文章将从内核源码级别剖析 Linux 的内存管理子系统,涵盖物理内存管理、虚拟内存机制、页面分配器演进(伙伴系统 → SLUB)、交换与回收机制、以及当今热门技术在内存子系统中的实现。
一、物理内存管理:从 NUMA 到 memblock
1.1 NUMA 架构下的内存拓扑
现代服务器通常采用 NUMA(Non-Uniform Memory Access)架构,每个 CPU 节点拥有本地内存,同时可以访问远程节点内存,但访问延迟更高。Linux 内核通过 pg_data_t 结构体来描述每个 NUMA 节点的内存信息:
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 内存区域
struct zoneref node_zonelists[MAX_ZONETYPES]; // 分配区域列表
int nr_zones; // 区域数量
struct page *node_mem_map; // 页面描述符数组
unsigned long node_start_pfn; // 起始页帧号
unsigned long node_present_pages; // 可用页面总数
unsigned long node_spanned_pages; // 跨度页面总数
} pg_data_t;
在 x86_64 架构下,每个物理页帧都由一个 struct page 描述(存储在 mem_map 数组中),每个 struct page 大约 64 字节。这意味着每 1GB 物理内存需要约 16MB 的 struct page 元数据空间和 4MB 的页表开销。
1.2 memblock → buddy 的演进
内核启动早期阶段使用 memblock 作为物理内存分配器,它简单但低效。当核心内存子系统初始化完成后,memblock 会将所有物理内存交给伙伴系统(Buddy System)管理,随后自身被标记为"dead":
// 内核启动流程
start_kernel()
└── setup_arch()
└── mm_init()
└── memblock_alloc()
└── buddy_system_init() // 接管物理内存管理
└── memblock_free_all() // 释放 memblock 保留的内存
二、伙伴系统:物理页分配的核心
2.1 算法原理
伙伴系统的核心思想是将物理内存划分为不同阶(order)的连续块,每个阶 k 管理 2^k 个连续物理页:
- Order-0: 1 页 (4KB)
- Order-1: 2 页 (8KB)
- Order-2: 4 页 (16KB)
- ...
- Order-10: 1024 页 (4MB)
分配时,如果目标阶没有空闲块,就从更高阶拆分出一半;释放时,如果相邻的"伙伴块"也是空闲的,就合并成更大的块。
2.2 源码级剖析
// mm/page_alloc.c
static inline struct page *__alloc_pages_nodemask(
gfp_t gfp_mask, unsigned int order,
int preferred_nid, nodemask_t *nodemask)
{
struct page *page;
unsigned int alloc_flags = ALLOC_WMARK_LOW;
// 1. 快速路径:从 PCU 缓存分配
page = get_page_from_freelist(alloc_gfp, order, alloc_flags, &ac);
if (likely(page))
goto out;
// 2. 慢速路径:页面回收与直接压缩
page = __alloc_pages_slowpath(alloc_gfp, order, &ac);
out:
return page;
}
__alloc_pages_nodemask 是所有物理页分配的入口函数,几乎所有内核分配路径最终都会到达这里。它包含快速路径和慢速路径两部分,快速路径优先尝试从 Per-CPU 缓存(PCPU lists)分配,失败则进入涉及水标检查、内存回收的慢速路径。
2.3 GFP 标志与分配语义
gfp_t 标志决定了内核内存分配的行为方式:
| Flags | 含义 | 典型场景 |
|---|---|---|
| GFP_KERNEL | 标准内核分配,允许睡眠 | 通用分配 |
| GFP_ATOMIC | 不允许睡眠,可访问紧急保留池 | 中断上下文、自旋锁内 |
| GFP_DMA | 分配 ISA DMA 可用内存(<16MB) | 老旧 ISA 设备驱动 |
| GFP_DMA32 | 分配 32 位 DMA 可用内存(<4GB) | 32 位 PCI 设备 |
| GFP_NOIO | 不允许 I/O 操作 | 块层内部分配 |
| GFP_NOFS | 不允许文件系统调用 | 文件系统内部分配 |
| __GFP_ZERO | 分配后清零内存 | 安全敏感场景 |
三、SLUB 分配器:小对象分配的艺术
3.1 slab 分配器的设计动机
伙伴系统以页面(4KB)为最小分配单位,而内核需要大量小对象(task_struct、inode、dentry 等)。直接使用伙伴系统会造成严重的内部碎片。为此,Linux 实现了 slab 分配器作为二级分配机制。
3.2 slab → slub → slob 三兄弟
- slab:经典的 slab 分配器,每个 CPU 维护完整的 slab 页面队列,缓存友好但管理开销大
- slub(当前默认):简化版,使用通用链表管理 slab,内存占用更低,调试更友好
- slob:极简版,适合嵌入式系统(内存 < 64MB 场景)
SLUB 分配器的核心数据结构:
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab; // Per-CPU slab
unsigned long flags;
unsigned int size; // 对象实际大小
unsigned int object_size; // 用户请求大小
unsigned int offset; // 下一个空闲对象偏移
struct kmem_cache_node *node[MAX_NUMNODES]; // NUMA 节点
struct page *partial; // 部分空闲 slab 链表
struct page *full; // 已满 slab 链表
};
3.3 slub 分配流程
快速路径(fast path):直接从 kmem_cache_cpu→freelist 取出第一个空闲对象。这是 slub 最高效的路径,单条指针操作即可完成。
中速路径:当前 CPU 的 freelist 为空时,检查 CPU 是否有 partial slab。如果有,就从 partial slab 中获取对象,并迁移到 freelist。
慢速路径(slow path):向伙伴系统申请新的 slab 页面。这涉及 alloc_pages() 调用,可能触发回收。
3.4 slab 调试功能
SLUB 内建了强大的调试能力:
- Red zoning:在对象末尾放置特殊标记,检测越界写入
- Poisoning:释放后填充特定字节模式(0x6b = "free"),检测 use-after-free
- Tracking:记录每个对象的分配/释放调用栈
启用方式:slub_debug=UFP 内核参数(U=用户跟踪,F=失败保护,P=中毒)。
四、虚拟内存:地址转换与内存保护
4.1 多级页表结构
x86_64 使用四级页表(PGD→PUD→PMD→PTE),每个表项 8 字节,每级 512 项(9 位索引),总共可以映射 256TB 虚拟地址空间:
Virtual Address (48-bit used):
┌────────┬────────┬────────┬────────┬──────────────┐
│ PGD[9] │ PUD[9] │ PMD[9] │ PTE[9] │ Offset[12] │
└────────┴────────┴────────┴────────┴──────────────┘
│ │ │ │
▼ ▼ ▼ ▼
512 entry 512 entry 512 entry 512 entry
CR3 寄存器存储 PGD 基地址,每次上下文切换时更新。用户态/内核态切换时 CR3 不变,但内核页表包含所有物理内存的映射。
4.2 大页(Huge Pages)机制
标准 4KB 页面对现代应用(数据库、虚拟机)会造成巨大的 TLB 压力和页表内存开销。Linux 提供了两种大页方案:
传统 HugeTLB:启动时预先预留,vm.nr_hugepages 参数控制。仅匿名映射可用,缺失则直接段错误。
Transparent Huge Pages(THP):内核自动将普通页合并成 2MB 大页(只支持匿名映射)。虽然易于启用,但存在三个问题:
- 合并/拆分操作引发延迟抖动
- 内核 khugepaged 后台线程消耗 CPU
- 某些场景(内存数据库)下导致性能下降
推荐配置:transparent_hugepage=madvise(需程序显式申请)或针对不同场景关闭。
五、页面回收:OOM 之前的最后防线
5.1 页面回收触发路径
当系统内存紧张时,内核通过三个水标触发回收:
- high:有充足空闲,不需要回收
- low:开始唤醒 kswapd 背景回收
- min:分配器进入直接回收(direct reclaim)
直接回收会阻塞调用者性能影响很大。
5.2 LRU 算法与两类页面
Linux 使用双 LRU 链表(active/inactive)管理页面回收:
| LRU List | 页面类型 | 典型内容 |
|---|---|---|
| Active Anonymous | 活跃匿名页 | 进程堆、栈 |
| Inactive Anonymous | 不活跃匿名页 | 待交换的堆内存 |
| Active File | 活跃文件缓存 | 频繁读取的文件页 |
| Inactive File | 不活跃文件缓存 | 可丢弃的文件页 |
| Unevictable | 不可驱逐 | mlock、ramfs |
回收优先级:Inactive File > Inactive Anonymous > Active File > Active Anonymous(大页)。
5.3 Swap 机制
交换空间可以是swap 分区(文件系统的 10-20%)或swap 文件。新内核支持 zswap ——通过压缩匿名页减少 I/O,zram —— 将 RAM 的一部分模拟为压缩交换设备,在嵌入式和服务器场景都非常受欢迎。
5.4 OOM Killer:末日的审判
当所有回收手段耗尽仍无法释放足够内存时,OOM Killer 会根据 oom_score 选择进程终止。评分公式大致为:
oom_score = (物理内存占用比例 × 1000) + 用户调整值 (oom_score_adj)
保护关键进程的方法:设置 oom_score_adj=-1000 使其永远不会被杀死(如 systemd、sshd)。
六、高级话题与性能调优
6.1 CMA:连续内存分配器
视频播放、摄像头、GPU 等需要大段连续物理内存。CMA(Contiguous Memory Allocator)在系统启动时预留一块连续区域,平时可被当作普通内存使用,有需求时通过页面迁移腾出连续区域。
6.2 内存控制组(memcg v2)
cgroup v2 的 memory 控制器提供精细化的内存控制:
memory.min:硬性保证的最小内存限制(不会被回收)memory.low:软性保护(优先不回收)memory.high:软性限制(超过触发 throttle)memory.max:硬性上限(超过触发 OOM)
Kubernetes 中 Pod 的 memory request 对应 memory.min,limit 对应 memory.max。
6.3 perf 与 ftrace 实战
内存相关问题的诊断工具:
# 查看系统内存概况
$ free -h
$ cat /proc/meminfo
# 查看大页配置
$ cat /proc/sys/vm/nr_hugepages
# slab 使用情况(使用 slabtop 或以下命令)
$ slabtop -o
$ cat /proc/slabinfo
# 查看进程内存映射
$ pmap -x <pid>
$ cat /proc/<pid>/smaps | grep -i huge
# 追踪页面回收事件
$ trace-cmd record -e vmscan:mm_vmscan_kswapd_wake
# 使用 BPF 分析分配热点
$ bpftrace -e 'kprobe:__alloc_pages_nodemask { @[comm] = count(); }'
总结
Linux 内存管理是一个层层递进的复杂系统:最底层是 memblock/伙伴系统管理物理页,中间层是 SLUB/slab 负责小对象分配,最上层是虚拟内存(多级页表 + swap + 页面回收)为用户提供连续的地址空间。
理解这些机制,不仅能帮助你在遇到性能问题时快速定位,还能在设计分布式系统、容器编排、高性能应用时做出更好的决策。下次当你看到一个 OOM 告警或一次毛刺延迟时,希望你能从内核级的视角理解发生了什么。

发表评论 取消回复