Linux内核内存管理深度工程实战:从SLUB分配器到页面回收与OOM调优的完整内存子系统设计

一、内存管理子系统架构全景

Linux内核的内存管理子系统(MM)是整个操作系统中最复杂、最核心的模块之一。它不仅负责物理内存的分配与回收,更是连接处理器架构、进程管理、文件系统和设备驱动的关键枢纽。理解内存管理的完整设计,是掌握Linux内核工程实战的必经之路。

Linux内存管理采用分层架构,从底层到上层依次为:

  • 物理内存模型层:NUMA/Zone/Page三级结构,管理物理内存拓扑
  • 分配器层:Buddy分配器(页级)→ SLAB/SLUB/SLOB(对象级)→ kmalloc/vmalloc
  • 虚拟内存层:VMA管理、缺页异常、COW(写时复制)、HugeTLB
  • 回收与压缩层:LRU页面回收、KSM、Zswap/Zram、Swap
  • 控制层:cgroup内存控制、OOM Killer、内存热插拔

二、物理内存模型:NUMA、Zone与Node

2.1 NUMA拓扑与pg_data_t

在现代多核服务器架构中,NUMA(Non-Uniform Memory Access)拓扑决定了内存访问延迟的差异。Linux内核通过pg_data_t数据结构描述每个NUMA节点:

typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];    // 节点的内存区域
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配回退列表
    int nr_zones;                             // 区域数量
    struct page *node_mem_map;               // 页描述符数组
    unsigned long node_start_pfn;            // 起始页帧号
    unsigned long node_present_pages;        // 可用页数
    unsigned long node_spanned_pages;        // 总页数(含空洞)
    int node_id;                             // 节点ID
    // ...
} pg_data_t;

每个NUMA节点被划分为多个Zone,常见的Zone类型包括:

  • ZONE_DMA:0-16MB,用于ISA设备DMA
  • ZONE_DMA32:4GB以内,32位DMA设备
  • ZONE_NORMAL:直接映射区(x86_64上为1GB-物理内存大小)
  • ZONE_MOVABLE:可移动内存区,用于内存热插拔
  • ZONE_DEVICE:设备内存映射区

2.2 页描述符struct page

物理内存的最小管理单位是页(通常4KB),每个物理页对应一个struct page描述符:

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链表节点
    // ...
};

_refcount是页的引用计数:0表示空闲,大于0表示被使用。_mapcount记录页表映射次数:-1表示未映射,0表示仅内核映射,大于0表示有用户空间映射。

三、Buddy分配器:页级物理内存分配

3.1 伙伴算法核心原理

Buddy分配器是物理页分配的基础设施,通过维护11个空闲链表(order 0-10)来管理不同大小的连续页块:

  • order 0:单页(4KB)
  • order 1:2页(8KB)
  • order 2:4页(16KB)
  • order 10:1024页(4MB)

分配时从满足要求的最小order开始查找,若无可用块则向更大order递归查找并分裂;释放时将伙伴块合并为更大的空闲块。

// 伙伴判断static inline unsigned long __find_buddy_pfn(unsigned long page_pfn, unsigned int order)
{
    return page_pfn ^ (1UL << order);
}

伙伴关系的判断非常精妙:对order n的块,其伙伴的页帧号与当前块页帧号仅在第n位不同。这意味着伙伴块在物理地址和总是(2^n) * PAGE_SIZE的整数倍,保证了合并后总是对齐的。

3.2 per-cpu page allocator (PCP)

为了减少多核竞争和避免虚假共享,Linux引入了per-cpu页缓存(PCP)。每个CPU维护一个本地空闲页列表,小分配可以直接从本地列表获取:

struct per_cpu_pages {
    int count;                 // 列表中页数
    int high;                  // 高水位,超过则归还buddy
    int batch;                 // 每次从buddy申请的数量
    struct list_head lists;    // 空闲页链表
};

当PCP列表低于low水位时,从Buddy分配器批量申请batch个页;当超过high水位时,归还多余页面给Buddy。

3.3 分配掩码GFP flags

GFP(Get Free Pages)掩码编码了分配行为和约束:

  • __GFP_RECLAIM:允许直接回收
  • __GFP_IO:允许I/O操作(写脏页)
  • __GFP_FS:允许文件系统操作
  • __GFP_ZERO:清零分配的页
  • __GFP_NOFAIL:分配不可失败(可能无限循环回收)
  • __GFP_NOWARN:分配失败不打印警告
  • GFP_KERNEL:内核常规分配(可能休眠,可回收)
  • GFP_ATOMIC:原子上下文分配(不休眠,优先使用紧急储备)

四、SLUB分配器:内核对象分配的核心引擎

4.1 SLAB分配器家族演进

  • SLOB:简单链表式分配器,适用于嵌入式系统
  • SLAB:Solaris设计,全局的kmem_cache管理
  • SLUB:默认分配器,简化设计,NUMA友好,低碎片

SLUB的核心思想是:每个CPU维护本地部分空页(partial page),优先从本地分配;NUMA系统为每节点维护本地缓存。

4.2 kmem_cache核心数据结构

struct kmem_cache {
    struct kmem_cache_cpu *cpu_slab;     // per-cpu缓存
    unsigned long flags;
    unsigned int size;                   // 对象大小
    unsigned int object_size;            // 实际对象大小
    unsigned int offset;             // 下一个空闲对象偏移
    struct kmem_cache_node *node[MAX_NUMNODES]; // per-node缓存
    struct kmem_cache_order_objects oo;   // 最佳order
    // ...
};

分配器层级关系:kmem_cache → kmem_cache_node → page → object。每个cache管理特定大小的对象,所有cache通过双向链表slab_caches连接。

4.3 SLUB分配流程详解

static __always_inline void *slab_alloc(struct kmem_cache *s, gfp_t gfpflags)
{
    // 1. 检查cpu_slab->freelist(per-cpu空闲对象)
    void *object = smp_load_acquire(&s->cpu_slab->freelist);
    if (likely(object)) {
        // 快速路径:直接从per-cpu获取
        s->cpu_slab->freelist = get_freepointer(s, object);
        return object;
    }
    // 2. 检查cpu_slab->page是否有空闲位
    //    通过检查page的inuse位
    // 3. 检查cpu_slab->partial是否有空页
    // 4. 从node->partial获取部分空页
    // 5. 从buddy分配新页
}

SLUB的快速路径(fast path)极为高效:仅需一次内存加载和指针更新即可返回对象。这是SLUB在高性能场景下的核心优势。

4.4 SLUB空闲对象链表:侵入式空闲指针

SLUB巧妙利用内存本身存储空闲对象链表指针。当对象未被分配时,其前8字节(64位系统)存储下一个空闲对象的地址指针:

static inline void *get_freepointer(struct kmem_cache *s, void *object)
{
    return freelist_dereference(s, object + s->offset);
}

static inline void set_freepointer(struct kmem_cache *s, void *object, void *fp)
{
    unsigned long freeptr_addr = (unsigned long)object + s->offset;
    *(void **)freeptr_addr = fp;
}

注意offset参数:当对象大小小于指针大小时,offset=0(直接使用对象起始位置存指针);当对象较大时,offset等于对象大小(指针存在对象末尾)。

4.5 SLUB的Red Zone与Poison检测

SLUB内置强大的越界访问和释放后重用检测机制:

  • Red Zone:在对象前后填充0x12347和0x12349魔数,检测写越界
  • Poison:释放后填充0x5a或0xa5,检测释放后重用
  • 跟踪信息:记录每次alloc/free的调用栈

开启CONFIG_DEBUG_SLUB后,这些机制可有效捕获内核内存损坏问题。

五、页面回收与LRU算法

5.1 LRU链表设计

Linux采用近似LRU的双链表设计(active/inactive),每个zone维护两组链表:

  • active_list:活跃页,最近被访问过
  • inactive_list:非活跃页,回收候选

页的活跃度通过PG_active标志区分。当inactive页被访问时,通过mark_page_active提升至active链表尾部;当active链表远大于inactive时,将active页的PG_active清除,移入inactive链表。

5.2 回收扫描:shrink_lruvec

页面回收的核心入口是shrink_lruvec():

static unsigned long shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
{
    unsigned long nr[NR_LRU_LISTS];
    unsigned long reclaim_nr;
    
    // 统计各inactive页数量
    // 按比例扫描active/inactive列表
    // 回收inactive列表尾部的页
    
    // 匿名页:写入swap area再释放
    // 文件页:若脏则写回,否则直接释放
}

扫描控制参数scan_control包含:nr_to_scan(目标扫描数)、priority(扫描优先级,越高越激进)、may_writepage(是否允许写页)等。

5.3 回收水位的触发机制

内核通过三个水位触发内存回收:

  • free > high watermark:无需回收
  • low < free ≤ high:唤醒kswapd后台回收
  • li>min < free ≤ low:直接回收(direct reclaim),分配者同步阻塞回收
  • free ≤ min:紧急回收 + OOM风险

水位计算:min = managed_pages * min_free_kbytes / 100 / 4,low = min * 5/4,high = min * 3/2。

5.4 Swap与匿名页回收

匿名页(堆栈、mmap匿名映射)无法直接丢弃,必须写入Swap area才能释放:

  1. 选择牺牲页(inactive列表尾部,优先无访问位)
  2. 分配swap slot
  3. 构造bio并写入swap设备
  4. 建立PTE条目(指向swap entry)
  5. 访问时触发缺页,再从swap读取回来

页面回收中的二次机会算法:检查页的PG_referenced位,若被访问过则给予第二次机会(清除accessed bit后放回LRU)。

六、KSM、Zswap与Zram:内存压缩技术

6.1 KSM(Kernel Samepage Merging)

KSM通过扫描查找内容相同的页,合并为一份物理副本+多份COW映射。适用于虚拟化场景(大量相同OS页面):

// KSM主要数据结构
struct rmap_item {
    struct rmap_item *rmap_list;  // 相同值的rmap链表
    struct anon_vma *anon_vma;    // 反向映射
    struct mm_struct *mm;         // 所属进程
    unsigned long address;         // 虚拟地址
    unsigned int checksum;        // 内容校验和(快速筛选)
};

KSM工作流程:将页面插入稳定树(red-black tree),新页面通过checksum和逐页比较找到重复项。合并时设置写保护(COW),写时分离。

6.2 Zswap:压缩交换缓存

Zswap在页面写入swap前,先用LZ4/ZSTD压缩存储在内存缓存中:

  • 压缩比通常2:1到3:1
  • 压缩失败的页直接写入磁盘swap
  • LRU淘汰最旧的压缩缓存页到磁盘swap
  • 完全透明:对上层仍是常规swap行为

参数/sys/module/zswap/parameters/:enabled(开关)、compressor(压缩算法)、max_pool_percent(占内存最大百分比)。

6.3 Zram:内存中的块设备

Zram创建基于内存的块设备用作swap空间:

# 创建zram swap
echo lz4 > /sys/block/zram0/comp_algorithm
echo 2G > /sys/block/zram0/disksize 
mkswap /dev/zram0
swapon /dev/zram0 -p 100

Zram适合内存较小的嵌入式设备或容器环境,但也可能增加CPU开销。

七、OOM Killer:最后的防线

7.1 OOM触发条件与流程

OOM Killer在所有回收尝试失败且无法从buddy获得页时触发:

  1. page分配器遍历zonelist,尝试回收
  2. 直接回收失败,尝试OOM前先再尝试一次分配
  3. 仍然失败 → 调用out_of_memory()
  4. select_bad_process()选择终止目标
  5. oom_kill_process()发送SIGKILL

7.2 OOM评分算法:oom_badness

每个进程通过oom_badness()计算得分:

long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    // 基础分:占总内存百分比(每1% = 1分)
    // CPU占用时间加权(CPU时间越长,分数越高)
    // oom_score_adj调整(-1000到+1000)
    // 特殊进程(init、内核线程)跳过
    
    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) + mm_pmd_pages;
    adj = (long)p->signal->oom_score_adj;
    if (adj == OOM_SCORE_ADJ_MIN) return 0;  // 永不杀死
    
    points = points * 1000 / totalpages;
    points += adj;
    return max(0L, points);
}

7.3 控制OOM行为

  • /proc/[pid]/oom_score_adj:-1000(永不kill)到+1000(优先kill)
  • /proc/[pid]/oom_adj:旧接口,-17到+15
  • vm.overcommit_memory:0=启发式,1=永远允许,2=严格检查
  • vm.overcommit_ratio:overcommit=2时可分配内存比例
  • vm.panic_on_oom:0=kill进程,1=panic,2=强制panic
  • cgroup memory.limit_in_bytes:cgroup级别OOM

八、cgroup内存控制:容器级隔离

8.1 memory controller核心参数

  • memory.limit_in_bytes:硬限制,超过触发cgroup OOM
  • memory.soft_limit_in_bytes:软限制,仅在系统内存紧张时生效
  • memory.kmem.limit_in_bytes:内核内存限制(SLAB、TCP栈等)
  • memory.swappiness:该cgroup的swap倾向(v1)
  • memory.swap.max:swap使用上限
  • memory.oom.group:cgroup整体OOM(v2)

8.2 cgroup v2内存控制

cgroup v2引入了更统一的资源控制:

# cgroup v2 内存配置
echo "512M" > /sys/fs/cgroup/myapp/memory.max      # 硬限制
echo "384M" > /sys/fs/cgroup/myapp/memory.high      # 高水位(开始回收)
echo "256M" > /sys/fs/cgroup/myapp/memory.low       # 低水位(保护)
echo "128M" > /sys/fs/cgroup/myapp/memory.min       # 最小保证
echo "0"    > /sys/fs/cgroup/myapp/memory.swap.max  # 禁用swap

九、HugeTLB与大页内存

9.1 透明大页(THP)

透明大页(Transparent Huge Pages)将普通4KB页透明合并为2MB(AMD64/ARM64)大页,减少TLB miss率:

/sys/kernel/mm/transparent_hugepage/enabled = [always|madvise|never]
  always: 全局启用
  madvise: 仅在进程调用madvise(MADV_HUGEPAGE)时启用
  never: 完全禁用

THP通过khugepaged后台线程扫描并合并页面。适合数据库、Java等有大内存工作集的应用。但可能引入延迟抖动,对延迟敏感场景推荐静态大页。

9.2 静态大页(HugeTLB)

静态大页在启动时预分配,不参与Buddy管理:

# 启动参数hugepages=1024 hugepagesz=2M
# 或运行时
echo 1024 > /proc/sys/vm/nr_hugepages
# 挂载
mount -t hugetlbfs hugetlbfs /dev/hugepages
# 使用mmap
mmap(NULL, size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_HUGETLB, -1, 0);

静态大页不可被swap,无碎片风险,适合DPDK、QEMU/KVM、数据库等场景。

十、vmalloc与高端内存映射

10.1 vmalloc:分配虚拟连续但物理不连续的内存

vmalloc用于分配大块虚拟连续内存(物理页可分散):

void *vmalloc(unsigned long size)
{
    // 1. 从Buddy分配物理页
    // 2. 在vmalloc区找到虚拟地址空间
    // 3. 建立页表映射(可能触发TLB刷新)
    // 4. 返回虚拟地址
}

vmalloc的缺点:

  • 需要页表操作(可能TLB flush)
  • 物理不连续(DMA可能需要特别处理)
  • 每次调用可能触发多页分配
  • 最大可用空间受vmalloc区限制(通常128MB到数GB)

10.2 高端内存(High Memory)与32位遗留

在32位系统(4GB地址空间)中,内核空间通常1GB,其中低端896MB直接映射,剩余128MB作为vmalloc/ioremap窗口用于访问高端内存。

64位系统(x86_64的47位或56位虚拟地址)有极大地址空间,高端内存几乎不存在。vmalloc主要用于需要虚拟连续但物理不连续的大块分配场景(如模块加载、视频采集卡缓冲)。

十一、内存管理调优实践

11.1 数据库场景(MySQL/PostgreSQL/Redis)

# 禁用THP(减少延迟抖动)
echo never > /sys/kernel/mm/transparent_hugepage/enabled

# 降低swappiness,避免swap命中buffer pool
sysctl -w vm.swappiness=1

# 增大min_free_kbytes,减少直接回收风险
sysctl -w vm.min_free_kbytes=262144  # 1GB机器

# NUMA本地化
numactl --interleave=all mysqld

11.2 容器/Kubernetes场景

# 确保容器有合理的memory.high,避免OOM during burst
# 配置适当的oom_score_adj保护关键进程
# 使用Zswap减少swap I/O延迟
# 避免swap抖动:设置合理的memory.swap.max

11.3 网络高性能(DPDK/eBPF场景)

# 使用静态大页支持DPDK
echo 4096 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# 或1GB大页
echo 8 > /sys/kernel/mm/hugepages/hugepages-1048576kB/nr_hugepages

11.4 高并发Java/Go服务

# THP对Java可能有正面效果(大堆扫描),也可能负面(GC停顿抖动)
# 测试对比后决定
# Go服务建议使用madvise模式避免不必要的大页合并

十二、内存诊断工具箱

12.1 slabtop:实时监控SLAB/SLUB使用

$ slabtop -o
 Active / Total Objects (% used)    : 125478 / 130205 (96.4%)
 Active / Total Slabs (% used)      : 3847 / 3847 (100.0%)
 Active / Total Caches (% used)     : 114 / 164 (69.5%)
 Active / Total Size (% used)       : 28.45M / 29.12M (97.7%)
 Minimum / Average / Maximum Object : 0.01K / 0.22K / 8.00K

  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
 85714  85714 100%    0.19K   4083       21     16332K dentry
 38322  37246  97%    0.10K    983       39     3932K buffer_head
 13938  13938 100%    0.57K   1072       13     8576K inode_cache

12.2 vmstat:内存与Swap实时监控

$ vmstat 1
procs -----------memory---------- ---swap-- -----io----
 r  b   swpd   free   buff  cache   si   so    bi    bo
 0  0      0 524288  81920 1048576  0    0     0     2
 1  0      0 522240  81920 1050624  0    0   512   128

关键指标:si/so(swap in/out速率)若持续大于0说明严重的内存压力。

12.3 sar与/proc/meminfo深度分析

$ cat /proc/meminfo
MemTotal:       16777216 kB
MemFree:          524288 kB
MemAvailable:    8388608 kB  # 可用的内存(含可回收页)
Buffers:           81920 kB
Cached:          1048576 kB
SwapCached:            0 kB
Active:          6291456 kB
Inactive:        2097152 kB
Active(anon):    4194304 kB
Inactive(anon):   524288 kB
Active(file):    2097152 kB
Inactive(file):  1572864 kB
Unevictable:           0 kB
Mlocked:               0 kB
SwapTotal:       8388608 kB
SwapFree:        8388608 kB
Dirty:               256 kB
Writeback:             0 kB
AnonPages:       4194304 kB
Mapped:          1048576 kB
Shmem:            524288 kB
Slab:             262144 kB
SReclaimable:     196608 kB  
SUnreclaim:        65536 kB    # 内核不可回收内存
KernelStack:       16384 kB
PageTables:        32768 kB
NFS_Unstable:          0 kB
Bounce:                0 kB
WritebackTmp:          0 kB
CommitLimit:    16777216 kB
Committed_AS:   16777216 kB   # 已提交的虚拟内存
VmallocTotal:   34359738367 kB
VmallocUsed:       65536 kB
HugePages_Total:       0
HugePages_Free:        0
Hugepagesize:       2048 kB

MemAvailable从3.14内核开始提供,它估算了在不触发OOM的前提下可使用的实际内存量(含page cache中可快速回收的部分)。

12.4 BPF工具追踪内存行为

# 追踪SLUB分配
$ bpftrace -e 'kprobe:slub_alloc { @[comm] = count(); }'

# 查看直接回收延迟分布  
$ bpftrace -e 'kprobe:direct_reclaim_begin { @start = nsecs; }
    kprobe:direct_reclaim_end /@start/ { 
        @us = hist((nsecs - @start) / 1000); 
        delete(@start); 
    }'

# 追踪OOM事件
$ bpftrace -e 'kprobe:oom_kill_process { printf("OOM kill pid %d (%s)\n", 
    ((struct task_struct *)arg0)->pid, 
    ((struct task_struct *)arg0)->comm); }'

十三、NUMA感知内存分配策略

13.1 NUMA分配策略

  • LocalAlloc(默认):从请求CPU所在的NUMA节点分配
  • Preferred:优先从指定节点分配,失败回退
  • Bind:必须在指定节点分配,失败则OOM
  • Interleaved:轮询分配,分散负载

13.2 NUMA Balancing(AutoNUMA)

Linux 3.8+引入自动NUMA平衡(AutoNUMA):

  1. 扫描进程页表,标记远程映射页
  2. 采样访问延迟(通过页面 fault 时间)
  3. 将冷页迁移到本地节点
  4. 甚至迁移整个进程(migrate task to node)
# 查看NUMA状态
$ numastat
                           node0           node1
numa_local             12345678        98765432  
numa_foreign            1234567         9876543
numa_hit              120000000       950000000
numa_miss                123456         9876543
numa_interleaved           1234           98765

numa_miss表示原本应在node0的内存分配到了node1(或反之),值高说明NUMA本地化效果不佳。

十四、生产环境经典案例

案例1:容器OOM导致服务雪崩

某K8s集群中,Java Pod频繁OOM但cgroup限制充足。经诊断发现是Slab中的dentry和inode缓存占用了过多不可回收内存(SUnreclaim)。

解决方案:

  • 降低vfs_cache_pressure加速回收:vm.vfs_cache_pressure=50
  • 设置合理的cgroup kmem限制
  • 重启问题Pod清理泄漏的shmem

案例2:数据库被Swap打挂

某PostgreSQL实例突然响应延迟飙升。经查vm.swappiness=60导致Swap大量使用。

根因:Slab + buffer cache 消耗了大量内存 → kswapd 唤醒 → 数据库工作集被swap。

解决:sysctl vm.swappiness=1 + 启用Zswap压缩缓存。

案例3:NUMA远程访问导致TPS下降30%

某高频交易系统跨NUMA节点访问内存,每次访问增加~50ns。TPS从100万降至70万。

解决:

  • numactl绑核绑内存
  • 启用Auto NUMA Balancing
  • 调整中断亲和性

核心应用场景的内存本地化至关重要。

十五、前沿演进:内存管理的下一个十年

  • CXL(Compute Express Link)内存池化:打破单机内存限制,实现跨节点共享内存池
  • Unmapping Guest内存:KVM的MSEV-SNP和TDX加密内存
  • Linux Tiered Memory:NPM(Non-Volatile Persistent Memory)多级管理
  • Rust for Linux内存安全:引入Rust ABI减少内核内存损坏
  • io_uring注册的缓冲区:固定缓冲区减少TLB shootdown
  • Transparent Page Placement:异构内存(HBM+DRAM)自动页面分层
  • cgroup v2统一控制完善:更精细的递归限制和事件通知

总结

Linux内存管理子系统是一个经过20多年锤炼的精密系统工程。从Buddy分配器的伙伴算法,到SLUB分配器的per-cpu无锁快速路径,从LRU双链表的近似回收,到OOM的最后防线,每一层都经过大量生产环境验证。

掌握这些机制不仅有助于系统级问题诊断,更能指导应用程序设计:理解cgroup内存控制有助于写出更"容器友好"的代码,理解NUMA效应有助于降低内存访问延迟,理解Fragmentation有助于选择合理的分配策略。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }