Linux 内核内存管理深度实战:从物理页到 slab 分配器的完整链路

内存管理是 Linux 内核最复杂的子系统之一,它直接决定了系统的性能上限。本文将从底层硬件机制出发,系统梳理物理内存模型、虚拟内存布局、buddy 分配器、kmalloc/vmalloc、slab 分配器链、OOM killer 等核心组件的工作原理,并给出实战级的内存调试与优化方案。

一、物理内存模型:从 UMA 到 NUMA

1.1 UMA 与 NUMA 架构差异

在传统的 UMA(Uniform Memory Access)架构中,所有 CPU 通过同一总线访问内存,延迟一致。而现代服务器普遍采用 NUMA(Non-Uniform Memory Access)架构,每个 CPU 节点绑定本地内存,跨节点访问会带来额外的 QPI/UPI 总线延迟。

Linux 内核通过以下数据结构描述 NUMA 拓扑:

// NUMA 节点描述符
typedef struct pglist_data {
    struct zone node_zones[MAX_NR_ZONES];    // 每个节点的内存区域
    struct zonelist node_zonelists[MAX_ZONELISTS]; // 分配 fallback 列表
    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;

通过 numactl --hardware 可以查看 NUMA 拓扑信息。在 4 路服务器上,不合理的跨节点内存分配可能导致 2-3 倍的访问延迟惩罚。

1.2 内存区域(Zone)的划分

每个 NUMA 节点内部,内存被进一步划分为多个 Zone,以兼容 DMA 等硬件约束:

Zone 类型说明典型大小
ZONE_DMAISA DMA 设备可用,16MB0-16MB
ZONE_DMA3232 位 DMA 设备可用16MB-4GB
ZONE_NORMAL内核可直接线性映射4GB-896MB(32位)
ZONE_HIGHMEM动态映射高端内存(仅 32 位系统)> 896MB
ZONE_MOVABLE可迁移页面(用于内存热插拔)动态

64 位系统中 ZONE_HIGHMEM 已消失,48 位或 57 位虚拟地址空间足以线性映射全部物理内存(最高支持 64TB/128PB)。

1.3 struct page — 物理页面的身份证

内核为每一个物理页面维护一个 struct page 描述符(位于全局数组 mem_map 中),这是内存管理最核心的数据结构:

struct page {
    unsigned long flags;        // 页状态标志(PG_locked, PG_dirty, PG_lru ...)
    union {
        struct address_space *mapping; // 页映射的文件/匿名页
        void *s_mem;            // slab 对象起始地址
        // ...
    };
    atomic_t _refcount;         // 引用计数
    atomic_t _mapcount;         // 映射计数
    unsigned long private;      // 私有数据(buddy 中存 order、slab 中存 slab 指针)
    // ...
};

struct page 的大小约为 64 字节,16GB 物理内存对应的 mem_map 数组约占用 16MB 内核空间。每个 page 的 _refcount 为 0 表示空闲,大于 0 表示被使用。

二、虚拟内存与多级页表体系

2.1 五层页表架构(5-Level Page Table)

Linux 5.12+ 支持 5 级页表(PGD→P4D→PUD→PMD→PTE),虚拟地址空间扩展至 57 位(128PB):

// x86_64 五级页表结构
#define PGDIR_SHIFT  48       // 第 47-39 位
#define P4D_SHIFT    47       // 第 47 位(通常 5 级折叠时与 PGD 合并)
#define PUD_SHIFT    39       // 第 38-30 位
#define PMD_SHIFT    30       // 第 29-21 位
#define PTE_SHIFT    12       // 第 20-12 位(4KB 页)

// 4KB 页:12 位 offset + 9×4 级 = 48 位虚拟地址
// 2MB 大页:21 位 offset + PMD 直接指向(Huge Page 跳过 PTE)
// 1GB 大页:30 位 offset + PUD 直接指向

页表查找过程由硬件 MMU 的 Page Walk Cache(PWC,即 TLB)加速。L1 TLB 通常 64 条目(覆盖 256KB),L2 TLB 通常 1536 条目(覆盖 6MB,含大页合并)。翻译未命中时硬件自动触发 page walk,消耗约 20-100 个 CPU 周期。

2.2 进程内存布局(mm_struct)

每个进程通过 mm_struct 管理其虚拟地址空间,mmap 红黑树组织所有的 VMA(Virtual Memory Area):

struct mm_struct {
    struct vm_area_struct *mmap;     // VMA 红黑树根
    struct rb_root mm_rb;            // 红黑树快速查找
    unsigned long mmap_base;         // mmap 区域起始地址
    unsigned long task_size;         // 用户空间大小
    unsigned long start_code, end_code;   // 代码段
    unsigned long start_data, end_data;   // 数据段
    unsigned long start_brk, brk;         // 堆
    unsigned long start_stack;            // 栈起始
    unsigned long arg_start, arg_end;     // 参数区
    // ...
};

64 位 Linux 典型布局(用户空间):

  • 0x0000_0000_0000 ~ 0x0000_7FFF_FFFF_FFFF:用户空间(128TB)
  • 0xFFFF_8000_0000_0000+:内核空间
  • 栈从高位向下增长,堆通过 sbrk 向上扩展
  • mmap 区域从 mmap_base 向下/向上分配

三、Buddy 分配器:物理页的核心引擎

3.1 伙伴系统的核心原理

Buddy 分配器管理 Zone 级别的物理页分配与回收。它将页按 order(2^n 个连续页)组织成链表,分配时分裂、释放时合并:

// 每个 Zone 的 free_area 数组
struct zone {
    struct free_area free_area[MAX_ORDER]; // order 0~10
    // free_area[0]: 单页链表 (2^0 = 1 page)
    // free_area[1]: 2 页链表 (2^1 = 2 page)
    // ...
    // free_area[10]: 4MB 链表 (2^10 = 1024 pages = 4MB)
};

分配 3 个页面的流程:

  1. 查找 free_area[0],为空
  2. 查找 free_area[1],有空闲
  3. 取出一个 2 页组,分裂为两个单页
  4. 一个单页给 free_area[0],返回另一个单页
  5. free_area[0] 仍无空闲,上升到 free_area[2]
  6. 取出 4 页组,分裂为两个 2 页组
  7. 一个入 free_area[1],另一个继续分裂
  8. 最终得到 3 个连续页,1 个单页归入 free_area[0]

3.2 alloc_pages 完整调用链

alloc_pages(gfp_mask, order)
  → __alloc_pages_nodemask()
    → get_page_from_freelist()   // 快速路径:从伙伴系统直接取
    → __alloc_pages_slowpath()   // 慢速路径:回收/压缩/OOM
      → __alloc_pages_direct_reclaim()
      → __alloc_pages_direct_compact()
      → __alloc_pages_may_oom()

gfp_mask 是分配行为的核心控制参数:

  • GFP_KERNEL:标准内核分配,可能睡眠
  • GFP_ATOMIC:原子上下文,不可睡眠,从紧急储备分配
  • GFP_DMA:要求 DMA 可用内存
  • __GFP_ZERO:零初始化
  • __GFP_HIGHMEM:允许使用高端内存

3.3 页面回收与 LRU 机制

当内存紧张时,Buddy 分配器触发页面回收。内核维护两个 LRU 链表:

  • Active Anonymous:活跃匿名页(最近被访问)
  • Inactive Anonymous:非活跃匿名页(候选回收)
  • Active File:活跃文件缓存页
  • Inactive File:非活跃文件缓存页(优先回收,因为磁盘有副本)

kswapd 内核线程在后台运行,当 free 页面低于 pages_high 时开始异步回收,低于 pages_min 时触发 direct reclaim(同步回收,会阻塞分配进程)。

// LRU 页面老化与回收流程
页面访问 → mark_page_accessed() → 从 Inactive 晋升 Active
页面长时间未访问 → shrink_lruvec() → 从 Active 降级到 Inactive
Inactive 尾部页面 → shrink_page_list() → 回写/交换/丢弃
    ├─ 匿名页:写入 swap 分区/文件
    ├─ 脏文件页:回写到底层文件系统
    └─ 干净文件页:直接丢弃(下次读文件时重新加载)

四、kmalloc vs vmalloc — 两个关键接口的本质差异

4.1 kmalloc 的物理连续保证

kmalloc 通过 slab 分配器获取物理连续的内存,这是驱动和内核子系统的默认选择:

void *kmalloc(size_t size, gfp_t flags);

// 大小限制:通常最大 4MB(KMALLOC_MAX_SIZE)
// 小于 PAGE_SIZE:直接从 slab 缓存分配
// 大于 PAGE_SIZE 小于 4MB:从 buddy 系统分配,但仍由 kmalloc 管理
// 超过 KMALLOC_MAX_SIZE:失败,需使用 vmalloc

关键限制:物理连续性意味着在内存碎片化严重时,即使总空闲内存充足也可能分配失败。

4.2 vmalloc 的虚拟连续方案

void *vmalloc(unsigned long size);
void *vzalloc(unsigned long size);  // 零初始化版本

// 内部通过修改页表,将不连续的物理页映射到连续的虚拟地址空间
// 代价:每次访问都可能需要页表查找,TLB miss 率更高
// 不适用于 DMA 设备(需要物理连续)

选型指南:

场景推荐函数原因
设备 DMA 缓冲区kmalloc / dma_alloc_coherent需要物理连续
内核小对象(结构体等)kmalloc / kmem_cache_alloc高性能,slab 优化
大内存映射(> 4MB)vmalloc避免物理碎片限制
内核模块代码/数据vmalloc模块加载不一定需要物理连续
用户空间缓冲区copy_from_user / copy_to_user用户页不需要连续

五、Slab 分配器:高频小对象的加速引擎

5.1 Slab 的设计哲学

内核大量重复分配相同大小的对象(如 file、dentry、task_struct、inode 等),每次 buddy 分配都会引入初始化/析构的开销。Slab 通过对象缓存池将已初始化的对象直接复用,避免了重复构造/析构:

kmem_cache → slab(一个或多个连续页)
  slab 内部:对象槽位数组
    [空闲对象1] → [空闲对象2] → [空闲对象3] → ...

分配流程:直接从 freelist 取出一个已初始化的对象
释放流程:将对象 freelist 头部,标记为空闲

5.2 三种 Slab 变体

变体特点适用场景
Slab(原始)每个 slab 维护空闲对象链表,硬件缓存友好Linux 早期(2.6 前)
Slub(默认)简化元数据,嵌入 freelist 到对象空闲空间,NUMA 感知现代 Linux 默认
Slob极致简化,仅 4 字节/页,内存开销最小嵌入式/极小型系统

Slub 是 Linux 2.6.22 至今的默认分配器,核心优化包括:

  • freelist 指针嵌入到空闲对象内部,分配时从对象头部移除
  • 每个 CPU 维护本地缓存(kmem_cache_cpu),避免 NUMA 节点间消费
  • Partial slab 空闲列表实现跨节点对象复用

5.3 自定义 kmem_cache 实战

// 示例:创建专用缓存管理自定义结构体
struct my_struct {
    u64 timestamp;
    char name[64];
    int counter;
    spinlock_t lock;
};

static struct kmem_cache *my_cache;

// 初始化时创建缓存
my_cache = kmem_cache_create("my_struct_cache",
                            sizeof(struct my_struct),
                            0,           // 对齐(0 = 默认)
                            SLAB_HWCACHE_ALIGN | SLAB_ACCOUNT,
                            NULL);       // 构造函数(可传 NULL)

// 分配对象(等价于 kmalloc 但性能更好且已构造)
struct my_struct *obj = kmem_cache_alloc(my_cache, GFP_KERNEL);

// 释放对象
kmem_cache_free(my_cache, obj);

// 销毁缓存(所有 slab 空闲后)
kmem_cache_destroy(my_cache);

六、OOM Killer 与内存保护机制

6.1 OOM Kill 评分机制

当系统内存耗尽且所有回收手段都无法释放足够内存时,OOM Killer 选择进程终止:

// 进程的 OOM 评分计算(简化)
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages) {
    // 1. 计算进程占用的内存分
    long points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS)
                  + mm_pgtables_bytes(p->mm) / PAGE_SIZE;
    
    // 2. 特权调整:root 进程 -3%, oom_score_adj
    adj = p->signal->oom_score_adj;
    if (adj == OOM_SCORE_ADJ_MIN) return 0;  // 永不 kill
    points += adj * totalpages / 1000;
    
    return max_t(long, points, 1);
}

实践中保护关键进程:

# 锁定某个进程不被 OOM kill
echo -1000 > /proc//oom_score_adj

# systemd service 配置
[Service]
OOMPolicy=continue   # OOM 后不重启
OOMScoreAdjust=-1000 # 极高保护分

6.2 Cgroup 内存限制(memcg)

通过 cgroup v1/v2 的 memory 控制器为容器/服务设置硬上限:

# 创建内存限制 cgroup
mkdir /sys/fs/cgroup/memory/my_service
echo 2G > /sys/fs/cgroup/memory/my_service/memory.limit_in_bytes
echo $PID > /sys/fs/cgroup/memory/my_service/cgroup.procs

# 超限时 memcg 内部触发回收 → OOM(仅杀该 cgroup 内进程)
# 通过 memory.oom_control 禁用 cgroup OOM,等待回收

七、内存泄漏检测与性能优化实战

7.1 工具链一览

工具用途使用方式
slabtop实时查看 slab 缓存消耗slabtop -o
/proc/meminfo物理内存全景cat /proc/meminfo
/proc/slabinfo各 slab 缓存详细统计cat /proc/slabinfo

7.2 内存泄漏排查流程

Step 1:确认是否有泄漏迹象

$ free -h
              total        used        free      shared  buff/cache   available
Mem:           15Gi       8.2Gi       200Mi       500Mi       6.8Gi       5.5Gi
Swap:         4.0Gi       1.2Gi       2.0Gi

# 如 free 持续减少而 buff/cache 不变(排除缓存),怀疑泄漏

Step 2:通过 slabtop 定位异常 slab

$ slabtop -o | head -20
 Active / Total Objects (% used)    : 523456 / 543210 (96.3%)
 Active / Total Slabs (% used)      : 12345 / 12348 (99.9%)
 Active / Total Caches (% used)     : 95 / 137 (69.3%)
 Active / Total Size (% used)       : 234567 KB / 240000 KB (97.7%)
 Minimum / Average / Maximum Object: 0.01 / 0.44 / 12.00 KB

  OBJS ACTIVE  USE OBJ SIZE  SLABS OBJ/SLAB CACHE SIZE NAME
 65432  65432 100%   0.50K  4092       16     65472K my_struct_cache   <-- 异常膨胀!
 12345  12000  97%   0.94K   744       12     11904K dentry
  9876   9500  96%   0.19K    92      108      1472K kmalloc-192

Step 3:使用 kmemleak 追踪内核泄漏

# 开启 kmemleak(需编译时开启 CONFIG_DEBUG_KMEMLEAK)
echo scan > /sys/kernel/debug/kmemleak    # 手动触发扫描
cat /sys/kernel/debug/kmemleak             # 查看泄漏报告

# 输出示例:
unreferenced object 0xffff88803c4a0000 (size 128):
  comm "my_kthread", pid 1234, jiffies 4294967295
  backtrace:
    [<00000000c0012345>] kmem_cache_alloc_trace+0x156/0x2c0
    [<00000000d0012345>] my_module_do_something+0x42/0x123
    [<00000000e0012345>] my_work_handler+0x88/0x156
  ...

7.3 配置调优参数

# /etc/sysctl.conf

# 1. swap 使用倾向值(0-200),默认 60
# 0 = 几乎不用 swap,只在 OOM 前才交换
# 100 = 默认值
# 200 = 积极使用 swap 让出更多文件缓存
vm.swappiness = 10

# 2. 脏页回写策略
# 脏页占可用内存百分比阈值(pdflush/writeback)
vm.dirty_ratio = 40          # 全局脏页占总内存比例上限
vm.dirty_background_ratio = 10  # 后台回写起始阈值
vm.dirty_expire_centisecs = 3000  # 脏页过期时间(30秒)
vm.dirty_writeback_centisecs = 500  # 回写线程唤醒间隔(5秒)

# 3. NUMA 平衡
# 是否扫描进程内存并迁移到本地节点
vm.numa_stat = 1

# 4. 内存超分配策略
# 0 = 启发式(默认), 1 = 总是 overcommit, 2 = 严格检查
vm.overcommit_memory = 0
vm.overcommit_ratio = 50

7.4 Huge Page 配置

大页减少 TLB miss 和页表内存开销,对数据库/HPC 场景性能提升显著:

# 2MB 透明大页(THP)
echo always > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag

# 1GB 静态大页(需启动时预留)
# GRUB 参数:hugepagesz=1G hugepages=4 hugepagesz=2M hugepages=512
echo 4 > /proc/sys/vm/nr_hugepages

# 查看大页使用
$ grep Huge /proc/meminfo
HugePages_Total:    1024
HugePages_Free:     256
HugePages_Rsvd:     0
HugePages_Surp:     0
Hugepagesize:       2048 kB

八、eBPF 在内存分析中的应用

现代 Linux 可观测性工具大量使用 eBPF 进行内存事件的低开销追踪:

8.1 BPF 内存追踪工具

工具功能底层支持
bcc/memleak追踪未配对的 kmalloc/kfreekprobe + stack
bpftrace one-liner追踪 page_faulttracepoint:exceptions:page_fault_user
perf mem内存访问延迟采样PEBS + LBR
bpftrace slab 追踪slab 分配/释放事件tracepoint:kmem:kmalloc

8.2 bpftrace 实时追踪页分配

# 追踪 kmalloc 调用, 按进程聚合分配量
bpftrace -e 'tracepoint:kmem:kmalloc {
  @bytes[comm, args->ptr] = args->bytes_req;
  @total[comm] = sum(args->bytes_req);
  @count[comm] = count();
}'

# 输出示例:
@bytes[chrome, 0xffff88803b21c000]: 4096
@total[postgres]: 536870912
@total[nginx]: 268435456
@count[my_server]: 1234567

九、总结与最佳实践

Linux 内核内存管理的核心认知框架:

层次机制用户接口
NUMA/Zone物理节点 + 区域划分numactl, mbind
Buddy 系统页级分配回收引擎alloc_pages, free_pages
Slab小对象缓存池kmalloc, kmem_cache
LRU/kswapd页面回收与交换sysctl swappiness
OOM/Cgroup内存限制与强制回收memcg, oom_score_adj

性能优化铁律:

  1. 减少碎片:控制内存申请粒度,避免频繁小对象分配释放
  2. NUMA 亲和:线程与本地内存绑定,减少跨节点访问
  3. 合理配置 Huge Page:数据库和 DPDK/I/O 性能关键场景必开
  4. 监控 slab 异常:slabtop 持续监控,结合 kmemleak 发现泄漏
  5. 调整脏页策略:SSD 场景适当降低 dirty_background_ratio,避免突发回写
  6. swapiness 按需调整:桌面系统设为 10,HPC/DB 设为 1,SSD+swap 场景可设为 100

深入理解内存管理的完整链路,是 Linux 系统性能调优的基石。从物理页框到虚拟地址映射,从伙伴系统到 slab 缓存,每一层都有其设计背景和适用场景。掌握这些底层机制,才能在面对内存问题时做出精确的判断。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论