一、为什么我们需要自定义分配器?
说实话,很多C++开发者写了几年代码,都没碰过自定义分配器。我刚开始也是这样——直到有一次,我在做一个高频交易系统,每秒要处理几十万笔订单。默认的new/delete和malloc/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的幂次分成块
- 分配时,找到最小的满足要求的块
- 如果块太大,就不断对半切分
- 释放时,检查相邻的「伙伴」是否空闲,如果是就合并
我画了一张图,帮你理解这个过程:
伙伴分配算法的优点是:外部碎片少,合并速度快。缺点是:内部碎片可能比较多——比如你要分配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%的数据。
六、如何选择?我的决策框架
说了这么多,到底该用哪个?我总结了一个简单的决策树:
- 你的程序分配/释放频繁吗?
- 不频繁 → 用默认分配器,省心
- 频繁 → 继续往下看
- 分配大小固定吗?
- 固定 → 用固定大小内存池,性能最高
- 不固定 → 继续往下看
- 多线程并发高吗?
- 高 → tcmalloc,无锁缓存是王道
- 低 → jemalloc,内存效率更好
- 需要极致控制?
- 是 → 自己实现伙伴分配算法或slab分配器
- 否 → 用tcmalloc或jemalloc,开箱即用
重要提醒:不要为了用分配器而用分配器。我见过有人把整个项目改成自定义分配器,结果性能反而下降了——因为分配器本身也有开销。先profile,再优化,这是铁律。
七、总结
内存分配器这个话题,说深了可以写一本书。但核心就几点:
- 自定义分配器让你掌控内存,但增加了复杂度
- 内存池适合固定大小、频繁分配的场景
- 伙伴分配算法在碎片控制和分配速度之间取得了很好的平衡
- tcmalloc和jemalloc是工业级的选择,各有千秋
嗯,最后说一句:内存分配器的优化,往往是「最后一公里」的优化。先把业务逻辑写对,把架构设计好,再来折腾分配器。顺序别搞反了。
公众号:蓝海资料掘金营,微信deep3321