一、为什么我们需要自定义分配器?

说实话,很多C++开发者写了几年代码,都没碰过自定义分配器。我刚开始也是这样——直到有一次,我在做一个高频交易系统,每秒要处理几十万笔订单。默认的new/deletemalloc/free,性能完全扛不住。

你想想看,每次分配内存都要走系统调用,还要处理线程安全、内存碎片……这开销能不大吗?

自定义分配器的核心目标就三个:

  • 减少系统调用——提前申请一大块内存,自己管理
  • 降低内存碎片——按固定大小分配,避免碎片化
  • 提高缓存命中率——连续分配,数据局部性好

核心观点:分配器不是银弹。它解决的是特定场景下的性能瓶颈。如果你的程序只是偶尔分配几个对象,用默认分配器完全没问题。

二、自定义分配器:从STL开始

C++的STL容器都支持自定义分配器。说白了,就是你可以告诉vector、map这些容器:「别用默认的new,用我的分配器。」

一个最简单的分配器长这样:

template<typename T>
class SimpleAllocator {
public:
    using value_type = T;

    SimpleAllocator() = default;

    template<typename U>
    SimpleAllocator(const SimpleAllocator<U>&) {}

    T* allocate(std::size_t n) {
        // 直接调用malloc,但你可以换成自己的内存池
        auto ptr = std::malloc(n * sizeof(T));
        if (!ptr) throw std::bad_alloc();
        return static_cast<T*>(ptr);
    }

    void deallocate(T* ptr, std::size_t) {
        std::free(ptr);
    }
};

// 使用方式
std::vector<int, SimpleAllocator<int>> vec;

嗯,这里要注意:分配器必须是无状态的(C++17之前),或者至少是拷贝构造后等价的。我当年踩过一个坑——分配器里带了个文件句柄,拷贝后两个分配器指向同一个文件,释放时double free了。

避坑指南:我曾经在分配器里保存了线程ID,想着做线程局部缓存。结果容器拷贝时,分配器也被拷贝了,新线程拿着旧线程的ID去访问缓存……嗯,你懂的,crash了。

三、内存池设计:从原理到实现

内存池的思路很简单:一次性向系统申请一大块内存,然后切成小块,按需分配。释放时,把小块还回池子里。

我习惯把内存池分成两类:

  • 固定大小池——所有块大小相同,实现简单,性能极高
  • 可变大小池——支持不同大小的分配,需要更复杂的管理算法

3.1 固定大小内存池

这是最常用的模式。比如你要频繁创建和销毁某个对象,就可以用这个:

class FixedPool {
    struct Block {
        Block* next;
    };

    Block* free_list_;
    char* memory_;
    std::size_t block_size_;
    std::size_t capacity_;

public:
    FixedPool(std::size_t block_size, std::size_t count)
        : block_size_(block_size), capacity_(count) {
        // 一次性申请
        memory_ = new char[block_size * count];
        // 构建空闲链表
        free_list_ = reinterpret_cast<Block*>(memory_);
        auto current = free_list_;
        for (std::size_t i = 1; i < count; ++i) {
            current->next = reinterpret_cast<Block*>(memory_ + i * block_size);
            current = current->next;
        }
        current->next = nullptr;
    }

    void* allocate() {
        if (!free_list_) throw std::bad_alloc();
        auto ptr = free_list_;
        free_list_ = free_list_->next;
        return ptr;
    }

    void deallocate(void* ptr) {
        auto block = static_cast<Block*>(ptr);
        block->next = free_list_;
        free_list_ = block;
    }

    ~FixedPool() { delete[] memory_; }
};

这个实现有个问题:它不支持线程安全。我在项目中通常加一个std::mutex或者用原子操作来做无锁版本。

3.2 可变大小内存池

如果分配的大小不固定,就需要更灵活的策略。我常用的方案是:

  • 维护多个固定大小池,比如8字节、16字节、32字节……
  • 分配时,向上取整到最近的池子大小
  • 比如要分配10字节,就从16字节的池子里拿

这其实就是伙伴分配算法的简化版。不过,真正的伙伴分配算法要更精巧一些。

四、伙伴分配算法:内存管理的经典

伙伴分配算法(Buddy Allocation)是操作系统内核里常用的算法。它的核心思想是:

  • 把内存按2的幂次分成块
  • 分配时,找到最小的满足要求的块
  • 如果块太大,就不断对半切分
  • 释放时,检查相邻的「伙伴」是否空闲,如果是就合并

我画了一张图,帮你理解这个过程:

伙伴分配算法示意图 初始状态: 64KB 空闲块 分配 8KB: 8KB 已用 8KB 伙伴 16KB 空闲 32KB 空闲 分配 16KB: 8KB 已用 8KB 伙伴 16KB 已用 32KB 空闲 释放 8KB(与伙伴合并成16KB): 16KB 空闲(合并后) 16KB 已用 32KB 空闲

伙伴分配算法的优点是:外部碎片少,合并速度快。缺点是:内部碎片可能比较多——比如你要分配9KB,它给你16KB,浪费了7KB。

经验之谈:我在做嵌入式系统时,特别喜欢用伙伴分配算法。因为嵌入式设备内存小,碎片问题特别突出。有一次,一个设备跑了三天后,malloc突然返回NULL了——就是碎片太多导致的。换成伙伴分配后,再也没出现过。

五、tcmalloc vs jemalloc:工业级分配器对决

说到工业级的内存分配器,绕不开两个名字:tcmalloc(Google出品)和jemalloc(FreeBSD出身,Facebook在用)。

它们俩都是对glibc malloc的优化,但思路不太一样:

特性 tcmalloc jemalloc
核心思想 线程局部缓存 + 中央堆 arena + 线程缓存 + 红黑树
小对象分配 每个线程有独立缓存,无锁 每个线程有缓存,但arena级别有锁
大对象分配 直接mmap,页对齐 使用红黑树管理大块内存
内存碎片 较好,但多线程下可能略高 极好,尤其是长时间运行
CPU开销 低,适合高并发 中等,但内存效率更高
适用场景 Web服务器、高频交易 数据库、缓存系统

我个人习惯这样选:

  • 如果程序是CPU密集型,线程数很多,用tcmalloc——它的无锁缓存太香了
  • 如果程序是内存密集型,运行时间长,用jemalloc——它的碎片控制更好
  • 如果只是普通应用,glibc malloc就够了,别折腾

真实案例:我曾经优化过一个Redis-like的缓存服务。用glibc malloc时,内存碎片率高达15%。换成jemalloc后,碎片率降到了3%以下。同样的物理内存,能多缓存20%的数据。

六、如何选择?我的决策框架

说了这么多,到底该用哪个?我总结了一个简单的决策树:

  1. 你的程序分配/释放频繁吗?
    • 不频繁 → 用默认分配器,省心
    • 频繁 → 继续往下看
  2. 分配大小固定吗?
    • 固定 → 用固定大小内存池,性能最高
    • 不固定 → 继续往下看
  3. 多线程并发高吗?
    • 高 → tcmalloc,无锁缓存是王道
    • 低 → jemalloc,内存效率更好
  4. 需要极致控制?
    • 是 → 自己实现伙伴分配算法或slab分配器
    • 否 → 用tcmalloc或jemalloc,开箱即用

重要提醒:不要为了用分配器而用分配器。我见过有人把整个项目改成自定义分配器,结果性能反而下降了——因为分配器本身也有开销。先profile,再优化,这是铁律。

七、总结

内存分配器这个话题,说深了可以写一本书。但核心就几点:

  • 自定义分配器让你掌控内存,但增加了复杂度
  • 内存池适合固定大小、频繁分配的场景
  • 伙伴分配算法在碎片控制和分配速度之间取得了很好的平衡
  • tcmalloc和jemalloc是工业级的选择,各有千秋

嗯,最后说一句:内存分配器的优化,往往是「最后一公里」的优化。先把业务逻辑写对,把架构设计好,再来折腾分配器。顺序别搞反了。


公众号:蓝海资料掘金营,微信deep3321