内存分配器:伙伴系统、slab分配器、kmalloc与vmalloc

内存分配器,说白了就是内核的“仓库管理员”。你申请内存,它给你找块合适的地方。但内核里不是只有一个管理员,而是好几个,各管一摊。我刚开始接触这块时也懵,后来调了几个性能问题才摸清门道。

今天咱们就把这几个角色捋清楚:伙伴系统、slab分配器、kmalloc和vmalloc。它们各自管什么、怎么用、坑在哪。

伙伴系统:物理内存的“大管家”

伙伴系统是内核最底层的物理内存分配器。它管的是整个物理内存的“大块”分配和回收。

它的核心思想很简单:把内存按2的幂次分成块。比如4KB、8KB、16KB……一直到4MB。分配时找大小合适的块,如果没合适的,就拆大块;释放时,如果相邻的两个块都是空闲且大小相同,就合并成更大的块。

关键点:伙伴系统分配的最小单位是一个页面(通常4KB),最大可以到整个内存区域。它只负责连续的物理页面。

我在项目中遇到过一个问题:某台服务器跑了几个月后,分配连续大块内存总是失败。查了半天,发现是内存碎片化严重——虽然总空闲内存还有不少,但都是零散的小块,凑不出一个大的连续区域。这就是伙伴系统的典型痛点。

来看一个实际例子,分配连续物理页面的代码:

// 分配2个连续物理页面(8KB)
struct page *pages = alloc_pages(GFP_KERNEL, 1);
if (!pages) {
    pr_err("alloc_pages failed\n");
    return -ENOMEM;
}

// 使用完后释放
__free_pages(pages, 1);

这里的第二个参数是order,表示2的order次方个页面。order=1就是2个页面,order=0就是1个页面。

slab分配器:小对象的“专属管家”

伙伴系统管大块,但内核里大量分配的是小对象——几十字节到几百字节的那种。比如task_struct、inode、dentry。如果每次都找伙伴系统要一个4KB的页面,只用了其中几十字节,那浪费就太大了。

slab分配器就是解决这个问题的。它从伙伴系统拿一些页面,然后切成大小相同的“对象缓存”。每个缓存专门管一种固定大小的对象。

我习惯把slab分配器想象成“宜家的零件盒”——每个格子放一种螺丝,拿取方便,回收也快。

特性 伙伴系统 slab分配器
最小分配单位 4KB(一个页面) 几字节到几KB
适用场景 大块连续物理内存 频繁分配/释放的小对象
碎片问题 外部碎片 内部碎片(可控)
分配速度 较慢(可能触发合并/拆分) 快(常驻缓存)

slab分配器有三种实现:SLAB、SLUB和SLOB。现在主流内核默认用SLUB,因为它更简单、性能更好。SLOB主要用于嵌入式系统。

调优小技巧:你可以通过 /proc/slabinfo 查看当前所有slab缓存的使用情况。如果某个缓存的对象数量持续增长不回落,很可能有内存泄漏。

kmalloc与vmalloc:两个“前台”接口

kmalloc和vmalloc是内核模块最常用的内存申请接口。它们背后调用的分配器不同,行为也完全不同。

kmalloc:保证物理连续

kmalloc分配的是物理上连续的内存。它底层走的是slab分配器(小对象)或伙伴系统(大对象)。

什么时候必须用kmalloc?跟硬件打交道的时候。比如DMA传输、设备寄存器映射,硬件要求物理地址连续。你给DMA控制器一个不连续的物理地址,它直接罢工。

// 分配128字节的物理连续内存
void *buf = kmalloc(128, GFP_KERNEL);
if (!buf) {
    pr_err("kmalloc failed\n");
    return -ENOMEM;
}

// 使用完后释放
kfree(buf);

kmalloc的最大分配大小有限制,通常是4MB左右(取决于架构和配置)。超过这个大小,kmalloc会返回失败。

注意:kmalloc的GFP标志位很重要。GFP_KERNEL允许睡眠,适合进程上下文;GFP_ATOMIC不允许睡眠,适合中断上下文。我曾经在中断处理里用了GFP_KERNEL,结果系统直接死锁——因为中断里不能睡眠。

vmalloc:只保证虚拟连续

vmalloc分配的是虚拟地址连续、但物理地址可能不连续的内存。它底层通过伙伴系统拿到多个不连续的物理页面,然后通过页表映射成连续的虚拟地址空间。

什么时候用vmalloc?需要大块内存,但不要求物理连续的时候。比如内核模块加载、大缓冲区分配。

// 分配1MB的虚拟连续内存
void *buf = vmalloc(1024 * 1024);
if (!buf) {
    pr_err("vmalloc failed\n");
    return -ENOMEM;
}

// 使用完后释放
vfree(buf);

vmalloc可以分配很大的内存(比kmalloc大得多),但性能开销也大——因为它要修改页表、刷新TLB。所以频繁分配释放的场景别用vmalloc。

对比项 kmalloc vmalloc
物理连续性 连续 不连续
虚拟连续性 连续 连续
最大大小 ~4MB 几乎无限制
分配速度 快 慢(需修改页表)
适用场景 DMA、硬件交互 大缓冲区、模块加载

核心流程图:内存分配器层级关系

下面这张图展示了从内核接口到物理内存的完整分配路径。你想想看,一个kmalloc调用背后经历了多少层?

内存分配器层级关系图 内存分配器层级关系 内核模块 / 驱动 kmalloc / kfree vmalloc / vfree slab / SLUB 分配器 伙伴系统 (页分配) 物理内存 (页面帧) 说明: • kmalloc 小对象走 slab,大对象直接走伙伴系统 • vmalloc 从伙伴系统拿不连续页面,再通过页表映射成连续虚拟地址 • slab 本身也从伙伴系统申请页面作为缓存池

实际调优经验

讲几个我踩过的坑,希望能帮你少走弯路。

坑一:kmalloc分配过大导致失败

我曾经写一个网络驱动,需要分配一个64KB的DMA缓冲区。我直接用kmalloc(65536, GFP_KERNEL)。结果在内存碎片化严重的机器上,这个分配频繁失败。后来改成在驱动初始化时用alloc_pages_exact预分配,或者用DMA池来管理,问题就解决了。

坑二:vmalloc频繁分配导致性能抖动

有个同事写了一个内核模块,每次处理网络包都vmalloc一个缓冲区。结果高并发时系统响应变慢,CPU的iowait飙升。查了才发现vmalloc的页表操作太频繁,TLB刷新开销巨大。改成kmalloc预分配+复用后,性能提升了一个数量级。

坑三:slab缓存泄漏

我记得有一次排查内存泄漏,用slabtop命令发现dentry缓存持续增长。原来是某个文件系统操作后没有正确释放dentry引用。这种泄漏很难查,但slab分配器提供了很好的追踪手段——/sys/kernel/slab/<cache_name>/下面有详细的统计信息。

实用命令:

  • slabtop - 实时查看slab缓存使用情况
  • cat /proc/meminfo | grep Slab - 查看slab总用量
  • cat /proc/slabinfo - 查看每个缓存的详细信息
  • cat /proc/vmallocinfo - 查看vmalloc分配情况

如何选择?

说白了,选择哪个分配器取决于你的需求:

  • 需要物理连续(DMA、硬件寄存器)→ 用kmalloc
  • 需要大块内存(>4MB)→ 用vmalloc
  • 频繁分配小对象(几十到几百字节)→ 自己创建slab缓存
  • 一次性分配大量页面(比如几十个连续页面)→ 直接用伙伴系统的alloc_pages

嗯,这里要注意:不要盲目用vmalloc替代kmalloc。vmalloc虽然能分配更大的内存,但性能代价不小。我见过有人把所有kmalloc都改成vmalloc,结果系统性能直接腰斩。

最后分享一个个人习惯:写驱动时,我一般先用kmalloc,如果分配失败再fallback到vmalloc。这样既保证了大部分场景的性能,又不会因为内存碎片导致完全无法工作。