引言:为什么内存管理是内核最复杂的子系统
Linux 内核内存管理子系统(MM)是整个内核中最复杂、涉及面最广的子系统之一。它不仅要管理物理页面的分配与回收,还要处理虚拟地址空间的映射、页面缓存、交换空间、NUMA 拓扑感知、OOM 紧急回收等场景。在云计算和大内存(TB 级)服务器时代,内存子系统的性能直接影响应用的吞吐和延迟。本文从 Buddy 分配器切入,深入剖析 SLAB/SLUB 对象分配器、LRU 页面回收、OOM Killer、NUMA 内存策略、KSM 去重机制,最后总结生产环境中的调优实践。
一、Linux 内存管理架构全景
1.1 物理内存模型
Linux 内核使用 struct page 描述每一个物理内存页帧(通常 4KB)。内核支持三种内存模型:
- FLATMEM:平坦内存模型,适用于连续物理内存(大多数单/双路服务器)
- DISCONTIGMEM:不连续内存模型,处理物理内存有较大空洞的场景
- SPARSEMEM:稀疏内存模型,支持内存热插拔(现代服务器主流),以 section(通常为 128MB/2GB)为粒度管理
在 NUMA 架构中,内存被组织为节点(pg_data_t),每个节点包含一个或多个 Zone(DMA/DMA32/Normal/HighMem/Movable)。Zone 的类型决定了内存的用途限制,例如 DMA32 只允许 32-bit DMA 设备访问。
1.2 虚拟地址空间布局
x86_64 Linux 虚拟地址空间(48 位有效):
| 区域 | 地址范围 | 用途 |
|---|---|---|
| 用户空间 | 0x0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF | 进程私有:代码、堆、栈、mmap 映射 |
| 内核空间 | 0xFFFF_8000_0000_0000 ~ 0xFFFF_FFFF_FFFF_FFFF | 直接映射区(物理内存线性映射)、vmalloc 区、模块区 |
内核空间的直接映射区(PAGE_OFFSET 开始)将物理内存线性映射到内核虚拟地址,访问时通过 __va()/__pa() 快速转换,省去了动态映射开销。
1.3 内存管理核心层次
顶层是虚拟内存管理(VMM),它将进程的虚拟地址映射到物理页;底层是物理页分配器(Buddy)和对象缓存(SLUB)。两者的交互通过 per-CPU page cache(PPL)和 LRU 链表完成。整个架构形成:进程/VMA → 页表 → Buddy/SLUB → 物理页 的分层管理体系。
二、Buddy 分配器:物理页框分配的核心
2.1 设计原理
Buddy 分配器将物理内存划分为不同阶(order)的连续块,每个块大小为 2^order 个物理页。分配时从最接近的申请阶向上查找,找到后逐对分裂(buddy splitting);释放时检查相邻块(buddy)是否空闲,合并为更大块。核心数据结构为 free_area 数组(每个 Zone 一份),维护 0~MAX_ORDER(通常 11)个空闲链表。
2.2 伙伴(Buddy)关系计算
两个页帧号为 buddy 的条件是:page_idx XOR (1 << order) == buddy_idx。这种异或运算保证了在一个块中,每一对伙伴块的 XOR 结果都为 2^order,使得合并检测只需要一次异或操作。
2.3 页面迁移与 compaction
当系统运行一段时间后,Buddy 链表中的高阶块变得稀缺(内存碎片化),导致大页(HugePage、透明大页 THP)无法分配。内核通过以下策略缓解:
- 页面迁移类型(migrate type):MIGRATE_MOVABLE(用户匿名页)、MIGRATE_RECLAIMABLE(页面缓存)、MIGRATE_UNMOVABLE(内核对象),减少不同类型的混叠
- kswapd + compaction:内核线程 kswapd 后台扫描并迁移页面,使同类型页面连续
- 手动触发:
echo 1 > /proc/sys/vm/compact_memory强制压缩全系统内存
2.4 分配标志(gfp_t)
Buddy 分配器的行为由 gfp(get free page)标志控制,常见标志:
| 标志 | 含义 |
|---|---|
| GFP_KERNEL | 标准内核分配,允许睡眠等待页面回收 |
| GFP_ATOMIC | 原子上下文分配,允许从紧急 reserves 池中取页,不睡眠 |
| GFP_HIGHUSER | 用户空间分配,包含 __GFP_HIGHMEM(访问 ZONE_HIGHMEM) |
| __GFP_ZERO | 返回清零后的页面 |
| __GFP_THISNODE | 仅从当前 NUMA 节点分配(无 fallback) |
| GFP_NOIO / GFP_NOFS | 限制分配时做 I/O 或文件系统操作,防止递归回收 |
分配成功概率路径:alloc_pages() → get_page_from_freelist() → __alloc_pages_nodemask() → 如果失败则调用的页面回收路径。
三、SLAB/SLUB 分配器:内核对象的高效复用
3.1 为什么需要对象级分配器
Buddy 分配的最小粒度是页(4KB),而内核对象(task_struct、inode、dentry)通常几十到几百字节。若直接通过 Buddy 分配,会严重浪费内存。SLAB 系列分配器基于"对象复用"思想:从 Buddy 端要来整页,在其中预分配一组同类型对象,维护空闲链表。分配/释放对象只需修改链表指针,无需修改页表和 Buddy 元数据。
3.2 SLUB:现代 Linux 默认分配器
SLUB(Unqueued SLAB Allocator)是 SLAB/SLUB/SLOB 三大家族中的现代默认实现在 Linux 2.6.23 之后替代了 SLAB。核心区别:
- 去除了 per-CPU 队列:SLAB 为每个 CPU 维护 object 链表但管理复杂;SLUB 用每 CPU 的 partial page 简化
- 更少的元数据开销:SLUB 将 object 空闲链表嵌入 object 体内(第一个 free 指针),无需独立 slab 控制结构
- CPU partial 与 Node partial 分层:每 CPU partial 链表优先使用;Node partial 作为全局备份
查看 /proc/slabinfo 就能观察到 SLUB 缓存:
kmalloc-256 12000 12300 256 32 2 : tunables ...
其中列含义为:缓存名 → 活跃对象数 → 总对象数 → 每个对象大小 → 每个 slab 包含对象数 → 每个 slab 占用页数。
3.3 kmalloc 家族
kmalloc() 是内核中最常用的小对象分配器,内部使用一组 kmalloc-* 通用 SLUB 缓存:
| 分配器 | 大小 | 底层缓存 |
|---|---|---|
| kmalloc(32) | 32 bytes | kmalloc-32 |
| kmalloc(64) | 64 bytes | kmalloc-64 |
| kmalloc(128) | 128 bytes | kmalloc-128 |
| kmalloc(1024) | 1024 bytes | kmalloc-1024 |
| kmalloc(8192) | 8192 bytes | kmalloc-8192 |
注意:kmalloc() 最大可分配大小受 KMALLOC_MAX_SIZE 限制(通常为 8KB×4 = 32KB on 4KB page),特大分配必须用 vmalloc()。
3.4 vmalloc vs kmalloc
vmalloc() 分配虚拟地址连续的内存(物理不一定连续),适用于大缓冲区(如模块加载、do_mmap() 等大型缓冲)。代价是 TLB 抖动较大、访问延迟较高(因为不是线性映射,需要走非直接映射的页表路径)。kmalloc() 分配物理连续的内存,适合小对象和中量 DMA 缓冲。
3.5 SLAB 释放与 RCU 延迟
内核对象释放常涉及 kfree(),部分场景(如 RCU callback)需要等待 RCU grace period 后才真正释放页面。这种延迟释放策略避免了 use-after-free,但会消耗内存峰值(RCU 批量删除页面)。可通过 /sys/kernel/slab/<cache>/* 节点观察每个 slab 缓存的状态。
四、LRU 页面回收与 kswapd
4.1 LRU 双链结构
Linux 内核将页面分为 Active 和 Inactive 两组,构成 LRU(Least Recently Used)核心:
- Active list:最近被访问过的页面(通过 PTE Access bit 判断)
- Inactive list:长时间未访问的页面,回收候选
- 页面老化:当页面从 Inactive tail 移动到 Active head(二次机会算法),称为"promote";反之为"demote"
每个 LRU 列表按类型(File/Anon)分:因此每个内存 cgroup/NUMA Node 共维护 4 个链表:[Inactive File, Active File, Inactive Anon, Active Anon]。Active Anod 链表中的页面需先 demote 到 Inactive 后才被回收(swap-out 或文件回写)。
4.2 kswapd 与直接回收
kswap_daemon 是内核后台回收线程。当 Zone 空闲页低于 high_wmark 时启动,做异步页回收直到达到 low_wmark 稳定。当分配请求连 min_wmark 的水线都保不住时,触发 直接回收(direct reclaim)——分配进程同步扫描 LRU 链表,造成分配延迟飙升。三种水线关系:free < min → 同步回收,min ≤ free < low → kswapd 运行,low ≤ free < high → kswapd 休眠。
4.3 页面回写与 swap
- 文件页(File page):脏页通过
writeback线程刷到磁盘。脏页比例下降后系统更安全(断电无数据丢失),但也增加了回收延迟 - 匿名页(Anonymous page):没有后备文件,回收只能 swap 到磁盘。
vm.swappiness(0-200)控制匿名页 vs 文件页回收的权重。100 表示等权重;设为 0 表示尽量不 swap 匿名页(仅内存不够时);设为 200 表示激进 swap 匿名页
调整 swappiness 对数据库等业务(大文件缓存占主导)至关重要:swappiness=0 可以避免文件缓存被驱逐。
五、OOM Killer:最后一根救命稻草
5.1 触发条件
当系统所有页面回收方式(kswapd + direct reclaim + compaction)均无法释放足够内存,且 alloc_pages() 重试达到上限,触发 Out-Of-Memory Killer。其核心逻辑在 out_of_memory() → select_bad_process() → oom_badness() 中。
5.2 oom_badness 评分公式
进程的"坏分数"计算如下:
points = (进程占用物理内存 + swap使用 + pagetable占用 + HWTE占用) * 1000 / totalmemory
加上 oom_score_adj(用户可配置的偏置,-1000到1000)后,得分最高的进程被 kill。oom_score_adj=-1000 表示不会被 kill(如 systemd、关键守护进程可以通过 /proc/<pid>/oom_score_adj 设为 -1000)。
5.3 cgroup OOM 与全局 OOM
在 cgroup v2 场景中,内存事件受 memory.max 控制器限制:当 cgroup 用量超过限制时,先触发 cgroup OOM(只影响组内进程),而不是全局 OOM。memory.pressure 指标可用于监控组内内存压力。生产经验:容器中的 OOM 多为 cgroup OOM,查看 dmesg | grep -i 'oom' 时注意区分 Memory cgroup out of memory 和 Out of memory: Killed process。
5.4 容器中 OOM 的识别
容器环境下的 OOM 识别链:
kubectl describe pod | grep -A5 Last State
→ OOMKilled: true
→ dmesg | grep oom-kill
→ journalctl -k | grep "Out of memory"
容器已被 kill 但 Pod 重启策略为 Always 时,容器会立刻重启——形成 OOMLoop。需要配合 resources.limits.memory 合理设限并启用 metrics-server 监控内存实际用量。
六、NUMA 与内存策略
6.1 NUMA 架构的影响
多路(multi-socket)服务器中,每个 CPU socket 连接本地内存和远端内存。访问远端内存的延迟通常是本地的 1.5~2 倍,跨 socket 带宽减半。lscpu | grep 'NUMA node' 可以查看 NUMA 拓扑。numastat 显示每个节点上调度和访问的命中率。
6.2 NUMA 内存分配策略
| 策略 | 定义 | 说明 |
|---|---|---|
| MPOL_DEFAULT | 本地分配 | 在当前 CPU 所在 NUMA 节点分配 |
| MPOL_BIND | 绑定分配 | 从指定节点集合分配,无可用则触发 OOM |
| MPOL_PREFERRED | 优先分配 | 优先从指定节点分配,fallback 到其他节点 |
| MPOL_INTERLEAVED | 交叉分配 | 在指定节点间轮转分配,均匀分布 |
数据中心常用 numactl --interleave=all 启动应用,使内存访问负载均衡到多个 NUMA 节点,适合 MongoDB/Redis 等大内存应用。
6.3 AutoNUMA Balancing
Linux 内核支持自动 NUMA 平衡(kernel.numa_balancing=1),通过采样进程的内存访问情况,将冷页面迁移到远端,将热页面迁移到本地节点,在不显式指定策略时自动优化。代价是扫描开销,对延迟敏感的 OLTP 数据库通常关闭此功能,改在启动时静态绑定。
七、KSM:内核同页合并(去重)
7.1 工作原理
KSM(Kernel Same-page Merging)由内核线程 ksmd 扫描内存区域,将内容相同的物理页合并为同一个写时复制(CoW)页。特别适合 KVM 虚拟化场景:多个虚拟机运行相同 OS 内核/基础库时,大量内存页相同,合并率可达 50%~80%。
7.2 KSM 参数调优
/sys/kernel/mm/ksm/pages_to_scan # 每次扫描页数,默认 100
/sys/kernel/mm/ksm/sleep_millisecs # 扫描间隔,默认 20ms
/sys/kernel/mm/ksm/run # 0=关闭, 1=运行, 2=停止并合并表清空
通过 MADV_MADVICE 标记候选页面:madvise(addr, len, MADV_MERGEABLE) 告诉内核该区域适合扫描;madvise(..., MADV_UNMERGEABLE) 取消标记。
7.3 注意事项
KSM 的扫描开销不可忽视:pages_to_scan=10000 在 64 核机器上可能造成 CPU 资源争抢。在数据库/内存敏感场景下,KSM 的写时复制延迟(首次合并后写入触发 page fault 复制)反而可能导致延迟劣化。
八、生产环境调优实践
8.1 sysctl 内存核心参数
| 参数 | 推荐值(大内存数据库) | 说明 |
|---|---|---|
| vm.dirty_ratio | 5-10 | 脏页占可用内存的最大比例,触发同步回写 |
| vm.dirty_background_ratio | 2-5 | 后台回写线程启动阈值 |
| vm.dirty_expire_centisecs | 1000-3000 | 脏页过期时间,控制回写频率 |
| vm.swappiness | 1(数据库)或 0(无 swap) | swap 倾向 |
| vm.min_free_kbytes | 总内存 0.5%~1% | 保留紧急池大小,过低会导致 GFP_ATOMIC 分配失败 |
| vm.overcommit_memory | 2(严格模式) | 2=CommitLimit = SwapTotal + overcommit_ratio × RAM |
| vm.overcommit_ratio | 80-90 | 允许过量提交的物理内存比例 |
8.2 透明大页(THP)
透明大页将 2MB(x86_64)的连续页合并为 1 个 TLB entry,减少 TLB miss。但对于数据库(如 PostgreSQL、Oracle),THP 的合并操作可能造成最严重延迟毛刺,Oracle 官方建议关闭(/sys/kernel/mm/transparent_hugepage/enabled = never)。对于大数据/AI/Spark 等顺序访问密集场景,开启 THP 可获得 10%~20% 吞吐提升。
8.3 查看与调试工具链
- /proc/meminfo:内存统计全貌,MemAvailable = MemFree + 可回收缓存 + SReclaimable
- /proc/zonelist:NUMA fall-back 顺序
- /proc/buddyinfo:每个 Zone 各阶 buddy 块数量(碎片诊断关键)
- /proc/slabinfo:SLAB/SLUB 缓存用量
- /proc/pid/smaps:进程级内存映射详细统计
- vmstat -w 1:实时内存 include si/so(swap in/out)
- sar -r 1:历史内存用量
- slabtop:实时 slab 缓存查看(如 top)
- perf probe --add 'mm_page_alloc':通过 tracepoint 跟踪分配热点
8.4 内存碎片化应急处理
如果 /proc/buddyinfo 中高阶(order ≥ 7)块数为 0,说明内存碎片严重。应急处理:
# 1. 强制内存压缩
echo 1 > /proc/sys/vm/compact_memory
# 2. 如果仍不能解决,重启受影响服务(释放大量 unmovable 页)
# 3. 禁用透明大页,避免碎片源
echo never > /sys/kernel/mm/transparent_hugepage/enabled
根治需要调节对端:开启 vm.extfrag_threshold 控制外部碎片风险评分;长期方案是在启动时预留大页(hugepagesz=2M hugepages=2048),消除数据库外碎片来源。
九、进阶话题:内存热插拔与 CXL 内存
9.1 内存热插拔
Linux 支持物理内存的在线添加和移除(memory_hotplug)。核心步骤:离线页 → 迁移 → 删除 section。/sys/devices/system/memory/memory<nr>/state 文件控制 memory block 的 online/offline 状态。在云厂商(阿里云、AWS)的弹性内存功能中已有应用,但要注意 offlining 时必须确保页面已无 Movable 页——这不是所有页面都可以迁移的(如内核锚定页)。
9.2 CXL 内存
Compute Express Link(CXL)定义了一种基于 PCIe 物理层的缓存一致性高速互联协议,允许主机访问连接在 CXL 设备上的 DRAM。在 Linux 内核中,CXL 内存被描述为额外的 NUMA 节点(node_offline 或独立 zone)。CXL 使得 TB 级扩展内存池成为可能(内存分层:本地 DRAM → CXL-DRAM → NVMe Swap),但在内核内存管理层面的统一抽象仍在快速演进中。
结语
Linux 内存管理子系统是一个精密的实时运行协同体:Buddy 管理物理页、SLUB 管理小对象、LRU 做回收策略、OOM 做最后兜底、NUMA 做拓扑感知、KSM 做去重优化。理解这些机制的交互方式,是内核性能调优、故障排查和容量规划的基石。在大内存、多核、NUMA 服务器成为标配的当下,掌握 MM 子系统已不再是"高级话题",而是系统工程师的基本功。

发表评论 取消回复