3、内存分配策略:malloc/free原理、内存池设计、伙伴系统与slab分配器

内存分配,说白了就是程序跟操作系统「要地」和「还地」的过程。很多人写代码时只管 new 和 delete,从没想过背后发生了什么。直到线上服务出现抖动、内存碎片爆炸,才回头来查分配器的问题。

我个人习惯,在接手一个性能敏感的系统时,第一件事就是搞清楚它用的什么分配策略。因为不同的场景,分配器的表现天差地别。

3.1 malloc/free:你每天都在用,但你真的懂吗?

glibc 的 malloc 实现,底层用的是 ptmalloc。它并不是每次分配都直接调用系统调用 brk 或 mmap,那样太慢了。它维护了一个「内存池」——只不过这个池子是通用的,谁都能用。

核心机制:

  • 小块分配(< 128KB): 从 heap 区域用 brk 扩展,由 fastbin、smallbin、unsorted bin 等管理。
  • 大块分配(>= 128KB): 直接用 mmap 映射匿名内存页,释放时 unmap 还给内核。
  • free 不一定会归还内存: 释放的小块会留在 bin 里,下次 malloc 直接复用。

我在项目中遇到过一个问题:一个长时间运行的服务,内存占用只增不减。用 valgrind 查了一圈,没有泄漏。后来发现是 ptmalloc 的「内存不归还」特性导致的——大量小对象被释放后,堆并没有收缩。嗯,这里要注意,malloc_trim() 可以强制归还,但调用时机很讲究。

// 一个典型的 malloc 内部结构(简化)
struct malloc_chunk {
    size_t prev_size;    // 前一个 chunk 的大小(如果前一个空闲)
    size_t size;         // 当前 chunk 的大小(低 3 位是标志位)
    struct malloc_chunk *fd; // 空闲链表前向指针
    struct malloc_chunk *bk; // 空闲链表后向指针
    // ... 用户数据从这里开始
};

避坑指南: 我曾经在一个实时音频处理模块里频繁 malloc/free 小对象,结果导致严重的性能抖动。后来改用内存池,延迟直接降了 70%。对于高频分配场景,通用分配器不是好选择。

3.2 内存池设计:自己管,才放心

内存池的思路很简单:一次性向系统申请一大块内存,然后自己切成小块,按需分配。释放时也不真还给系统,而是放回池子里复用。

为什么这么做?说白了,就是绕过 ptmalloc 的锁竞争和碎片问题。你想想看,多线程环境下,每次 malloc 都要抢锁,性能能好吗?

对比项 通用 malloc 内存池
分配速度 O(log n) ~ O(n) O(1)(通常指针偏移)
碎片问题 严重(长期运行后) 可控(固定块大小)
多线程竞争 全局锁或 arena 锁 线程局部池,无锁
内存归还 不主动归还 池满时整块归还

我设计内存池时,一般会考虑这几个要素:

  • 块大小: 固定大小还是可变?固定大小实现简单,但浪费空间;可变大小需要更复杂的管理。
  • 对齐: 通常按 8 字节或 16 字节对齐,避免缓存行伪共享。
  • 线程安全: 每个线程一个本地池,或者用原子操作管理全局池。
// 一个简单的固定大小内存池示例
typedef struct {
    void *pool;          // 大块内存起始
    size_t block_size;   // 每个块的大小
    size_t total_blocks; // 总块数
    void *free_list;     // 空闲链表头
} mempool_t;

void* mempool_alloc(mempool_t *mp) {
    if (!mp->free_list) return NULL;
    void *ptr = mp->free_list;
    mp->free_list = *(void**)ptr; // 空闲链表用块内前 8 字节存指针
    return ptr;
}

void mempool_free(mempool_t *mp, void *ptr) {
    *(void**)ptr = mp->free_list;
    mp->free_list = ptr;
}

我的经验: 内存池最适合「分配-释放-分配-释放」模式固定的场景,比如网络连接对象、协议缓冲区。如果分配大小变化很大,内存池的浪费会很高,这时候可以考虑伙伴系统。

3.3 伙伴系统:内核里的「分而治之」

伙伴系统(Buddy System)是 Linux 内核管理物理内存的核心算法。它的思想很优雅:把内存不断对半切,直到满足需求。

举个例子:你要分配 64KB,系统从 1MB 的空闲块开始,切成 512KB、256KB、128KB、64KB,最后给你 64KB。释放时,如果相邻的「伙伴」也是空闲的,就合并回去。

伙伴系统分配过程 1 MB(空闲) 512 KB(已分配) 512 KB(空闲) 256 KB(已分配) 256 KB(空闲) 128 KB 128 KB 说明:每次分配从最大可用块开始,对半分裂直到满足需求 释放时检查伙伴是否空闲,是则合并回上一级

伙伴系统的优点很明显:碎片少,合并简单。但缺点也很突出——内部碎片。你申请 65KB,它给你 128KB 的块,浪费了将近一半。

关键参数: Linux 内核中,伙伴系统管理的最小单位是页(4KB),最大可以到 4MB(11 级 order)。每个 order 对应一个空闲链表。

3.4 slab 分配器:专治「频繁创建销毁」

slab 分配器是 Solaris 引入的,后来被 Linux 内核采纳。它专门解决一个问题:内核里大量对象(比如 inode、task_struct)频繁分配释放,伙伴系统扛不住。

slab 的思路是:每种对象类型,维护一个专属的缓存池。池子里预先分配好对象,用的时候直接拿,用完放回去,不涉及伙伴系统的分裂合并。

特性 伙伴系统 slab 分配器
管理粒度 页(4KB 起) 对象(任意大小)
适用场景 大块连续内存 小对象高频分配
碎片类型 内部碎片(2 的幂次对齐) 几乎无碎片
缓存友好 一般 好(对象着色避免伪共享)

slab 还有一个很聪明的设计叫「对象着色」(object coloring)。说白了,就是让不同 slab 中的对象起始地址错开几个字节,避免它们落在同一个缓存行里。我在调优一个多核网络转发程序时,就靠这个把 cache miss 率降了 15%。

// Linux 内核 slab 分配器使用示例(伪代码)
struct kmem_cache *cache = kmem_cache_create("my_object",
                                             sizeof(my_object_t),
                                             0, // 对齐
                                             SLAB_HWCACHE_ALIGN,
                                             NULL);

my_object_t *obj = kmem_cache_alloc(cache, GFP_KERNEL);
// ... 使用 obj ...
kmem_cache_free(cache, obj);

我的建议: 如果你在用户态写高性能服务,可以考虑用 tcmalloc 或 jemalloc。它们内部都实现了类似 slab 的机制,而且对多线程优化得更好。我曾经把一个用 ptmalloc 的 Redis 代理换成 jemalloc,吞吐量提升了 20%。

3.5 如何选择?一张图说清楚

不同的分配策略,各有各的舞台。我一般这样选:

  • 通用场景: 用 ptmalloc(glibc 默认),省心。
  • 高频小对象: 自己写内存池,或者用 jemalloc/tcmalloc。
  • 内核模块: 伙伴系统 + slab,这是 Linux 内核的标准组合。
  • 实时系统: 必须用固定大小内存池,避免任何动态分配。

最后提醒一句: 我曾经见过一个项目,为了「性能」自己手写了内存池,结果因为 bug 导致内存越界,查了三天。内存池虽好,但引入前一定要评估收益和风险。不是所有场景都需要自己管内存。