Linux 内核内存管理深度实战:从物理页到 slab 分配器的完整链路
内存管理是 Linux 内核最复杂的子系统之一,它直接决定了系统的性能上限。本文将从底层硬件机制出发,系统梳理物理内存模型、虚拟内存布局、buddy 分配器、kmalloc/vmalloc、slab 分配器链、OOM killer 等核心组件的工作原理,并给出实战级的内存调试与优化方案。
一、物理内存模型:从 UMA 到 NUMA
1.1 UMA 与 NUMA 架构差异
在传统的 UMA(Uniform Memory Access)架构中,所有 CPU 通过同一总线访问内存,延迟一致。而现代服务器普遍采用 NUMA(Non-Uniform Memory Access)架构,每个 CPU 节点绑定本地内存,跨节点访问会带来额外的 QPI/UPI 总线延迟。
Linux 内核通过以下数据结构描述 NUMA 拓扑:
// NUMA 节点描述符
typedef struct pglist_data {
struct zone node_zones[MAX_NR_ZONES]; // 每个节点的内存区域
struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配 fallback 列表
int nr_zones; // 节点中区域的数量
struct page *node_mem_map; // 该节点的物理页数组
unsigned long node_start_pfn; // 起始页帧号
unsigned long node_present_pages; // 实际存在的页数
unsigned long node_spanned_pages; // 跨度页数(含空洞)
int node_id; // 节点 ID
// ...
} pg_data_t;
通过 numactl --hardware 可以查看 NUMA 拓扑信息。在 4 路服务器上,不合理的跨节点内存分配可能导致 2-3 倍的访问延迟惩罚。
1.2 内存区域(Zone)的划分
每个 NUMA 节点内部,内存被进一步划分为多个 Zone,以兼容 DMA 等硬件约束:
| Zone 类型 | 说明 | 典型大小 |
|---|---|---|
| ZONE_DMA | ISA DMA 设备可用,16MB | 0-16MB |
| ZONE_DMA32 | 32 位 DMA 设备可用 | 16MB-4GB |
| ZONE_NORMAL | 内核可直接线性映射 | 4GB-896MB(32位) |
| ZONE_HIGHMEM | 动态映射高端内存(仅 32 位系统) | > 896MB |
| ZONE_MOVABLE | 可迁移页面(用于内存热插拔) | 动态 |
64 位系统中 ZONE_HIGHMEM 已消失,48 位或 57 位虚拟地址空间足以线性映射全部物理内存(最高支持 64TB/128PB)。
1.3 struct page — 物理页面的身份证
内核为每一个物理页面维护一个 struct page 描述符(位于全局数组 mem_map 中),这是内存管理最核心的数据结构:
struct page {
unsigned long flags; // 页状态标志(PG_locked, PG_dirty, PG_lru ...)
union {
struct address_space *mapping; // 页映射的文件/匿名页
void *s_mem; // slab 对象起始地址
// ...
};
atomic_t _refcount; // 引用计数
atomic_t _mapcount; // 映射计数
unsigned long private; // 私有数据(buddy 中存 order、slab 中存 slab 指针)
// ...
};
struct page 的大小约为 64 字节,16GB 物理内存对应的 mem_map 数组约占用 16MB 内核空间。每个 page 的 _refcount 为 0 表示空闲,大于 0 表示被使用。
二、虚拟内存与多级页表体系
2.1 五层页表架构(5-Level Page Table)
Linux 5.12+ 支持 5 级页表(PGD→P4D→PUD→PMD→PTE),虚拟地址空间扩展至 57 位(128PB):
// x86_64 五级页表结构
#define PGDIR_SHIFT 48 // 第 47-39 位
#define P4D_SHIFT 47 // 第 47 位(通常 5 级折叠时与 PGD 合并)
#define PUD_SHIFT 39 // 第 38-30 位
#define PMD_SHIFT 30 // 第 29-21 位
#define PTE_SHIFT 12 // 第 20-12 位(4KB 页)
// 4KB 页:12 位 offset + 9×4 级 = 48 位虚拟地址
// 2MB 大页:21 位 offset + PMD 直接指向(Huge Page 跳过 PTE)
// 1GB 大页:30 位 offset + PUD 直接指向
页表查找过程由硬件 MMU 的 Page Walk Cache(PWC,即 TLB)加速。L1 TLB 通常 64 条目(覆盖 256KB),L2 TLB 通常 1536 条目(覆盖 6MB,含大页合并)。翻译未命中时硬件自动触发 page walk,消耗约 20-100 个 CPU 周期。
2.2 进程内存布局(mm_struct)
每个进程通过 mm_struct 管理其虚拟地址空间,mmap 红黑树组织所有的 VMA(Virtual Memory Area):
struct mm_struct {
struct vm_area_struct *mmap; // VMA 红黑树根
struct rb_root mm_rb; // 红黑树快速查找
unsigned long mmap_base; // mmap 区域起始地址
unsigned long task_size; // 用户空间大小
unsigned long start_code, end_code; // 代码段
unsigned long start_data, end_data; // 数据段
unsigned long start_brk, brk; // 堆
unsigned long start_stack; // 栈起始
unsigned long arg_start, arg_end; // 参数区
// ...
};
64 位 Linux 典型布局(用户空间):
- 0x0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF:用户空间(128TB)
- 0xFFFF_8000_0000_0000+:内核空间
- 栈从高位向下增长,堆通过 sbrk 向上扩展
- mmap 区域从 mmap_base 向下/向上分配
三、Buddy 分配器:物理页的核心引擎
3.1 伙伴系统的核心原理
Buddy 分配器管理 Zone 级别的物理页分配与回收。它将页按 order(2^n 个连续页)组织成链表,分配时分裂、释放时合并:
// 每个 Zone 的 free_area 数组
struct zone {
struct free_area free_area[MAX_ORDER]; // order 0~10
// free_area[0]: 单页链表 (2^0 = 1 page)
// free_area[1]: 2 页链表 (2^1 = 2 page)
// ...
// free_area[10]: 4MB 链表 (2^10 = 1024 pages = 4MB)
};
分配 3 个页面的流程:
- 查找 free_area[0],为空
- 查找 free_area[1],有空闲
- 取出一个 2 页组,分裂为两个单页
- 一个单页给 free_area[0],返回另一个单页
- free_area[0] 仍无空闲,上升到 free_area[2]
- 取出 4 页组,分裂为两个 2 页组
- 一个入 free_area[1],另一个继续分裂
- 最终得到 3 个连续页,1 个单页归入 free_area[0]
3.2 alloc_pages 完整调用链
alloc_pages(gfp_mask, order)
→ __alloc_pages_nodemask()
→ get_page_from_freelist() // 快速路径:从伙伴系统直接取
→ __alloc_pages_slowpath() // 慢速路径:回收/压缩/OOM
→ __alloc_pages_direct_reclaim()
→ __alloc_pages_direct_compact()
→ __alloc_pages_may_oom()
gfp_mask 是分配行为的核心控制参数:
GFP_KERNEL:标准内核分配,可能睡眠GFP_ATOMIC:原子上下文,不可睡眠,从紧急储备分配GFP_DMA:要求 DMA 可用内存__GFP_ZERO:零初始化__GFP_HIGHMEM:允许使用高端内存
3.3 页面回收与 LRU 机制
当内存紧张时,Buddy 分配器触发页面回收。内核维护两个 LRU 链表:
- Active Anonymous:活跃匿名页(最近被访问)
- Inactive Anonymous:非活跃匿名页(候选回收)
- Active File:活跃文件缓存页
- Inactive File:非活跃文件缓存页(优先回收,因为磁盘有副本)
kswapd 内核线程在后台运行,当 free 页面低于 pages_high 时开始异步回收,低于 pages_min 时触发 direct reclaim(同步回收,会阻塞分配进程)。
// LRU 页面老化与回收流程
页面访问 → mark_page_accessed() → 从 Inactive 晋升 Active
页面长时间未访问 → shrink_lruvec() → 从 Active 降级到 Inactive
Inactive 尾部页面 → shrink_page_list() → 回写/交换/丢弃
├─ 匿名页:写入 swap 分区/文件
├─ 脏文件页:回写到底层文件系统
└─ 干净文件页:直接丢弃(下次读文件时重新加载)
四、kmalloc vs vmalloc — 两个关键接口的本质差异
4.1 kmalloc 的物理连续保证
kmalloc 通过 slab 分配器获取物理连续的内存,这是驱动和内核子系统的默认选择:
void *kmalloc(size_t size, gfp_t flags);
// 大小限制:通常最大 4MB(KMALLOC_MAX_SIZE)
// 小于 PAGE_SIZE:直接从 slab 缓存分配
// 大于 PAGE_SIZE 小于 4MB:从 buddy 系统分配,但仍由 kmalloc 管理
// 超过 KMALLOC_MAX_SIZE:失败,需使用 vmalloc
关键限制:物理连续性意味着在内存碎片化严重时,即使总空闲内存充足也可能分配失败。
4.2 vmalloc 的虚拟连续方案
void *vmalloc(unsigned long size);
void *vzalloc(unsigned long size); // 零初始化版本
// 内部通过修改页表,将不连续的物理页映射到连续的虚拟地址空间
// 代价:每次访问都可能需要页表查找,TLB miss 率更高
// 不适用于 DMA 设备(需要物理连续)
选型指南:
| 场景 | 推荐函数 | 原因 |
|---|---|---|
| 设备 DMA 缓冲区 | kmalloc / dma_alloc_coherent | 需要物理连续 |
| 内核小对象(结构体等) | kmalloc / kmem_cache_alloc | 高性能,slab 优化 |
| 大内存映射(> 4MB) | vmalloc | 避免物理碎片限制 |
| 内核模块代码/数据 | vmalloc | 模块加载不一定需要物理连续 |
| 用户空间缓冲区 | copy_from_user / copy_to_user | 用户页不需要连续 |
五、Slab 分配器:高频小对象的加速引擎
5.1 Slab 的设计哲学
内核大量重复分配相同大小的对象(如 file、dentry、task_struct、inode 等),每次 buddy 分配都会引入初始化/析构的开销。Slab 通过对象缓存池将已初始化的对象直接复用,避免了重复构造/析构:
kmem_cache → slab(一个或多个连续页)
slab 内部:对象槽位数组
[空闲对象1] → [空闲对象2] → [空闲对象3] → ...
分配流程:直接从 freelist 取出一个已初始化的对象
释放流程:将对象 freelist 头部,标记为空闲
5.2 三种 Slab 变体
| 变体 | 特点 | 适用场景 |
|---|---|---|
| Slab(原始) | 每个 slab 维护空闲对象链表,硬件缓存友好 | Linux 早期(2.6 前) |
| Slub(默认) | 简化元数据,嵌入 freelist 到对象空闲空间,NUMA 感知 | 现代 Linux 默认 |
| Slob | 极致简化,仅 4 字节/页,内存开销最小 | 嵌入式/极小型系统 |
Slub 是 Linux 2.6.22 至今的默认分配器,核心优化包括:
- freelist 指针嵌入到空闲对象内部,分配时从对象头部移除
- 每个 CPU 维护本地缓存(kmem_cache_cpu),避免 NUMA 节点间消费
- Partial slab 空闲列表实现跨节点对象复用
5.3 自定义 kmem_cache 实战
// 示例:创建专用缓存管理自定义结构体
struct my_struct {
u64 timestamp;
char name[64];
int counter;
spinlock_t lock;
};
static struct kmem_cache *my_cache;
// 初始化时创建缓存
my_cache = kmem_cache_create("my_struct_cache",
sizeof(struct my_struct),
0, // 对齐(0 = 默认)
SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT,
NULL); // 构造函数(可传 NULL)
// 分配对象(等价于 kmalloc 但性能更好且已构造)
struct my_struct *obj = kmem_cache_alloc(my_cache, GFP_KERNEL);
// 释放对象
kmem_cache_free(my_cache, obj);
// 销毁缓存(所有 slab 空闲后)
kmem_cache_destroy(my_cache);
六、OOM Killer 与内存保护机制
6.1 OOM Kill 评分机制
当系统内存耗尽且所有回收手段都无法释放足够内存时,OOM Killer 选择进程终止:
// 进程的 OOM 评分计算(简化)
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages) {
// 1. 计算进程占用的内存分
long points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS)
+ mm_pgtables_bytes(p->mm) / PAGE_SIZE;
// 2. 特权调整:root 进程 -3%, oom_score_adj
adj = p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN) return 0; // 永不 kill
points += adj * totalpages / 1000;
return max_t(long, points, 1);
}
实践中保护关键进程:
# 锁定某个进程不被 OOM kill
echo -1000 > /proc//oom_score_adj
# systemd service 配置
[Service]
OOMPolicy=continue # OOM 后不重启
OOMScoreAdjust=-1000 # 极高保护分
6.2 Cgroup 内存限制(memcg)
通过 cgroup v1/v2 的 memory 控制器为容器/服务设置硬上限:
# 创建内存限制 cgroup
mkdir /sys/fs/cgroup/memory/my_service
echo 2G > /sys/fs/cgroup/memory/my_service/memory.limit_in_bytes
echo $PID > /sys/fs/cgroup/memory/my_service/cgroup.procs
# 超限时 memcg 内部触发回收 → OOM(仅杀该 cgroup 内进程)
# 通过 memory.oom_control 禁用 cgroup OOM,等待回收
七、内存泄漏检测与性能优化实战
7.1 工具链一览
| 工具 | 用途 | 使用方式 |
|---|---|---|
| slabtop | 实时查看 slab 缓存消耗 | slabtop -o |
| /proc/meminfo | 物理内存全景 | cat /proc/meminfo |
| /proc/slabinfo | 各 slab 缓存详细统计 | cat /proc/slabinfo |
7.2 内存泄漏排查流程
Step 1:确认是否有泄漏迹象
$ free -h
total used free shared buff/cache available
Mem: 15Gi 8.2Gi 200Mi 500Mi 6.8Gi 5.5Gi
Swap: 4.0Gi 1.2Gi 2.0Gi
# 如 free 持续减少而 buff/cache 不变(排除缓存),怀疑泄漏
Step 2:通过 slabtop 定位异常 slab
$ slabtop -o | head -20
Active / Total Objects (% used) : 523456 / 543210 (96.3%)
Active / Total Slabs (% used) : 12345 / 12348 (99.9%)
Active / Total Caches (% used) : 95 / 137 (69.3%)
Active / Total Size (% used) : 234567 KB / 240000 KB (97.7%)
Minimum / Average / Maximum Object: 0.01 / 0.44 / 12.00 KB
OBJS ACTIVE USE OBJ SIZE SLABS OBJ/SLAB CACHE SIZE NAME
65432 65432 100% 0.50K 4092 16 65472K my_struct_cache <-- 异常膨胀!
12345 12000 97% 0.94K 744 12 11904K dentry
9876 9500 96% 0.19K 92 108 1472K kmalloc-192
Step 3:使用 kmemleak 追踪内核泄漏
# 开启 kmemleak(需编译时开启 CONFIG_DEBUG_KMEMLEAK)
echo scan > /sys/kernel/debug/kmemleak # 手动触发扫描
cat /sys/kernel/debug/kmemleak # 查看泄漏报告
# 输出示例:
unreferenced object 0xffff88803c4a0000 (size 128):
comm "my_kthread", pid 1234, jiffies 4294967295
backtrace:
[<00000000c0012345>] kmem_cache_alloc_trace+0x156/0x2c0
[<00000000d0012345>] my_module_do_something+0x42/0x123
[<00000000e0012345>] my_work_handler+0x88/0x156
...
7.3 配置调优参数
# /etc/sysctl.conf
# 1. swap 使用倾向值(0-200),默认 60
# 0 = 几乎不用 swap,只在 OOM 前才交换
# 100 = 默认值
# 200 = 积极使用 swap 让出更多文件缓存
vm.swappiness = 10
# 2. 脏页回写策略
# 脏页占可用内存百分比阈值(pdflush/writeback)
vm.dirty_ratio = 40 # 全局脏页占总内存比例上限
vm.dirty_background_ratio = 10 # 后台回写起始阈值
vm.dirty_expire_centisecs = 3000 # 脏页过期时间(30秒)
vm.dirty_writeback_centisecs = 500 # 回写线程唤醒间隔(5秒)
# 3. NUMA 平衡
# 是否扫描进程内存并迁移到本地节点
vm.numa_stat = 1
# 4. 内存超分配策略
# 0 = 启发式(默认), 1 = 总是 overcommit, 2 = 严格检查
vm.overcommit_memory = 0
vm.overcommit_ratio = 50
7.4 Huge Page 配置
大页减少 TLB miss 和页表内存开销,对数据库/HPC 场景性能提升显著:
# 2MB 透明大页(THP)
echo always > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag
# 1GB 静态大页(需启动时预留)
# GRUB 参数:hugepagesz=1G hugepages=4 hugepagesz=2M hugepages=512
echo 4 > /proc/sys/vm/nr_hugepages
# 查看大页使用
$ grep Huge /proc/meminfo
HugePages_Total: 1024
HugePages_Free: 256
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
八、eBPF 在内存分析中的应用
现代 Linux 可观测性工具大量使用 eBPF 进行内存事件的低开销追踪:
8.1 BPF 内存追踪工具
| 工具 | 功能 | 底层支持 |
|---|---|---|
| bcc/memleak | 追踪未配对的 kmalloc/kfree | kprobe + stack |
| bpftrace one-liner | 追踪 page_fault | tracepoint:exceptions:page_fault_user |
| perf mem | 内存访问延迟采样 | PEBS + LBR |
| bpftrace slab 追踪 | slab 分配/释放事件 | tracepoint:kmem:kmalloc |
8.2 bpftrace 实时追踪页分配
# 追踪 kmalloc 调用, 按进程聚合分配量
bpftrace -e 'tracepoint:kmem:kmalloc {
@bytes[comm, args->ptr] = args->bytes_req;
@total[comm] = sum(args->bytes_req);
@count[comm] = count();
}'
# 输出示例:
@bytes[chrome, 0xffff88803b21c000]: 4096
@total[postgres]: 536870912
@total[nginx]: 268435456
@count[my_server]: 1234567
九、总结与最佳实践
Linux 内核内存管理的核心认知框架:
| 层次 | 机制 | 用户接口 |
|---|---|---|
| NUMA/Zone | 物理节点 + 区域划分 | numactl, mbind |
| Buddy 系统 | 页级分配回收引擎 | alloc_pages, free_pages |
| Slab | 小对象缓存池 | kmalloc, kmem_cache |
| LRU/kswapd | 页面回收与交换 | sysctl swappiness |
| OOM/Cgroup | 内存限制与强制回收 | memcg, oom_score_adj |
性能优化铁律:
- 减少碎片:控制内存申请粒度,避免频繁小对象分配释放
- NUMA 亲和:线程与本地内存绑定,减少跨节点访问
- 合理配置 Huge Page:数据库和 DPDK/I/O 性能关键场景必开
- 监控 slab 异常:slabtop 持续监控,结合 kmemleak 发现泄漏
- 调整脏页策略:SSD 场景适当降低 dirty_background_ratio,避免突发回写
- swapiness 按需调整:桌面系统设为 10,HPC/DB 设为 1,SSD+swap 场景可设为 100
深入理解内存管理的完整链路,是 Linux 系统性能调优的基石。从物理页框到虚拟地址映射,从伙伴系统到 slab 缓存,每一层都有其设计背景和适用场景。掌握这些底层机制,才能在面对内存问题时做出精确的判断。

发表评论 取消回复