内存池实现:固定大小分配器、slab分配器、tcmalloc原理、jemalloc特点

说到内存管理,我猜你肯定遇到过这种情况:程序跑着跑着,突然卡一下,或者内存碎片多得吓人。嗯,这就是我们今天要聊的内存池要解决的问题。

我个人习惯把内存池看作「内存的批发市场」。你想想看,如果每次要一块小内存都去操作系统那里申请,就像每次买瓶水都跑一趟工厂,效率能高吗?内存池就是先批发一大块,然后零售给程序用。

固定大小分配器

这是最朴素的内存池思路。说白了,就是预先分配一大块内存,切成大小相同的块,用链表串起来。

核心思想:把内存切成等大的「槽位」,用空闲链表管理。

class FixedAllocator {
    struct Node {
        Node* next;
    };
    Node* freeList;
    char* pool;
    size_t blockSize;
    size_t blockCount;
public:
    FixedAllocator(size_t size, size_t count)
        : blockSize(size), blockCount(count) {
        pool = new char[blockSize * blockCount];
        freeList = reinterpret_cast<Node*>(pool);
        Node* cur = freeList;
        for (size_t i = 0; i < blockCount - 1; ++i) {
            cur->next = reinterpret_cast<Node*>(pool + (i+1)*blockSize);
            cur = cur->next;
        }
        cur->next = nullptr;
    }
    void* allocate() {
        if (!freeList) return nullptr;
        Node* p = freeList;
        freeList = freeList->next;
        return p;
    }
    void deallocate(void* p) {
        Node* n = static_cast<Node*>(p);
        n->next = freeList;
        freeList = n;
    }
};

我在项目中遇到过一个问题:用固定大小分配器管理网络包缓冲区,结果发现不同大小的包混在一起,浪费严重。所以这个方案只适合「所有对象大小一致」的场景。

适用场景:线程池的任务对象、网络连接对象、日志条目等固定大小的对象。

Slab 分配器

Slab 分配器是 Linux 内核里的经典设计。它解决了固定大小分配器的痛点——不同大小的对象怎么办?

Slab 的思路是:准备多个「尺寸类别」,每个类别管理一种固定大小的对象。比如 8 字节、16 字节、32 字节……一直到几 KB。

Slab 的三层结构

  • Cache:管理一种特定大小的对象
  • Slab:一大块连续内存,切成多个对象
  • Object:实际分配出去的小块内存

每个 Cache 里有三个链表:

  • 完全空闲的 Slab:随时可以切分
  • 部分使用的 Slab:还有空位
  • 完全满的 Slab:等释放

分配时,先从「部分使用」的链表里找,没有就从「完全空闲」的拿一个。释放时,如果 Slab 变空了,就放回空闲链表。如果整个 Cache 的空闲 Slab 太多,就还给操作系统。

我曾经在一个嵌入式项目里手撸过 Slab 分配器。当时内存只有 64MB,用 Slab 管理后,碎片率从 15% 降到了 3% 以下。嗯,这个数字我记得很清楚。

注意:Slab 分配器对多线程支持不太好。每个 Cache 需要加锁,高并发下锁竞争很严重。

TCMalloc 原理

TCMalloc 是 Google 的杰作。它全称 Thread-Caching Malloc,核心就一个字——「快」。

怎么做到的?说白了就是「每个线程自己管一小块内存,不用抢锁」。

TCMalloc 有三层结构:

层级 名称 作用
第一层 Thread Cache 每个线程独享,无锁分配小对象
第二层 Central Cache 全局共享,线程 Cache 不够时从这里拿
第三层 Page Heap 管理大块内存,按页分配

分配流程是这样的:

  1. 先看线程自己的 Cache 有没有空闲块
  2. 有就直接拿,无锁操作,快得很
  3. 没有就去 Central Cache 批量拿一批
  4. Central Cache 也没有,就去 Page Heap 申请新页

释放时也是先放回线程 Cache。如果线程 Cache 里的空闲块太多了,就批量还给 Central Cache。

我记得有一次优化一个高并发服务,把 glibc malloc 换成 TCMalloc,QPS 直接提升了 30%。原因很简单——锁竞争没了。

小技巧:TCMalloc 对小于 256KB 的对象优化特别好。大对象它会直接走 Page Heap,性能优势就不明显了。

Jemalloc 特点

Jemalloc 是 FreeBSD 和 Firefox 的标配。它和 TCMalloc 思路类似,但更激进。

Jemalloc 的核心特点:

  • Arena 机制:每个 CPU 核心一个 Arena,减少锁冲突
  • 多级缓存:线程本地缓存 + Arena 缓存 + 全局缓存
  • 内存碎片控制:通过「红黑树」管理空闲块,合并相邻空闲块
  • 可扩展性:支持 NUMA 架构,优先分配本地内存

Jemalloc 最让我佩服的是它的「内存压缩」能力。长时间运行的服务,用 Jemalloc 内存占用会比 TCMalloc 低 10%-20%。代价是分配速度稍慢一点。

为什么会这样?因为 Jemalloc 在释放时会花更多精力去合并碎片。TCMalloc 更激进地缓存空闲块,所以分配快但内存占用高。

选型建议

  • 追求极致性能 → TCMalloc
  • 追求低内存占用 → Jemalloc
  • 通用场景 → 两者都可以,建议 Jemalloc

三种分配器对比

特性 固定大小分配器 Slab 分配器 TCMalloc Jemalloc
适用场景 单一大小对象 多种大小对象 通用高性能 通用低内存
多线程性能 差(需加锁) 一般 优秀 优秀
内存碎片 无(大小固定) 中等
实现复杂度 简单 中等 复杂 复杂

最后说一句:别盲目追求高级分配器。如果你的程序只是单线程跑几个对象,手写一个固定大小分配器就够用了。我见过有人为了「炫技」上了 TCMalloc,结果反而因为内存占用过高出了问题。

嗯,选工具要合适,不是越高级越好。

内存池实现体系 固定大小分配器 单一大小,空闲链表 Slab 分配器 多尺寸类别,三层结构 TCMalloc 线程缓存,无锁分配 Jemalloc Arena机制,低碎片 复杂度递增 → 性能递增 → 通用性递增 关键特性对比 固定大小:实现简单,零碎片,适合单一对象 Slab:多尺寸管理,Linux内核经典,锁竞争中等 TCMalloc:线程缓存,分配极快,内存占用偏高 Jemalloc:Arena隔离,碎片控制好,NUMA友好
公众号:蓝海资料掘金营,微信 deep3321