Linux 内核内存管理深度实战:从 Buddy 分配器到 Slab 缓存、NUMA 调优与 cgroup v2 OOM 防护的完全工程指南
引言:为什么内存管理值得深入钻研
在 Linux 内核的诸多子系统中,内存管理(Memory Management, MM)是最复杂、对整体性能影响最深远的模块之一。它不仅决定了系统能否高效利用有限的物理内存,还直接影响着应用程序的延迟表现、响应能力和吞吐量。对于后端工程师和基础设施工程师而言,深入理解 Linux 内核内存管理机制,是排查内存泄漏、OOM 异常、性能抖动等生产级问题的核心技能基础,也是进行 NUMA 调优、大页配置、容器内存限制优化的前置知识。
本文将从 Buddy System 分配器出发,深入解析 Slab 分配器、页面回收机制、KSM 与透明大页、swap 子系统、 NUMA 架构下的内存局部性优化,再到 cgroup v2 memory 控制器与容器场景下的 OOM 处理策略,构建完整的 Linux 内存管理工程知识体系。
一、物理内存管理基础架构
1.1 页(Page)作为基本管理单元
Linux 内核以页(Page)作为物理内存管理的基本单位,默认页大小为 4KB(x86_64 架构)。每个物理页都对应一个 struct page 结构体(约 64 字节),存储在全局数组 mem_map 中。这意味着每 4KB 物理内存需要 64 字节元数据,元数据开销约为内存总量的 1.6%。
关键代码路径:
// include/linux/mm.h
struct page {
unsigned long flags; /* 原子标志位,如 PG_locked, PG_dirty */
atomic_t _refcount; /* 引用计数 */
atomic_t _mapcount; /* 映射到页表的次数 */
unsigned long private; /* 映射私有数据 */
struct address_space *mapping; /* 反向映射 */
pgoff_t index; /* 在映射中的偏移 */
struct list_head lru; /* LRU 链表节点 */
// ... 约 64 字节 total
};
1.2 区域(Zone)与水位(Watermark)机制
由于硬件架构的限制(如 DMA 只能寻址低 16MB),物理内存被划分为多个区域(Zone):
- ZONE_DMA:0-16MB,用于老式 ISA 设备 DMA
- ZONE_DMA32:16MB-4GB,用于 32 位 DMA 设备
- ZONE_NORMAL:直接映射到内核虚拟空间(x86_64 为 896MB)
- ZONE_HIGHMEM:>896MB 部分(32位系统需要动态映射,64位系统通常无需此区域)
- ZONE_MOVABLE:可移动页面区域(用于内存热插拔和碎片整理)
每个区域维护三个水位标记(high, low, min),触发不同强度的页面回收:
min:kswapd 开始异步回收的阈值
low:分配器进入 direct reclaim 的阈值
high:kswapd 认为区域平衡,停止回收的阈值
通过 /proc/zoneinfo 可以查看各区域状态:
$ grep -E "Node|zone|managed|present|min|low|high" /proc/zoneinfo
Node 0, zone Normal
managed 32566784 pages # 内核管理的总页数(约 124GB)
present 32649216 pages # 实际存在的页数
min 114688 pages # ~448MB
low 143360 pages # ~560MB
high 172032 pages # ~672MB
1.3 NUMA 架构下的节点(Node)模型
在多路服务器中,物理内存通过 NUMA(Non-Uniform Memory Access) 拓扑组织。每个 CPU Socket 及其直接连接的内存构成一个 NUMA Node,CPU 访问本地内存延迟低(~70ns),跨节点访问延迟高(~130ns+)。
系统通过 /sys/devices/system/node/ 查看 NUMA 拓扑:
$ numactl --hardware
available: 2 nodes (0-1)
node 0 cpus: 0-31 # 32 个 CPU 在 node 0
node 0 size: 128GB # node 0 有 128GB 内存
node 0 free: 64GB
node 1 cpus: 32-63
node 1 size: 128GB
node 1 free: 32GB # node 1 内存使用更多
node distances:
node 0 1
0: 10 21 # 本地访问开销=10,跨节点=21
1: 21 10
二、Buddy System 物理页分配器
2.1 伙伴算法核心原理
Buddy System(伙伴分配器) 是 Linux 分配连续物理页面的核心算法。它将空闲页面组织为 11 个链表数组(free_area[MAX_ORDER]),对应 2^0 到 2^10 个连续页面(即 4KB 到 4MB 的连续块)。
分配时通过拆分(Split) 将大块分解为小块,释放时通过合并(Coalesce) 将相邻的空闲伙伴(Buddy)合并为更大块:
分配 8 页(2^3)的流程:
1. 检查 free_area[3],若有空闲块则直接分配
2. 若 free_area[3] 为空,向上查找 free_area[4](16页块)
3. 将 16 页块拆分为两个 8 页伙伴(buddy)
4. 一个分配给请求者,另一个加入 free_area[3]
5. 如果 free_area[4] 也为空,继续向上查找更高阶
Buddy 判断公式:两个大小为 2^n 的块互为 Buddy,当且仅当它们起始页号的差为 2^n。
// mm/page_alloc.c
static inline unsigned long
__find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
return page_pfn ^ (1UL << order>
2.2 页面分配器 API 与 GFP 标志
内核分配物理页面的核心接口:
struct page *alloc_pages(gfp_t gfp_mask, unsigned int order); // 返回 page 指针
unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order); // 返回虚拟地址
struct page *alloc_page(gfp_t gfp_mask); // 分配单个页面
void free_pages(unsigned long addr, unsigned int order); // 释放
GFP(Get Free Pages)标志 控制分配行为:
GFP_ATOMIC:原子上下文使用,不允许睡眠,可能失败
GFP_KERNEL:普通内核分配,允许睡眠和 IO 操作
GFP_NOIO:允许睡眠但不触发 IO 操作
GFP_NOFS:允许睡眠但不触发文件系统操作
GFP_HIGHUSER:用户空间分配,可用 HIGHMEM
GFP_USER:用户空间分配,可能触发 IO/FS 操作
__GFP_ZERO:分配的内存清零
__GFP_THISNODE:仅在当前 NUMA 节点分配
__GFP_NOWARN:分配失败不输出警告
2.3 Per-CPU 热页缓存(PCP)
为了减少锁竞争和提升单页分配效率,Buddy System 之上维护了 Per-CPU Page Cache(PCP)。每个 CPU 都有自己的冷热页链表,单页分配和释放首先操作 PCP,仅在不足或溢出时才与全局 Buddy 交互。
// 查看 PCP 配置
$ sysctl vm.extfrag_threshold
vm.extfrag_threshold = 500 # 外部碎片阈值
$ cat /proc/buddyinfo
Node 0, zone Normal 1 0 0 0 1 2 1 1 1 1 1760
// 数字依次表示 order=0,1,2,...,10 的空闲块数量
// 大数字在高阶说明碎片少,集中在低阶说明碎片严重
三、Slab 分配器:内核对象的精细管理
3.1 Slab 的设计动机
Buddy System 按 4KB 粒度分配,但内核中的许多对象(如 task_struct 约 7KB、inode 约 600B、dentry 约 192B)远小于一页。频繁按页分配会导致严重内部碎片。Slab 分配器 在 Buddy System 之上构建,将一页划分为多个固定大小的对象槽(slot),实现细粒度对象缓存。
3.2 三代 Slab 实现演进
Linux 历史上经历三代 Slab 分配器实现:
- SLAB(2.1 引入):最初的实现,每个缓存维护三个链表(满/部分满/空),硬件缓存友好但元数据开销大
- SLUB(2.6.23 默认):简化实现,将元数据嵌入 page 结构体,multi-queue 设计减少锁争用,多核扩展性更好
- SLOB(嵌入式场景):极简实现,适合内存极其受限的嵌入式设备
现代服务器默认使用 SLUB:
$ cat /proc/slabinfo # 查看所有 slab 缓存
kmalloc-1024 2400 2400 1024 32 8
kmalloc-512 4864 5120 512 32 4
kmalloc-256 10240 12800 256 64 2
dentry 86400 86400 192 40 2
inode_cache 51200 52000 632 25 2
# 各列:缓存名称 活跃对象数 总对象数 对象大小 单页对象数 页面阶
3.3 Slab 内部结构与缓存着色
一个 Slab(物理页)的内部布局:
+--------+--------+--------+--------+--------+--------+
| obj[0] | obj[1] | obj[2] | obj[3] | ... |free obj| padding
+--------+--------+--------+--------+--------+--------+
|<--------- 活跃对象 --------->|<- 空闲对象 -->|
空闲对象通过单向链表串联:free_list 指向第一个空闲对象
每个空闲对象的内存存储下一个空闲对象的指针
缓存着色(Cache Coloring) 通过在每个 Slab 起始位置添加不同大小的偏移,避免多个 Slab 中的相同索引对象映射到同一个 CPU Cache Line,减少缓存冲突未命中(Cache Conflict Miss)。
3.4 kmalloc 与 NUMA-aware 分配
内核通过 kmalloc() 分配可变大小内存,它内部从预定义的 kmalloc_caches 数组中选择最接近的 slab 缓存:
void *kmalloc(size_t size, gfp_t flags);
size <= 8B -> kmalloc-8
size <= 16B -> kmalloc-16
size <= 32B -> kmalloc-32
...
size <= 8KB -> kmalloc-8192
size > 8KB -> 直接走 alloc_pages(Buddy System)
对于 NUMA 架构,可以使用 kmalloc_node() 在指定节点分配:
void *kmalloc_node(size_t size, gfp_t flags, int node);
// 实际硬件 NUMA 分配示例
void *buf = kmalloc_node(4096, GFP_KERNEL, 0); // 在 node 0 分配
void *buf = kmalloc_node(4096, GFP_KERNEL, 1); // 在 node 1 分配
四、虚拟内存管理与页表机制
4.1 虚拟地址空间布局
x86_64 架构使用 48 位虚拟地址(256TB 空间),分为 User Space 和 Kernel Space:
0x0000 0000 0000 0000 ───────────────────────────
| User Space (128TB)
| Stack -> 向下增长
| Memory Mapping Region (mmap)
| Heap -> 向上增长(brk/mmap)
| BSS / Data / Text
0x0000 7FFF FFFF FFFF ───────────────────────────
| Non-canonical Hole (空洞)
0xFFFF 8000 0000 0000 ───────────────────────────
| Kernel Space (128TB)
| Direct Mapping of all物理内存 (phys_base)
| vmalloc / ioremap Region
| Fixmap / 固定映射
| Kernel Text / Data
0xFFFF FFFF FFFF FFFF ───────────────────────────
4.2 四级/五级页表结构
x86_64 使用 4 级页表(ARM64 支持 4-5级),将 48 位虚拟地址拆分为 5 个字段:
Virtual Address (48-bit):
├─ PGD Index (9 bits) ──────┐
├─ PUD Index (9 bits) ──────┤ 每级页表有 512 项
├─ PMD Index (9 bits) ──────┤ 每个表项 8 字节
├─ PTE Index (9 bits) ──────┤ 每级页表 = 1 Page (4KB)
└─ Page Offset (12 bits) ─
CR3 寄存器存储 PGD 物理地址
Linux 5.12+ 引入了五级页表(LA57),支持 57 位虚拟地址(128PB 空间),增加 P4D 层级,防止未来地址空间不足。
4.3 TLB 管理与 ASID/PCID 优化
Translation Lookaside Buffer(TLB) 缓存虚拟到物理地址映射的硬件缓存,避免每次内存访问都走页表查询。TLB 通常有 64-2048 个条目,未命中需要 4 次内存访问(遍历 4 级页表)。
进程切换时 TLB 需要刷新(Flush),Intel 引入了PCID(Process-Context Identifier)优化,在 TLB 条目中打上进程标签,避免切换时全局刷新:
// 检查 PCID 支持
$ grep pcid /proc/cpuinfo | head -1
// Linux 利用 PCID 保留不同进程的 TLB 条目
// CR3 [11:0] = PCID,区分不同地址空间
// INVPCID 指令提供细粒度的 TLB 条目失效
五、页面回收与交换机制
5.1 LRU 近似算法:Active/Inactive 双链表
Linux 使用近似 LRU(Least Recently Used)算法决定哪些页面可以被回收。每个页面有一名访问参考位(Reference Bit,由硬件设置,操作系统清零检查),内核利用它近似判断页面的"冷热"程度。
每个 LRU List 由 Active(热)和 Inactive(冷)两个链表组成:
页面状态转换:
alloc_page ──> Inactive List (tail)
│
▼
被访问一次 ──> 仍在 Inactive
被访问两次 ──> Active List (tail) (promote)
│
▼
长时间未访问 ──> Inactive (demote)
│
▼
Inactive + 未映射/脏 ──> 回收/写入交换区/丢弃
页面类型与回收代价(从低到高):
1. 可丢弃页面(Discardable):如缓存的 clean page cache
2. 交换页面(Swappable):匿名内存 -> 写出到 swap 分区
3. 同步回写(Sync):dirty page cache -> 写入文件系统
4. 写回页面(Writeback):正在 IO 中的脏页,代价最高
5.2 Kswapd 后台回收与 Direct Reclaim
kswapd 是内核线程(每个 NUMA 一个),在后台异步扫描和回收页面:
// 每个内存节点运行 kswapd
$ ps aux | grep kswapd
root 12 0.0 0.0 0 0 ? S kswapd0
root 13 0.0 0.0 0 0 ? S kswapd1
kswapd 触发逻辑:
1. 空闲页 < low> threshold,启动 writeback
4. 目标是让空闲页数恢复到 high watermark
当 Direct Reclaim(直接回收) 发生时(如 alloc_pages 无法分配),分配进程本身同步执行回收路径,严重影响延迟。这应由开发者极力避免的场景。
5.3 Swap 子系统与 ZRAM
Swap 允许内核将匿名内存(如进程堆、栈)换出到磁盘,模拟更多内存空间。但磁盘 IO 延迟(~10ms)相比内存(~70ns)差距巨大,频繁 swap 会导致系统 thrashing。
ZRAM(压缩 RAM 块设备) 提供内存内压缩交换:
// 配置 ZRAM(用于无磁盘 swap 的容器场景)
$ modprobe zram num_devices=1
$ echo zstd > /sys/block/zram0/comp_algorithm
$ echo 16G > /sys/block/zram0/disksize
$ mkswap /dev/zram0
$ swapon /dev/zram0 -p 100
压缩率通常为 2-3 倍,用 CPU 换内存
六、KSM 与透明大页(THP)
6.1 KSM(Kernel Samepage Merging)
KSM 通过扫描内存页面找出内容相同的副本,合并为一份物理页(Copy-on-Write),常用于 KVM 虚拟化场景(多个相同 OS 镜像的虚拟机):
// KSM 参数调优
/sys/kernel/mm/ksm/run = 1 # 启用
/sys/kernel/mm/ksm/pages_to_scan = 100 # 每次扫描页数
/sys/kernel/mm/ksm/sleep_millisecs = 20 # 扫描间隔
// 监控 KSM 节省的内存
$ grep -E "pages_shared|pages_sharing|pages_unshared" /sys/kernel/mm/ksm/
pages_shared 5000 # 已合并页组数
pages_sharing 15000 # 共享该数据的虚拟页总数
pages_unshared 2000 # 扫描后不重复的页数
6.2 透明大页(Transparent HugePages, THP)
将 2MB(或 1GB)大页面替代 4KB 小页使用,可以:
- 减少 TLB Miss(一个 2MB 大页覆盖更多内存空间)
- 减少页表遍历深度(4级→3级)
- 但带来内存浪费(内部碎片)和碎片化风险
数据库场景(PostgreSQL、Redis)通常建议禁用 THP:
// 检查 THP 状态
$ cat /sys/kernel/mm/transparent_hugepage/enabled
always [madvise] never
// 数据库场景推荐:改为 never
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
// 或选择性使用(通过 madvise)
madvise(addr, length, MADV_HUGEPAGE); // 对特定内存区域建议大页
madvise(addr, length, MADV_NOHUGEPAGE); // 禁止大页
七、NUMA 感知内存调优工程实践
7.1 NUMA 策略与绑定
对于延迟敏感的数据库/高吞吐服务,需要显式控制内存分配策略:
// 使用 numactl 绑定进程
numactl --cpunodebind=0 --membind=0 redis-server // 绑定到 node 0
// 使用 libnuma 动态绑定 struct bitmask *mask = numa_get_membind();
numa_set_preferred(0); // 优先在 node 0 分配
numa_set_localalloc(); // 默认策略:按 CPU 所在节点分配
// mbind() 系统调用:设置已映射区域的政策
mbind(addr, length, MPOL_PREFERRED, mask, maxnode, 0);
// MPOL_DEFAULT:系统默认(Local)
// MPOL_PREFERRED:优先在指定节点分配,不足时 fallback
// MPOL_BIND:强制在指定节点,否则触发 OOM
// MPOL_INTERLEAVE:交错分配在所有节点
7.2 NUMA 统计与不平衡检测
通过 numastat 监控跨节点内存分配比例:
$ numastat -p $(pidof myapp)
Per-node process memory (in pages) for PID 12345:
Node 0 Node 1 Total
Total 8200000 2000000 10200000 (40GB)
HugePage 512 256 768
Heap 100000 850000 950000
Stack 10000 5000 15000
// Node 1 的堆分配明显偏多 - 需要关注
也可以通过 perf c2c 检测跨 NUMA 的 Cache Line 争用:
$ perf c2c record -a -- sleep 30
$ perf c2c report
7.3 自动 NUMA Balancing
Linux 提供自动 NUMA 平衡(AutoNUMA),内核通过采样页面访问频率,自动将页面迁移到访问者所在 NUMA 节点:
$ echo 1 > /proc/sys/kernel/numa_balancing
// 代价较高,数据库场景通常建议关闭
echo 0 > /proc/sys/kernel/numa_balancing
八、cgroup v2 Memory 控制器与容器场景
8.1 cgroup v2 memory 参数
在容器编排环境(如 Kubernetes)中,内存限制通过 cgroup v2 实现:
// cgroup v2 内存参数
/sys/fs/cgroup/mycontainer/memory.max = 4294967296 # 硬限制(4GB)
/sys/fs/cgroup/mycontainer/memory.high = 3435973836 # 软限制:触发回收
/sys/fs/cgroup/mycontainer/memory.low = 2147483648 # 保护阈值:优先保留
/sys/fs/cgroup/mycontainer/memory.min = 1073741824 # 最低保证分配
/sys/fs/cgroup/mycontainer/memory.current # 当前内存使用
/sys/fs/cgroup/mycontainer/memory.stat # 统计详情
memory.max 触发时,容器内的 OOM Killer 将选择进程终止以释放内存。
8.2 OOM Killer 算法与保护机制
当内存耗尽且无法回收时,OOM Killer 通过 oom_score 选择进程终止,评分公式近似为:
oom_score = (内存占用百分比 * 1000) * (1000 + oom_score_adj) / 1000
// 关键系统进程负 oom_score_adj(不容易被杀)
$ cat /proc/1/oom_score_adj # systemd -1000
$ cat /proc/$(pidof sshd)/oom_score_adj
// 容器内保护应用进程
echo -1000 > /proc/self/oom_score_adj
oom_score_adj 取值范围 -1000(从不杀)到 +1000(优先杀)。
8.3 容器内存监控与诊断
诊断容器内存压力的关键指标:
// cgroup memory.stat 关键字段
$ cat /fs/cgroup/mycontainer/memory.stat
anon 3221225472 # 匿名内存 3GB
file 1073741824 # 文件缓存 1GB
kernel_stack 16384
pagetables 4194304
// 决定内存使用的核心指标
// 监控 OOM 事件
$ dmesg -T | grep -i "oom\|out of memory"
[Mon Sep 18 10:23:45 2026] oom-kill: Killed process 12345 (java)
// 监控 PSI(Pressure Stall Information)
$ cat /proc/pressure/memory
some avg10=5.00 avg60=3.00 avg300=2.00 total=123456
full avg10=2.00 avg60=1.00 avg300=0.50 total=65432
# some:任一任务被阻塞的时间占比
# full:所有任务同时被阻塞的时间占比
九、性能调优与疑难排查
9.1 碎片化问题与解决方案
长期运行的系统可能遇到外部碎片化问题(内存被分割成许多小块,无法分配连续大块):
// 检查碎片化程度
$ cat /proc/buddyinfo
Node 0, zone Normal 0 0 0 0 0 0 0 1 2 4 3
// 高阶块(order 7-10)极少或没有 → 碎片化严重
// 缓解方法:
// 1. 启用 compaction
echo 1 > /proc/sys/vm/compact_memory
// 2. 调整 extfrag_threshold (默认 500)
sysctl -w vm.extfrag_threshold=100 // 更积极的碎片整理
// 3. 大页预分配(启动时指定)
hugepages=1024 # 启动参数预留 1024 个 2MB 大页
default_hugepagesz=1G hugepagesz=1G hugepages=16 # 1GB 大页
9.2 内存泄漏排查流程
步骤 1:通过 /proc/slabinfo 观察增长
$ watch -n 1 "grep kmalloc /proc/slabinfo"
# 持续增长的 slab 缓存可能是泄漏
步骤 2:使用 slabtop 实时监控
$ slabtop -o | head -20
步骤 3:使用 memleak BPF 工具追踪
$ memleak-bpfcc -p $(pidof myapp) 30
步骤 4:如果怀疑用户态泄漏
$ valgrind --leak-check=full ./myapp
步骤 5:内核模块泄漏
# 使用 kmemleak
echo scan > /kernel/debug/kmemleak
cat /kernel/debug/kmemleak # 显示可疑泄漏点
9.3 关键 sysctl 参数调优
# vm.swappiness (0-100)
# 控制内核将匿名内存换出到 swap 的倾向
# 数据库/内存缓存场景推荐 1(几乎不用 swap)
# 桌面应用推荐 60(默认值)
sysctl -w vm.swappiness=1
# vm.dirty_ratio / vm.dirty_background_ratio
# 脏页占可用内存的最大比例 / 触发后台 writeback 的比例
sysctl -w vm.dirty_ratio=40 # 默认 20
sysctl -w vm.dirty_background_ratio=10 # 默认 10
# vm.overcommit_policy
# 0 = 启发式 Overcommit(默认,大部分场景推荐)
# 1 = 永远 Overcommit(适合有大量稀疏地址空间的场景)
# 2 = 严格不 Overcommit(commit 不能超过 CommitLimit)
sysctl -w vm.overcommit_ratio=50 # 可用于 Overcommit 的内存百分比
# vm.min_free_kbytes
# 保留给原子分配的最低内存(默认自动计算)
sysctl -w vm.min_free_kbytes=262144 # 256MB
十、生产案例与最佳实践
案例一:Kubernetes 集群 OOM 频发排查
某服务频繁 OOMKilled,排查发现 memory.limit 设置为 2GB,但 JVM Xmx 配置为 3GB。JVM 的堆外内存(Metaspace、Direct Buffer、Thread Stack)额外占用超过 1GB,导致容器被OOM。
结论:JVM Xmx 必须小于 cgroup memory.limit,保留 20-30% 给堆外。
案例二:Redis 透明大页导致的延迟抖动
生产环境 Redis P999 延迟周期性飙升到 100ms+。经排查 THP 的 khugepaged 线程在执行页面合并,阻塞了 Redis fork 操作(触发 COW 时需要拆分大页)。
修复:禁用 THP,追求低延迟的服务都应优先考虑。
案例三:Nginx NUMA 跨节点导致吞吐下降 30%
Nginx worker 绑定在 Node 0 CPU,但内存主要在 Node 1 分配。通过 numactl --interleave=all nginx 将内存交错分配到两个节点,或者以 --membind=0 确保本地分配,解决了跨节点延迟问题。
总结
Linux 是一个极其成熟的内存管理系统,从底层的 Buddy/Slab 分配器,到 LRU 页面回收、NUMA 拓扑感知,再到 cgroup 资源隔离,每一层都有丰富的工程细节。作为工程师,理解这些机制不仅仅是理论知识,更是解决实际生产问题的关键能力——无论是排查 OOM、优化数据库 P99 延迟、还是设计高并发的后端服务架构。
建议读者在日常工作中养成监控 /proc/meminfo、/proc/zoneinfo、/proc/buddyinfo、/proc/pressure/* 的习惯,在容器化环境中重点关注 PSI 指标的 full 值(反映内存争用的真实程度),以及观察 slab 缓存的异常增长,这些都是提前发现内存健康问题的有效手段。

发表评论 取消回复