MemoryPool:小对象内存池的工程用法
这个 MemoryPool 更偏工程基础设施,而不是“业务数据结构”。它解决的核心问题是:
当有大量大小相同、生命周期类似的小对象需要频繁分配 / 释放时,
直接用new/delete容易导致堆碎片和分配开销过大。
可以把它理解为:针对固定大小 block 的“专用堆”。
一、接口轮廓与关键约束
对应代码:codes/includes/memory_pool.h
核心接口非常小:
class MemoryPool {
public:
MemoryPool(std::size_t blockSize,
std::size_t blocksPerChunk = 1024);
void* allocate(); // 获取一块固定大小的内存
void deallocate(void* p) noexcept; // 归还这块内存
std::size_t blockSize() const noexcept;
};
- blockSize:每个 block 的大小,会对齐到
std::max_align_t。 - blocksPerChunk:每次向系统申请的 block 数量(一次性大块,再拆成小块挂到 free list)。
- 不做线程安全:调用方需要自己加锁或使用 TLS 等手段。
二、内部结构:chunk + free list
结构可以抽象成两层:
- chunks_:
std::vector<void*>,记录向系统申请的每一大块内存(chunk)。 - freeList_:单向链表,每个节点就是一个可用的 block。
关键点:
- 每次
allocateChunk():- 调用一次
::operator new(chunkSize)。 - 把这块大内存切成
blocksPerChunk_个等大小 block。 - 把这些 block 串成单向链表,挂到
freeList_上。
- 调用一次
allocate():- 如果
freeList_为空,先扩容(再从系统要一块新 chunk)。 - 弹出链表头结点,返回它的地址给调用方。
- 如果
deallocate(p):- 把
p强转成Node*,头插回freeList_。
- 把
这一套结构保证了:
- 从第二次分配开始,大部分
allocate()/deallocate()都只是在链表头上做 O(1) 操作。 - 减少了频繁触发全局堆管理器的次数。
三、和直接 new/delete 的对比场景
在 codes/src/memory_pool_example.cpp 中,对比了两种路径:
// Baseline: raw new/delete allocation.
for (...) {
ptrs.push_back(new Foo{...});
}
for (auto* p : ptrs) {
delete p;
}
// Compare: same workload served from MemoryPool.
MemoryPool pool(sizeof(Foo));
for (...) {
void* mem = pool.allocate();
ptrs.push_back(new (mem) Foo{...});
}
for (auto* p : ptrs) {
p->~Foo();
pool.deallocate(p);
}
典型适用场景:
- 大量短生命周期的小对象(如游戏中的粒子、临时组件)。
- 对象大小固定,且类型单一或有限。
- 频繁分配 / 回收,标准堆分配器带来的碎片与 metadata 开销明显。
四、设计取舍与注意事项
- 不负责调用构造 / 析构:
MemoryPool返回的是原始内存,真正构造 / 析构由上层负责(示例中用 placement new + 手动析构)。
- 不记录所有权关系:
- 谁分配谁释放,没有引用计数或 GC。
- 不做调试安全检查:
- 不检测 double free / 跨池释放等 UB,默认调用方严格自律。
- 面向“固定大小”的设计:
- block 大小一旦确定,整个生命周期都不再变化。
这使得 MemoryPool 更适合作为底层构件,由上层封装更安全的接口(例如 ObjectPool<T>)。
五、小结:原理 & 使用场景
- 原理一句话:
- 预先按固定大小从系统申请大块内存 → 切成很多小 block → 用单向 free list 管理可用块,之后
allocate/deallocate只在链表头 O(1) 操作。
- 预先按固定大小从系统申请大块内存 → 切成很多小 block → 用单向 free list 管理可用块,之后
- 典型使用场景:
- 大量同尺寸小对象、频繁分配 / 回收、对性能和碎片比较敏感的模块(游戏对象、临时组件、短生命周期 handle 等)。
附:内存安全版本(Debug 检查)做了什么?
对应代码:codes/includes/memory_pool_safe.h(MemoryPoolSafe / MemoryPoolSafeBase)
这一版的目标不是“彻底保证内存安全”(那需要更强的抽象),而是在 Debug 构建 下尽可能让常见错误“更早、更明确地暴露”。
1)做了哪些 effort(Debug 下启用)
-
double-free 检测
- 思路:维护一个“当前已分配块集合”(
allocated_)。 allocate():把返回的 block 记入allocated_。deallocate(p):如果p不在allocated_,则说明不是“当前已分配块”,可能是 double free 或跨池释放。
- 思路:维护一个“当前已分配块集合”(
-
跨池释放检测(wrong-pool deallocate)
- 当
p不在allocated_时,再检查p是否落在本 pool 的任一 chunk 地址范围内(belongsToPool)。 - 若不属于本 pool 的地址范围,则抛出 “pointer not from this pool”。
- 当
-
poison(释放后填充特殊字节)
deallocate(p)时用0xDD填充整个 block。- 目的:把 use-after-free 从“悄悄读到旧数据”变成“更容易观察到异常值/更快触发崩溃或断言”(配合 sanitizer 效果更明显)。
2)原理解释:为什么这些检查能抓住问题
-
double free 的本质:同一块内存被“归还两次”,会破坏 free list 结构,后续分配可能重复返回同一块内存。
allocated_把“是否处于借出状态”显式记录下来,所以第二次归还能被立刻识别。
-
跨池释放 的本质:把不属于本 pool 的指针塞进 free list,后续
allocate()会返回一块“不在自己管理范围内”的内存,直接进入 UB。belongsToPool通过 chunk 地址范围判断,把这类错误提前变成异常。
-
poison 并不能阻止 UAF,但能让 UAF 的后果更显性:
- 例如对象字段被 0xDD 覆盖后更容易在日志/断言/条件判断里暴露异常路径。
3)Release 下的取舍
MemoryPoolSafe 在 NDEBUG(Release)下会关闭上述检查:
- 不再维护
allocated_(避免 O(log n) 集合操作开销) - 不做 poison(避免额外
memset)
因此它仍然是“更快但更危险”的 infra:
Debug 追求尽早暴露错误,Release 追求最短 fast path。