内存管理是 Linux 内核最复杂的子系统之一。它不仅负责物理页面的分配与回收,还要在有限的内存资源下保证系统稳定运行。本文将从 Buddy System 开始,逐步深入到 SLAB 分配器、页面回收算法和 OOM 杀手机制,并通过实战案例展示内存调优的最佳实践。

一、Buddy System:物理页面管理的基石

Buddy System(伙伴系统)是 Linux 内核管理物理页面的核心算法。它将所有空闲页面按 2 的幂次分组(order 0 到 order 10,即 4KB 到 4MB 连续页面),当需要分配内存时,从合适大小的链表中直接取出;如果需要的大小不存在,则将大块连续内存一分为二,一半用于分配,另一半放回低一级的链表。

当内存被释放时,Buddy System 会检查其伙伴(相邻的同大小空闲块)是否也是空闲的。如果是,则合并为更大的块,回归上一级链表。这种分割与合并的机制有效减少了外部碎片。

然而,Buddy System 以页(通常 4KB)为最小分配单位。对于内核中大量的小对象分配(如 task_struct、inode、dentry 等),内部碎片率高达 99.99%。这就是 SLAB 分配器存在的意义。

二、SLAB 分配器:解决小对象分配的内部碎片

SLAB 分配器 由 Jeff Bonwick 于 1994 年为 Solaris 设计,后被 Linux 2.1 采纳为默认的小对象分配器。其核心思想是对象缓存:

每个 SLAB 缓存(struct kmem_cache)管理特定大小的对象。每个 SLAB 是一块连续的内存(通常 1-8 页),划分为多个等大的槽位(slots)。分配时直接从空闲槽位中取出,释放时将槽位标记为空闲而非真正归还页面,从而极大地提升了频繁分配小对象的效率。

SLAB 的三大状态:

1. Full(全满):所有槽位都已分配,不再参与空闲分配。

2. Partial(部分空闲):含有已分配和空闲槽位,分配器优先从此列表取对象。

3. Empty(全空):初始状态或所有对象归还后,这类 SLAB 可以被回收归还给 Buddy System。

SLAB 引入了一个关键优化:着色(coloring)。通过在 SLAB 起始位置添加不同偏移量,使得不同 SLAB 中相同索引的对象映射到 CPU 缓存的不同行,减少缓存行冲突(cache line aliasing),从而提升多核性能。

三、SLUB 分配器:SLAB 的现代简化版

随着核心数的增加,SLAB 的复杂元数据(如每 CPU 缓存队列、Partial 链表维护)在多核环境下暴露出严重的扩展性问题。SLUB 作为 SLAB 的继任者,自 Linux 2.6.23 起成为默认分配器,其设计哲学是简化元数据、提升 可扩展性。

SLUB 的关键改进包括:

⚡ 精简 CPU 队列:每个 SLUB 使用一个简单的每 CPU freelist 指针(而非 SLAB 的多层队列),大幅减少锁争用。

⚡ 移除 Partial 链表的复杂维护:SLUB 不再为每 CPU 维护独立的 Partial 列表,而是直接在 SLAB 层面管理空闲对象。

⚡ 内存调试集成:SLUB 内置了强大的调试能力(red zoning, poisoning, tracking),通过 slub_debug 启动参数即可开启,无需像 SLAB 那样重新编译。

SLUB 在 x86_64 架构下默认配置为每 CPU 最多持有 32 个 partial 页面,超过则立即归还给 Buddy System,这有效控制了内存浪费。

四、页面回收:当内存变得紧张时

当系统空闲内存低于阈值时,kswapd 内核线程被唤醒,开始异步回收内存。其核心算法是二次机会 LRU:

内核为每种内存维护两条链表:active_list(活跃)和inactive_list(非活跃)。页面在被访问时进入活跃链表;当页面一段时间未被访问,则从活跃链表降级到非活跃链表。在非活跃链表中的页面再次被访问(第二次机会)时,会被重新提升到活跃链表。只有在非活跃链表中停留足够长时间且未被访问的页面,才会被真正回收。

回收方式主要有三种,优先级从高到低:

1. Swap 换出:将匿名内存页(进程堆、栈)写入磁盘交换分区。这是最昂贵的回收方式。

2. 丢弃 Page Cache:直接丢弃文件-backed 的干净缓存页(未修改过的文件内容)。代价最低,因为只需丢弃即可,磁盘上还有副本。

3. 回写脏页:将已修改的缓存页写回磁盘后丢弃。代价中等,涉及 I/O 操作。

当 kswapd 异步回收仍无法满足内存需求时,进程在分配内存路径上会触发直接回收(direct reclaim),此时分配进程自身被阻塞并执行回收操作,严重影响应用性能。这就是性能抖动的主要来源之一。

五、OOM 杀手:最后一道防线

当页面回收和直接回收都无法腾出足够内存时,内核的OOM 杀手(Out-Of-Memory Killer)被激活。其目标是在不崩溃整个系统的前提下,牺牲一个进程以释放内存。

OOM 杀手通过一个打分机制选择牺牲品:

内存使用量:占用物理内存越多,得分越高。

运行时间:运行越久的进程,扣分越多(倾向于保护长期运行的服务)。

nice 值:优先级越低的进程(nice 值高),越容易被杀。

oom_score_adj:一个可手动调整的权重系数(范围 -1000 到 +1000),-1000 表示永不杀死,+1000 表示最优先杀死。比如关键服务可以设为 -500 到 -1000。

最终,得分最高的进程被 SIGKILL 终止。从 kernel 5.0 开始,引入了oom_kill ordering heuristic 的改进,增加了对 memory cgroup 中进程权重的考虑,使得容器场景下的 OOM 决策更加精准。

六、实战调优:/proc 和 /sys 接口

理解内存管理机制后,以下是关键的调优参数和实战技巧:

1. swappiness(/proc/sys/vm/swappiness,默认 60):控制内核将匿名内存换出的倾向。数据库服务器建议设为 10 甚至 0(完全禁止 swap 换出),以提升性能;桌面系统可设为默认值。

2. min_free_kbytes(全局设置,或 memory cgroup 中的 memory.min,cgroup v2):保留的最小空闲内存。如果设置过高,会导致不必要的 OOM 触发;过低则可能频繁进入直接回收。建议根据工作负载静态调整。

3. overcommit_memory(/proc/sys/vm/overcommit_memory,默认 0):控制内存过量使用。设为 0 时,内核进行启发式检查;设为 1 时,永远允许 malloc 成功(依赖 OOM 兜底);设为 2 时,严格限制总申请不超过 swap + overcommit_ratio。

4. 透明大页(Transparent HugePages,THP):默认启用 "madvise" 模式,仅对显式请求的内存分配大页。某些数据库(如 Redis、PostgreSQL)建议关闭 THP,以避免延迟尖刺。

5. 内存 cgroup v2:在生产环境中,通过 cgroup v2 隔离关键服务的内存:memory.min 设置硬保护下限,memory.low 设置软保护,memory.max 限制硬上限。

查看当前内存状态:cat /proc/meminfo、slabtop、vmstat -s。

七、高级话题与展望

Sparse Memory Model:在 NUMA 系统中,Linux 使用 SPARSEMEM 模型管理热插拔内存,每个 memory section(通常为 128MB)独立维护页表。新版内核(5.15+)引入了 VMAP_FREE 机制用于高效回收页表本身,减少了内存管理元数据的内存占用。

slab merge(SLAB 合并):SLUB 会自动合并大小和构造函数相同的缓存,减少元数据开销。但在固态硬盘(SSD)和 NVDIMM 时代,这种优化变得不那么关键,内核社区也在讨论是否部分简化。

page table scalability:在 512GB+ 物理内存的服务器上,页表本身可能占用数吉字节内存。透明大页、1GB 大页(hugetlbfs)和五层级页表(LA57)都是为了缓解这个问题。

八、总结

Linux 内核内存管理是一个从微观到宏观的完整体系:Buddy System 以页为单位管理物理内存,SLUB 分配器解决小对象分配的内部碎片,LRU 页面回收使用二次机会算法平衡缓存命中和内存压力,OOM 杀手作为最后防线牺牲个别进程保全系统整体。理解这些机制不仅能帮助我们写出内存高效的程序,更能在系统出现性能瓶颈时快速定位根因。

在实际工作中,建议从 /proc/meminfo 和 slabtop 入手,找出内存异常的源头;然后通过 sysctl 调整 swappiness 和 overcommit 策略;最后在线上环境通过 cgroup v2 隔离不同服务的内存使用。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部