Linux内核内存管理深度解析:从Buddy系统到SLUB分配器
引言
内存管理是Linux内核最复杂、最核心的子系统之一。它为应用程序、内核模块、设备驱动程序提供了高效、安全的内存分配与回收机制。理解Linux内核内存管理不仅有助于编写高性能应用程序,更是内核开发者、系统架构师的必备知识。
本文将从底层硬件特性出发,深入解析Linux内核内存管理的核心机制:Buddy分配器、SLAB/SLUB/SLOB分配器、vmalloc、内存映射、OOM Killer等,并探讨现代内核(6.x版本)的最新优化。
一、物理内存管理:Buddy分配器
1.1 为什么需要Buddy系统
物理内存管理的核心挑战有两个:一是高效满足不同大小的内存请求,二是减少外部碎片。简单的首次适应(First Fit)或最佳适应(Best Fit)算法虽然易于实现,但随着内存分配和释放的交替进行,会产生大量无法利用的小空闲块,即外部碎片。
Buddy系统(二进制伙伴系统)通过一个巧妙的设计在时间效率和空间利用率之间取得了平衡:将内存划分为2的幂次大小的块,分配时向上取整到最近的2的幂,释放时尝试与相邻的"伙伴"(Buddy)合并。
1.2 Buddy系统的核心数据结构
Linux内核中,Buddy系统在mm/page_alloc.c中实现,其核心数据结构是free_area数组:
struct free_area {
struct free_list free_list[MIGRATE_TYPES];
unsigned long nr_free;
};
每个NUMA节点(pg_data_t)包含一个free_area数组,包含从0到MAX_ORDER-1(通常11阶,即最大连续内存2^10 = 4MB)共11个链表。每个链表管理对应阶大小的空闲内存块。
1.3 分配与释放流程
分配流程:
- 检查请求大小,向上取整到最近的2的幂
- 在对应阶的空闲链表中查找,若有空闲块则直接返回
- 若当前阶无空闲块,向更大的阶"借用"——将大块一分为二,一半返回给请求者,另一半加入当前阶的空闲链表
- 重复此过程直到找到空闲块
释放流程:
- 将释放的内存块加入对应阶的空闲链表
- 检查该块的"伙伴"Buddy是否也在空闲链表中
- 若伙伴空闲,则合并为更大的块(阶+1),继续尝试向上合并
- 重复直到达到最大阶或伙伴不在空闲状态
1.4 伙伴(Buddy)的判断条件
两个块是伙伴当且仅当满足以下条件:
- 大小相同(同一阶)
- 物理地址连续
- 低阶块(地址较低者)的地址能被当前阶大小整除
- 两者来自同一个父块(即同时被分割产生)
伙伴的物理地址关系非常简单:若块A的地址为X,块大小为S,则伙伴的地址为X XOR S。这个位运算关系是Buddy系统高效合并的数学基础。
1.5 内存迁移类型(MIGRATE_TYPES)
为了应对内存碎片,Linux内核引入了内存迁移类型(Page Migration Types):
- MIGRATE_UNMOVABLE:不可移动的内核页(如内核代码、页表)
- MIGRATE_MOVABLE:可移动的用户空间页
- MIGRATE_RECLAIMABLE:可回收的内核页(如缓存)
- MIGRATE_HIGHATOMIC:为原子分配保留(中断上下文)
- MIGRATE_ISOLATE:离线内存(热插拔)
- MIGRATE_CMA:连续内存分配器(CMA)
系统启动时,所有空闲页初始标记为MOVABLE;内核模块分配不可移动页时,从系统中"盗取"对应区域。不同类型的页独立管理,避免不可移动页阻碍后续的可移动页大页合并。
1.6 反碎片(Anti-Fragmentation)
Linux内核通过分配时按迁移类型分组、回收时按迁移类型隔离的方式应对碎片问题。此外,内核提供了两种运行时反碎片技术:
主动内存规整(Memory Compaction):扫描内存区域,将可移动页迁移至一侧,释放另一侧连续内存。此机制在申请高阶连续内存失败时自动触发,也可通过/proc/sys/vm/compact_memory手动触发。
页面回收(Page Reclaim):当系统内存不足时,通过LRU(最近最少使用)链表回收最近不活跃的页面,脏页回写后释放。
二、内核堆分配:SLAB / SLUB / SLOB
2.1 SLAB设计动机
Buddy系统最小粒度是页(通常4KB),而内核中大量对象远小于一页(如task_struct约8KB、inode约600字节、dentry约200字节)。若直接通过Buddy分配,会严重浪费内存(内部碎片率可达50%以上)。
SLAB分配器在Buddy系统之上构建了一层缓存层,专门用于频繁分配和释放的小对象。其核心思想是:
- 对象缓存池:为每种常用内核对象维护一组预分配的对象池
- 快速分配:分配时直接从缓存池中取出已初始化的对象,避免重复初始化开销
- 构造函数/析构函数:对象分配时调用构造函数初始化,释放时调用析构函数清理
2.2 SLAB核心数据结构
SLAB分配器由三个层次的结构组成:
// 1. 缓存描述符
struct kmem_cache {
struct kmem_cache_cpu *cpu_slab; // CPU本地缓存指针
unsigned long flags; // 标志位(如SLAB_HWCACHE_ALIGN)
unsigned long object_size; // 对象实际大小
unsigned long size; // 含元数据的对象大小
unsigned int align; // 对齐要求
const char *name; // 缓存名称(用于调试)
struct list_head list; // 全局缓存链表
// SLAB管理
unsigned int num; // 每SLAB对象数量
slab_flags_t slab_flags; // SLAB标志
unsigned int gfporder; // 每SLAB阶数(2^order pages)
// 构造/析构
int (*ctor)(void *obj);
void (*dtor)(void *obj);
};
// 2. CPU本地缓存(每CPU)
struct kmem_cache_cpu {
void **freelist; // 空闲对象指针列表
unsigned long tid; // 全局唯一事务ID
struct page *page; // 当前正在使用的SLAB页
struct page *partial; // 半满SLAB链表(仅多CPU/NUMA)
} ____cacheline_aligned_in_smp;
// 3. SLAB页结构
struct slab {
union {
struct list_head slab_list; // 链表节点
struct {
struct slab *next; // 下一个SLAB
int slabs; // 页数量
};
};
void *s_mem; // 对象内存起始地址
unsigned int inuse; // 已使用对象数量
unsigned int objects; // 总对象数量
unsigned long __unused; // 对齐填充
};
SLAB的三种状态:
- Full:所有对象都在使用中
- Partial:部分对象被使用,部分空闲
- Empty:所有对象空闲
2.3 SLUB分配器——现代默认选择
SLUB(Unqueued SLAB)是Linux 2.6.23版本引入的现代SLAB分配器,如今已成为所有主流平台的默认选项。它在SLAB基础上做了大量简化:
关键改进:
- 移除每CPU复杂队列:使用简单的per-CPU freelist链表,避免SLAB中繁琐的full/empty链表管理
- 简化Partial SLAB管理:NUMA节点下使用共享的partial链表,减少锁争用
- 热缓存友好:CPU本地缓存充分利用CPU缓存行,减少缓存失效
- 内存效率更高:简化了SLAB元数据结构,减少了管理开销
- 调试与检测能力:strong>内置red zone和poison功能,检测越界写入和释放后使用(UAF)
SLUB分配流程:
- 检查CPU本地缓存freelist,若有空闲对象直接弹出
- 检查CPU本地缓存页的partial链表,从中获取空闲对象
- 检查当前使用页的空闲链表,继续分配
- 若所有以上均失败,调用Buddy系统分配新的页,初始化后返回对象
SLUB释放流程:
- 检查CPU本地缓存数量和年龄,决定是否返回本地缓存
- 若本地缓存已满,将部分对象返回给共享partial链表
- 若页全部空闲,将页归还Buddy系统
2.4 SLOB——极致精简版
SLOB(Simple List Of Blocks)是为嵌入式系统设计的极简分配器,特点:
- 内存开销极小(仅约6字节/页元数据)
- 适用于内存极度受限的目标设备(如ARM Cortex-M路由器、传感器)
- 采用首次适应(First Fit)算法,速度较慢,易产生碎片
- 不适合通用服务器场景,默认关闭
通过config SLOB选择,多与CONFIG_EXPERT配合使用。
2.5 slabinfo / slabtop 工具
Linux提供了/proc/slabinfo和slabtop命令行工具,用于查看SLAB/SLUB分配器的实时状态:
$ cat /proc/slabinfo | head -20
slabinfo - version: 2.1
# name <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-8k 128 128 8192 4 8 : tunables 0 0 0 : slabdata 32 32 0
kmalloc-4k 512 512 4096 8 4 : tunables 0 0 0 : slabdata 64 64 0
kmalloc-2k 1024 1024 2048 16 2 : tunables 0 0 0 : slabdata 64 64 0
kmalloc-1k 2048 2048 1024 32 2 : tunables 0 0 0 : slabdata 64 64 0
kmalloc-512 4096 4096 512 64 1 : tunables 0 0 0 : slabdata 64 64 0
kmalloc-256 8192 8192 256 128 1 : tunables 0 0 0 : slabdata 64 64 0
dentry 4096 4096 192 21 1 : tunables 0 0 0 : slabdata 192 192 0
inode_cache 2048 2048 584 7 2 : tunables 0 0 0 : slabdata 288 288 0
task_struct 256 256 7040 5 8 : tunables 0 0 0 : slabdata 50 50 0
各列含义:活跃对象数、总对象数、对象大小、每SLAB对象数、每SLAB页数、tunables(批处理参数)、活跃SLAB数、总SLAB数。
三、虚拟内存与内存映射
3.1 虚拟地址空间
Linux采用进程独立的虚拟地址空间,每个进程拥有自己完整的地址映射表。x86_64架构下采用4级页表(PML4/PUD/PMD/PTE),地址空间通常划分为:
- 用户空间:0x0000000000000000 - 0x00007FFFFFFFFFFF(128TB)
- 内核空间:0xFFFF800000000000 - 0xFFFFFFFFFFFFFFFF(128TB)
用户空间与内核空间通过不同的页表项隔离,用户态访问内核空间会触发页错误(Page Fault)异常。
3.2 页表管理与虚拟地址翻译
虚拟地址到物理地址的翻译由MMU硬件完成,具体过程:
- CPU生成虚拟地址,发送到MMU
- MMU从CR3寄存器获取PML4表基地址
- 逐级查表:PML4E → PUDE → PMDE → PTE,每级9位索引(512项)
- PTE中存储物理页框号(PFN),与页内偏移组合得到物理地址
- 若某一级页表项不存在(Present位为0),触发Page Fault异常
为加速地址转换,CPU内置了TLB(Translation Lookaside Buffer),缓存最近使用的页表项。上下文切换时TLB条目可能失效,需使用INVLPG指令或PCID(Process-Context Identifiers)减少刷新开销。
3.3 内存映射机制
内核支持多种内存映射方式:
直接Buddy分配(GFP):适用于小于4MB的连续物理内存分配,使用GFP(Get Free Pages)系列函数:
- GFP_KERNEL:标准分配,允许睡眠,可用于进程上下文
- GFP_ATOMIC:原子分配,不可睡眠,用于中断上下文或持有自旋锁时
- GFP_NOIO:禁止I/O操作,避免递归I/O
- GFP_NOWAIT:不等待、不回收,失败立即返回
- __GFP_HIGHMEM:允许从High Memory分配
内存映射文件(mmap):将文件映射到进程虚拟空间,实现高效文件I/O:
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
关键标志:MAP_SHARED(共享修改映射)、MAP_PRIVATE(写时复制)、MAP_ANONYMOUS(匿名映射,不关联文件)、MAP_POPULATE(预读页面)。
vmalloc:分配虚拟连续但物理不一定连续的内存,适用于大容量不超过物理连续内存的场景:
void *vmalloc(unsigned long size);
void vfree(const void *addr);
vmalloc的限制:性能开销高于直接映射(需修改页表)、不支持DMA(通常)、地址在VMALLOC区间。
3.4 反向映射(Reverse Mapping)
反向映射允许从物理页反向查找所有映射它的虚拟地址(VMA),对页面回收至关重要。Linux内核维护两种反向映射:
- 基于对象(Object-based):文件映射页通过地址空间(address_space)的优先搜索树(PriTree)管理
- 基于页表(PTE-based):匿名映射页通过anon_vma链表管理
当页面回收时,内核利用反向映射快速找到并更新所有相关PTE条目,标记为已换出或已回收。
四、内存回收与OOM Killer
4.1 LRU链表与页面回收
Linux使用改进的LRU(最近最少使用)算法进行页面回收。每个NUMA节点维护两个LRU双链表:
- Active list:最近访问过的页面,优先不被回收
- Inactive list:较长时间未访问的页面,优先被回收
页面在两个链表之间移动:
- 新分配或换入页面首先进入Active list
- Active list中未被二次访问的页面随时间移至Inactive list
- Inactive页面被访问时重新提升到Active list
- Inactive页面被回收或换出时释放
这种双链表设计避免了传统LRU的扫描开销,同时确保"最近经常使用的页面保留在内存中"。
4.2 页面回收的触发机制
页面回收可以通过以下方式触发:
- 内存不足(Direct Reclaim):当空闲内存低于low水位线时,分配者同步执行回收
- kswapd后台回收:内核kswapd进程周期性扫描,当空闲内存低于min_watermark时唤醒,回收至high_watermark停止
- 内存压力通知(PSI):Pressure Stall Information机制向用户态报告内存压力,主动触发清理
4.3 页面换出(Swap)
匿名页面(无文件后备的内存,如堆、栈)通过swap机制交换到磁盘:
- 页面被选中换出时,分配swap空间
- 将页面数据写入swap分区/文件
- 更新PTE的swap entry,指向swap空间位置
- 页面再次访问时触发Page Fault,从swap换入
Swap带来的代价是磁盘I/O延迟,因此现代服务器常避免swap或设置swappiness=1。
4.4 OOM Killer——最后一道防线
当系统完全无法回收内存、swap也写满时,OOM Killer会选择一个进程终止以释放内存。其核心逻辑是mm/oom_kill.c中的oom_badness()函数,根据以下因素计算每个进程的OOM分数:
- 使用的物理内存 + swap总量
- 运行时间(越短越不容易被Kill)
- 进程优先级(nice值,低分进程优先)
- oom_score_adj(可手动调整)
- 是否是特权进程(init/systemd豁免)
分数最高的进程被选中终止。/proc/[pid]/oom_score和oom_score_adj允许用户和系统管理员调整进程的被杀优先级。
4.5 cgroup内存限制与OOM
现代容器化环境中,cgroup v1/v2通过memory.limit_in_bytes限制控制组内存使用。当cgroup内内存超限时,仅触发该cgroup内的OOM Killer(cgroup OOM),而不是影响整个系统。这比传统OOM更可控,Kubernetes等编排系统依赖此机制实现Pod级内存隔离。
五、大页(Huge Pages)与透明大页(THP)
5.1 大页原理
传统页大小为4KB,T LB条目有限(通常512-1024项)。当进程使用大量内存时,TLB Cache Miss率急剧上升,性能严重下降。使用大页(2MB或1GB)可以显著减少TLB压力。
TLB覆盖对比:
- 4KB页:512项 × 4KB = 2MB覆盖
- 2MB大页:512项 × 2MB = 1GB覆盖
- 1GB大页:512项 × 1GB = 512GB覆盖
大页将TLB覆盖提升三个数量级,对数据库(MySQL/PostgreSQL)、Java堆等内存密集型应用效果显著。
5.2 静态大页(HugeTLB)
静态大页在系统启动时通过hugepages=内核参数预分配,启动后不可动态调整。使用通过hugetlbfs文件系统和mmap实现,需要应用程序显式申请。
5.3 透明大页(Transparent Huge Pages)
THP允许动态将普通4KB页合并为2MB大页,对应用程序透明。内核通过khugepaged后台守护进程扫描内存区域,当多个连续4KB页满足条件时,自动合并为大页。
THP模式:
- always:始终尝试合并(可能导致内存浪费)
- madvise:仅对通过madvise(MADV_HUGEPAGE)标记的区域合并
- never:禁用THP(数据库场景推荐)
数据库通常推荐禁用THP并改用静态大页,避免THP碎片化导致的STALL问题。
六、NUMA感知内存管理
6.1 NUMA架构
在NUMA(Non-Uniform Memory Access)架构中,每个CPU节点访问不同内存节点的速度不同(本地内存远快于远程内存)。Linux内核通过以下机制感知NUMA拓扑:
- ACPI SRAT/SLIT表提供硬件拓扑信息
- 每个CPU的内存分配优先从本地节点获取
- NUMA Balancing机制自动将页面迁移至访问者所在节点
6.2 NUMA Balancing
Linux 3.8引入的NUMA Balancing通过以下步骤优化页面位置:
- 周期性或基于迁移窗口扫描进程内存
- 使用Page Fault采样判断页面被哪个节点CPU访问
- 若页面被远程节点频繁访问,将页面移回本地节点或标记为migrate-on-fault
可以通过/proc/sys/kernel/numa_balancing启用/禁用,或通过mbind()或set_mempolicy()系统调用手动绑定策略。
6.3 Zone-Based分配
Linux内核将内存划分为不同Zone(ZONE_DMA, ZONE_DMA32, ZONE_NORMAL, ZONE_MOVABLE),不同设备DMA能力不同,需要从低地址Zone分配。分配顺序如下:
- 优先从本地节点的ZONE_NORMAL分配
- 若不足,尝试本地节点的其他Zone
- 尝试远程节点
- 触发内存回收
- 最后失败返回NULL
- some:部分任务因内存压力stalled
- full:所有任务同时stalled
- avg10/60/300:10秒/60秒/300秒窗口的平均压力百分比
- CXL(Compute Express Link):提供共享内存池和DRAM缓存层次,NUMA拓扑更加复杂
- 持久内存(PMEM):要求内存管理区分持久性和易失性
- 加速内存框架:解决GPU、NPU等异构设备的统一内存管理
七、内存管理调试与诊断
7.1 内存泄漏检测
kmemleak是内核内置的内存泄漏检测工具,原理类似垃圾回收器的标记-清除:
// 启用kmemleak
echo scan > /sys/kernel/debug/kmemleak
cat /sys/kernel/debug/kmemleak
它会跟踪所有kmalloc/vmalloc分配,扫描内存(栈、寄存器、全局变量)查找指针。未被引用的已分配块被标记为泄漏。开销较大,仅建议调试时使用。
7.2 Page Owner追踪
Page Owner记录每个物理页的分配者和调用栈,帮助定位内存异常使用:
// 启用Page Owner
page_owner=1
// 查看分配信息
cat /sys/kernel/debug/page_owner
7.3 内存压力信息(PSI)
Pressure Stall Information (PSI)是Linux 4.20引入的内核特性,通过/proc/pressure/提供内存压力信息:
$ cat /proc/pressure/memory
some avg10=0.00 avg60=0.00 avg300=0.00 total=1234567
full avg10=0.00 avg60=0.00 avg300=0.00 total=234567
PSI是容器编排系统(如Kubernetes)进行资源监控和弹性伸缩的重要指标源。
八、现代内核内存管理新特性
8.1 Folios:新的内存管理单元
Linux 5.16引入了Folio的概念,将一组连续页(Compound Page)抽象为单一管理单元。Folio解决了传统复合页管理复杂度问题,统一了HugeTLB、文件缓存、匿名页的管理接口,代码复用性大幅提升。
8.2 DAMON:数据访问监控
DAMON(Data Access MONitor)是Linux 5.15引入的轻量级框架,支持对任意内存区域的精确访问模式监控,为NUMA Balancing、内存压缩、自定义内存策略提供数据基础。
8.3 MGLRU:多代LRU
Linux 6.1引入的多代LRU(Multi-Gen LRU)是传统LRU的重大重构。它将页面按年龄组织为多代,每一代对应不同的活跃度和回收成本,大幅提升了内存回收的精确度和CPU效率。Google的实验数据显示,在Chrome浏览器场景下,MGLRU相比传统LRU减少了50%以上的页面抖动。
8.4 Maple Tree
Linux 6.1引入了新的平衡搜索树Maple Tree用于内存映射管理,替代了传统的基于红黑树(为基础的)VMA结构。Maple Tree支持范围查询效率更高、锁争用更小,显著提升了多线程工作负载下的mmap/munmap性能。
九、总结与展望
Linux内核内存管理是一个经过二十多年持续演进的复杂系统,从早期的简单Buddy+sLAB到如今的多层次、多代、NUMA感知的智能体系。核心设计理念始终围绕一个目标:在有限的物理资源下,最大化系统吞吐量、最小化延迟开销。
随着硬件发展(持久内存、CXL、HBM)和工作负载演化(AI训练、大数据),内存管理系统仍在快速演进:
理解Linux内存管理底层原理,不仅是编写高性能应用程序的基础,更是参与内核贡献和系统架构设计的必备能力。

发表评论 取消回复