一、为什么需要自定义分配器
1. malloc 到底慢在哪
通用堆分配器(glibc malloc)必须应对任意大小、任意次序的分配请求,这要求它在内部维护复杂的空闲块元数据。一次 malloc 的开销分布在:
- 锁竞争:多线程并发分配时,堆的全局锁(或 arena 锁)成为串行点;
- 元数据成本:每个分配块头部/尾部都附带 size、标志位等,小对象上元数据占比惊人;
- 缓存局部性:通用分配器不保证连续分配的对象在内存中相邻,遍历链表式对象时缓存命中率低;
- 碎片化:长期运行后,空闲块散落各处,无法满足大块连续分配。
2. 什么时候值得自研
自研分配器只在一个前提下有意义:你清楚对象的生命周期模式。典型受益场景:
- 游戏引擎每帧创建/销毁的粒子、组件(生命周期短且统一);
- 网络服务中每请求分配的临时缓冲(尺寸相近);
- 高频交易中延迟敏感的小对象(
malloc的锁与系统调用不可接受)。
反过来,若分配模式随机、生命周期交错,通用分配器(或直接使用 jemalloc/tcmalloc)往往更合适。先测量,再优化是这里的第一原则。
3. 分配器接口的层次
C++ 中"分配器"有三个层次,从低到高:
operator new/operator delete重载:进程级或类级;- 标准 Allocator 概念:
std::allocator<T>风格的模板接口,供 STL 容器使用; std::pmr::memory_resource:运行期多态的分配资源抽象,可动态注入。
二、Arena 分配器
1. 基本思想:一次性大块,顺序推进
Arena(也称为 bump allocator / region allocator)是最简单的池化策略:一次向系统申请一大块内存,分配时仅移动游标(bump),不释放单个对象,整个 arena 一次性归还。它把 O(1) 分配成本压缩到极致,代价是无法单独释放中间对象。
#include <cstddef>
#include <cstdlib>
#include <cassert>
class Arena {
char* start_;
char* end_;
char* cur_;
public:
explicit Arena(size_t size)
: start_(static_cast<char*>(::operator new(size))),
end_(start_ + size), cur_(start_) {}
~Arena() { ::operator delete(start_); }
Arena(const Arena&) = delete;
Arena& operator=(const Arena&) = delete;
void* allocate(size_t n) {
// 对齐到 alignof(std::max_align_t)
n = (n + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1);
assert(cur_ + n <= end_);
void* p = cur_;
cur_ += n;
return p;
}
void reset() { cur_ = start_; } // 整体复用
};
2. 典型使用场景
Arena 最适合"一批对象同时出生、同时死亡"的场景。典型例子:
- 每帧构建一次的场景图(游戏);
- 每次 RPC 请求的中间对象(服务端);
- 编译器的一次编译过程(AST 结点)。
// 与构造/析构配合:Arena 只管内存,对象生命周期由使用者掌握
template<typename T>
T* construct_in_arena(Arena& arena, auto&&... args) {
void* p = arena.allocate(sizeof(T));
return ::new (p) T(std::forward<decltype(args)>(args)...);
}
3. 变体:Stack Allocator 与双缓冲
Arena 的进阶形态是栈式分配器(配合 RAII 标记回滚)与双缓冲(前后帧交替复用两块 arena)。栈式分配器允许按 LIFO 顺序释放:
class StackArena {
char* mem_; char* top_;
public:
struct Mark { char* pos; };
Mark mark() { return {top_}; }
void release(Mark m) { top_ = m.pos; }
};
三、Object Pool:定长对象的空闲链表
1. 核心思想
当所有对象大小一致(如 Node、Connection、Particle),可以用空闲链表把空闲对象串起来:分配从链表头取一个,释放把对象压回链表头,O(1) 且无碎片。
2. 侵入式 vs 非侵入式
- 非侵入式:链表指针存储在独立的元数据块中,对象本身保持标准布局;
- 侵入式:空闲指针借存在对象内存内(对象空闲时其字段无意义),省去元数据,是绝大多数定长池的做法。
#include <cstddef>
#include <cassert>
template<typename T>
class ObjectPool {
struct Block { Block* next; }; // 空闲节点复用对象内存
Block* free_head_ = nullptr;
T* storage_;
size_t capacity_;
public:
explicit ObjectPool(size_t capacity)
: capacity_(capacity) {
storage_ = static_cast<T*>(::operator new(sizeof(T) * capacity));
// 把所有槽位串成空闲链表
char* base = reinterpret_cast<char*>(storage_);
for (size_t i = 0; i < capacity; ++i) {
Block* b = reinterpret_cast<Block*>(base + i * sizeof(T));
b->next = free_head_;
free_head_ = b;
}
}
~ObjectPool() { ::operator delete(storage_); }
template<typename... Args>
T* construct(Args&&... args) {
assert(free_head_);
void* p = free_head_;
free_head_ = free_head_->next;
return ::new (p) T(std::forward<Args>(args)...);
}
void destroy(T* obj) {
obj->~T();
Block* b = reinterpret_cast<Block*>(obj);
b->next = free_head_;
free_head_ = b;
}
};
3. 安全性考量
定长池最大的坑是悬垂指针复用:槽位被释放后立刻重新分配,仍持有旧指针的代码会踩到新对象。缓解手段:
- 延迟回收(等所有用户离开后统一归还,类似 hazard pointer 思想);
- 在空闲链表里写入哨兵值,触发 debug 期检测。
四、std::pmr 与多态分配器
1. memory_resource 协议
std::pmr(polymorphic memory resources,C++17)把分配器从编译期模板参数变成运行期多态接口:
#include <memory_resource>
#include <vector>
std::pmr::monotonic_buffer_resource arena(1024 * 1024);
// 容器把分配请求转发给 resource
std::pmr::vector<int> vec(&arena);
for (int i = 0; i < 100000; ++i) vec.push_back(i); // 全部在 arena 中分配
memory_resource 的抽象接口只有两个纯虚函数 do_allocate / do_deallocate,加上 allocate / deallocate / is_equal 的公共包装。关键特性:对象释放时不必知道它来自哪个 resource 的具体类型,多态分派自动回到正确的分配路径。
2. 标准提供的 resource
| resource | 行为 | 适用 |
|---|---|---|
std::pmr::new_delete_resource() | 走 operator new/delete | 默认兜底 |
std::pmr::monotonic_buffer_resource | 线性 bump,不单独释放 | 短生命周期批量对象 |
std::pmr::synchronized_pool_resource | 线程安全定长池,多线程安全 | 多线程默认选择 |
std::pmr::unsynchronized_pool_resource | 同上但非线程安全 | 单线程性能 |
3. 自定义 resource 接入 STL
通过继承 memory_resource,可以把任意分配策略接入所有 pmr:: 容器:
#include <memory_resource>
#include <cstddef>
class MyResource : public std::pmr::memory_resource {
Arena arena_;
public:
explicit MyResource(size_t cap) : arena_(cap) {}
protected:
void* do_allocate(size_t bytes, size_t align) override {
return arena_.allocate(bytes);
}
void do_deallocate(void*, size_t, size_t) override {
// arena 不单独释放
}
bool do_is_equal(const std::pmr::memory_resource& other) const noexcept override {
return this == &other;
}
};
4. pmr 的代价
多态分派每次分配都有一次虚函数调用;且 pmr:: 容器与标准容器类型不兼容(std::pmr::vector<T> 是 std::vector<T, polymorphic_allocator<T>> 的别名),跨边界传递时需注意类型匹配。对性能极致敏感的内层,仍倾向手写模板分配器。
五、无锁分配器
1. 线程本地缓存(TLS Cache)
无锁分配器的核心思路是避免共享:每个线程维护自己的缓存,分配/释放只在本地进行,绝不触碰全局堆。这消除了锁竞争,还顺带改善了缓存局部性(每线程的数据天然集中)。
#include <vector>
#include <thread>
class ThreadLocalArena {
// 每线程一个 arena 实例
public:
static Arena& local() {
static thread_local Arena arena(64 * 1024);
return arena;
}
};
void worker() {
Arena& a = ThreadLocalArena::local(); // 各线程独立
void* p = a.allocate(32);
// ...
}
2. 原子空闲链表
当必须在线程间共享(如多生产者单消费者),可用原子指针实现的无锁空闲链表:
#include <atomic>
struct Node { Node* next; };
class LockFreeFreeList {
std::atomic<Node*> head_{nullptr};
public:
void push(Node* node) {
node->next = head_.load(std::memory_order_relaxed);
while (!head_.compare_exchange_weak(node->next, node,
std::memory_order_release, std::memory_order_relaxed)) {
// CAS 失败说明 head 变了,node->next 已被更新为最新 head
}
}
Node* pop() {
Node* h = head_.load(std::memory_order_acquire);
while (h && !head_.compare_exchange_weak(h, h->next,
std::memory_order_acquire, std::memory_order_relaxed)) {
}
return h;
}
};
这是典型的无锁栈(Treiber 栈),push 用 release 发布节点,pop 用 acquire 获取——内存序语义可参考 https://plumephp.com/cpp-atomic-memory-order/。注意:无锁 free list 面临 ABA 问题与节点回收安全的双重挑战,若对象本身需要释放内存而非入池,还需 hazard pointer 辅助。工程上建议先评估 std::pmr::synchronized_pool_resource,多数场景它已够用。
3. 无锁分配的性能特征
无锁分配在低竞争下与 TLS 缓存相当,在高竞争(大量线程同时 pop/push)下优于互斥锁版,但劣于每线程本地缓存。真正的杀手组合是本地缓存为主 + 全局无锁池兜底,这正是 jemalloc/tcmalloc 的设计哲学。
六、与 jemalloc / tcmalloc 的对比
1. 两大现成分配器
- jemalloc:Facebook/Redis 等使用。核心是 per-thread cache + 多层空闲分级(size classes),通过
arena划分减少碎片,元数据开销低,多线程扩展性极强; - tcmalloc:Google 出品。
ThreadCache每线程缓存,超阈值后归还中心化堆,page heap管理大块。对 C++ 大量小对象场景友好,与 Google 生态(gperftools)集成佳。
两者都支持通过 LD_PRELOAD 或 operator new 重载全局替换默认分配器,无需改动业务代码。
2. 对比表格
| 维度 | 手写 Arena | 手写 Object Pool | std::pmr | jemalloc/tcmalloc |
|---|---|---|---|---|
| 适用对象 | 同生命周期批量对象 | 定长高频对象 | 容器分配策略注入 | 通用替换 |
| 分配复杂度 | O(1) bump | O(1) 链表 | O(1)~O(n) 视资源 | 通常 O(1) |
| 释放粒度 | 整体 | 单个 | 单个 | 单个 |
| 线程安全 | 需自管 | 需自管 | 选同步/异步 | 内置 |
| 调优成本 | 低 | 低 | 中 | 零(黑盒) |
| 可控性 | 高 | 高 | 中 | 低 |
| 适用阶段 | 热路径特化 | 热路径特化 | 框架层 | 全局面替换 |
3. 选型决策
- 全局性瓶颈 → 先试
LD_PRELOAD=libjemalloc.so,一行命令看效果; - 某个类/容器高频分配 → 用 Object Pool 或
pmr::unsynchronized_pool_resource; - 一批对象同时存活 → Arena / monotonic buffer,配合
reset()复用; - 延迟极度敏感且分配模式已知 → 无锁本地缓存 + 原子链表兜底。
不要一开始就手写全局分配器。生产实践的正确路径是:先用 profiler 找到分配热点,再针对该热点引入最小范围的池化。
七、生产实践要点
1. 对齐与大小
分配器必须保证返回指针满足 alignof(T)。手写池建议按 alignof(std::max_align_t)(通常 16 字节)对齐;若对象含 SIMD 类型(__m256),需按 32/64 字节对齐,否则 aligned_alloc 或 posix_memalign 起步。
2. RAII 封装与所有权
内存池对象必须通过 RAII 管理(如 std::unique_ptr<T, PoolDeleter>),确保异常路径下对象归还池中。C++17 的 PMR 容器已自动处理,手写池需自行约定释放回调。
3. 与缓存局部性联动
对象池的槽位连续排布天然有利于顺序遍历。若场景是"频繁遍历整批对象"(粒子系统、ECS 组件),池化不仅省分配,还提升缓存命中率——这属于 https://plumephp.com/cpp-performance-optimization/ 中 Cache 友好编程的核心手法。与之配合的还有结构体字段重排与 SIMD 批处理。
4. 调试与统计
生产内存池应内置统计(分配次数、峰值占用、碎片率),并支持在 Debug 构建下填充哨兵值(如 0xCD)以暴露越界访问。切勿在 Release 热路径上保留无谓的断言。
八、总结
内存池的本质是用"预先规划的生命周期"换取"分配代价的可预期性"。从 Arena 的极简 bump,到 Object Pool 的定长链表,再到 std::pmr 的接口抽象与无锁分配的内存序博弈,每一层都在解决同一个问题:让内存分配不再成为性能的随机抖动源。
需要铭记的工程判断是:通用分配器(jemalloc/tcmalloc)负责兜底,自定义池负责特化热点。先测量定位热点,再以最小侵入的方式引入池化;先利用 std::pmr 与标准组件,手写仅保留在真正无法被现成工具满足的路径上。这才是内存管理从"能用"走向"高效"的正确顺序。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。