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 分配与释放流程

分配流程:

  1. 检查请求大小,向上取整到最近的2的幂
  2. 在对应阶的空闲链表中查找,若有空闲块则直接返回
  3. 若当前阶无空闲块,向更大的阶"借用"——将大块一分为二,一半返回给请求者,另一半加入当前阶的空闲链表
  4. 重复此过程直到找到空闲块

释放流程:

  1. 将释放的内存块加入对应阶的空闲链表
  2. 检查该块的"伙伴"Buddy是否也在空闲链表中
  3. 若伙伴空闲,则合并为更大的块(阶+1),继续尝试向上合并
  4. 重复直到达到最大阶或伙伴不在空闲状态

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分配流程:

  1. 检查CPU本地缓存freelist,若有空闲对象直接弹出
  2. 检查CPU本地缓存页的partial链表,从中获取空闲对象
  3. 检查当前使用页的空闲链表,继续分配
  4. 若所有以上均失败,调用Buddy系统分配新的页,初始化后返回对象

SLUB释放流程:

  1. 检查CPU本地缓存数量和年龄,决定是否返回本地缓存
  2. 若本地缓存已满,将部分对象返回给共享partial链表
  3. 若页全部空闲,将页归还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硬件完成,具体过程:

  1. CPU生成虚拟地址,发送到MMU
  2. MMU从CR3寄存器获取PML4表基地址
  3. 逐级查表:PML4E → PUDE → PMDE → PTE,每级9位索引(512项)
  4. PTE中存储物理页框号(PFN),与页内偏移组合得到物理地址
  5. 若某一级页表项不存在(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:较长时间未访问的页面,优先被回收

页面在两个链表之间移动:

  1. 新分配或换入页面首先进入Active list
  2. Active list中未被二次访问的页面随时间移至Inactive list
  3. Inactive页面被访问时重新提升到Active list
  4. Inactive页面被回收或换出时释放

这种双链表设计避免了传统LRU的扫描开销,同时确保"最近经常使用的页面保留在内存中"。

4.2 页面回收的触发机制

页面回收可以通过以下方式触发:

  1. 内存不足(Direct Reclaim):当空闲内存低于low水位线时,分配者同步执行回收
  2. kswapd后台回收:内核kswapd进程周期性扫描,当空闲内存低于min_watermark时唤醒,回收至high_watermark停止
  3. 内存压力通知(PSI):Pressure Stall Information机制向用户态报告内存压力,主动触发清理

4.3 页面换出(Swap)

匿名页面(无文件后备的内存,如堆、栈)通过swap机制交换到磁盘:

  1. 页面被选中换出时,分配swap空间
  2. 将页面数据写入swap分区/文件
  3. 更新PTE的swap entry,指向swap空间位置
  4. 页面再次访问时触发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通过以下步骤优化页面位置:

  1. 周期性或基于迁移窗口扫描进程内存
  2. 使用Page Fault采样判断页面被哪个节点CPU访问
  3. 若页面被远程节点频繁访问,将页面移回本地节点或标记为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分配。分配顺序如下:

  1. 优先从本地节点的ZONE_NORMAL分配
  2. 若不足,尝试本地节点的其他Zone
  3. 尝试远程节点
  4. 触发内存回收
  5. 最后失败返回NULL
  6. 七、内存管理调试与诊断

    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
    • some:部分任务因内存压力stalled
    • full:所有任务同时stalled
    • avg10/60/300:10秒/60秒/300秒窗口的平均压力百分比

    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训练、大数据),内存管理系统仍在快速演进:

    • CXL(Compute Express Link):提供共享内存池和DRAM缓存层次,NUMA拓扑更加复杂
    • 持久内存(PMEM):要求内存管理区分持久性和易失性
    • 加速内存框架:解决GPU、NPU等异构设备的统一内存管理

    理解Linux内存管理底层原理,不仅是编写高性能应用程序的基础,更是参与内核贡献和系统架构设计的必备能力。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部