C++ STL 之 allocator 与内存池详解
难度:高级|字数:≈3500|阅读时间:12 min
问题:为什么要有 allocator?
std::vector<int> 可以动态增长,但 new int[n] 不行——因为 vector 需要将内存分配与对象构造解耦。
C++ 的 operator new 每次分配都要从堆上取,频繁小对象分配会产生碎片和巨大的性能开销。allocator 就是 STL 容器的"内存供应商"接口,它让你能替换底层策略,包括内存池。
一、std::allocator —— 默认分配器
标准库每个容器模板都有一个 Allocator 参数:
template <class T, class Allocator = std::allocator<T>>
class vector;
std::allocator<T> 的接口:
template <typename T>
struct allocator {
using value_type = T;
T* allocate(std::size_t n) {
// 默认调用 ::operator new(n * sizeof(T))
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* p, std::size_t) noexcept {
::operator delete(p);
}
// C++11 之后,构造/析构被 deprecated,改用 allocator_traits
};
关键细节:allocate 只拿裸内存,不构造对象。构造由容器的 construct 或 std::allocator_traits::construct 通过 placement new 完成:
::new (p) T(std::forward<Args>(args)...);
这个两步走(alloc → construct / destroy → dealloc)是 allocator 设计的核心,也是写内存池的基础。
二、SGI STL 二级分配器(非标,但影响深远)
SGI STL 是 GCC 早期使用的实现,它的 __default_alloc_template 常被面试题称为"SGI 二级分配器"。它不是标准,但思想被无数项目借鉴。
架构
核心设计
- 16 条自由链表,管理 8、16、24、…、128 字节(8 对齐)
- 空闲块用嵌入式指针(union)串联——块空闲时存储 next 指针,分配出去后这块内存归用户用,零额外开销
- 内存池(memory pool)是一大块预分配的连续内存,refill 时从池中切出若干块,多余挂入 free list
- 内存池耗尽时调用
malloc重新申请(chunk = 20 块 × 上调后的大小),若失败则向更大的 free list 借
// 伪代码示意
union obj {
union obj* free_list_link; // 空闲时用
char data[1]; // 占位,实际不占用
};
enum { ALIGN = 8, MAX_BYTES = 128, NFREELISTS = 16 };
static obj* free_list[NFREELISTS];
static char* start_free; // 内存池起始
static char* end_free; // 内存池结束
核心思想:小对象不走 malloc/free,从池中取,极大降低碎片和系统调用开销。
三、allocator_traits —— C++11 统一接口
C++11 以前,自定义分配器需要实现所有成员类型和函数,非常繁琐。allocator_traits 提供了默认实现,让分配器作者只需实现最少接口。
template <typename Alloc>
struct allocator_traits {
using allocator_type = Alloc;
using value_type = typename Alloc::value_type;
// pointer, const_pointer, void_pointer, size_type, difference_type ...
// 若 Alloc 未定义,使用默认
static pointer allocate(Alloc& a, size_type n) {
return a.allocate(n);
}
template <typename U, typename... Args>
static void construct(Alloc&, U* p, Args&&... args) {
::new (p) U(std::forward<Args>(args)...); // 默认 placement new
}
template <typename U>
static void destroy(Alloc&, U* p) {
p->~U();
}
};
这意味着写一个最简单的分配器,只需要提供 value_type、allocate、deallocate,剩下的 construct/destroy 由 traits 补齐。
template <typename T>
struct MyAlloc {
using value_type = T;
T* allocate(std::size_t n) { ... }
void deallocate(T* p, std::size_t n) { ... }
};
// 直接用:construct/destroy 来自 allocator_traits
MyAlloc<int> a;
int* p = std::allocator_traits<MyAlloc<int>>::allocate(a, 10);
std::allocator_traits<MyAlloc<int>>::construct(a, p, 42);
std::allocator_traits<MyAlloc<int>>::destroy(a, p);
std::allocator_traits<MyAlloc<int>>::deallocate(a, p, 10);
四、C++17 PMR —— 多态内存资源
C++17 引入 std::pmr(Polymorphic Memory Resources),这是标准对"替换分配器"问题的终局方案。
PMR 体系
四种资源详解
| 资源 | 线程安全 | 回收行为 | 适用场景 |
|---|---|---|---|
new_delete_resource | 是 | 完整回收 | 兜底,等价于 ::operator new/delete |
monotonic_buffer_resource | 否 | 不回收,析构一次释放全部 | 短期任务、单帧计算 |
synchronized_pool_resource | 是 | 按池回收,归还上游 | 多线程容器频繁分配 |
unsynchronized_pool_resource | 否 | 按池回收,归还上游 | 单线程高频小对象 |
monotonic_buffer_resource —— 最快的分配器
// 最快分配器:从不 deallocate,析构一次性释放
char buffer[1024] = {};
std::pmr::monotonic_buffer_resource pool{
buffer, sizeof(buffer),
std::pmr::new_delete_resource() // 上游,buffer 用尽时后备
};
std::pmr::vector<int> vec{&pool};
vec.reserve(100); // 全部从 buffer 中切,零 malloc
特点:
do_allocate:从缓冲区简单偏移,O(1)do_deallocate:直接 no-op- 析构时整块释放,免去逐个释放的开销
- 配合上层一个请求/一个 request 的生命周期使用
pool_resource —— 专门对付小对象
内部维护多个自由链表(与 SGI 相似),按 8 的倍数对齐。不同之处在于它是标准、可组合的:
std::pmr::unsynchronized_pool_resource pool;
std::pmr::vector<std::string> vec{&pool};
for (int i = 0; i < 1000; ++i)
vec.emplace_back("hello");
// 所有 string 内部的 char 数组分配走池,减少碎片
polymorphic_allocator —— 运行时多态
传统模板分配器的问题是类型绑定不同分配器的容器类型不兼容:
std::vector<int, MyAlloc<int>> v1;
std::vector<int, OtherAlloc<int>> v2;
// v1 = v2; // Error!不同类型
PMR 把分配器抽象到运行时指针:
using pmr_vector = std::pmr::vector<int>; // 等价 vector<int, polymorphic_allocator<int>>
pmr_vector v1{&pool1};
pmr_vector v2{&pool2};
v1 = v2; // OK,底层 memory_resource* 不同但类型相同
整个 std::pmr 容器族(pmr::vector、pmr::string、pmr::map 等)共享同一个分配器类型,全部在运行时切换资源。
实战:用 PMR 监控分配
继承 memory_resource,三行代码实现分配计数:
class TracingResource : public std::pmr::memory_resource {
std::pmr::memory_resource* upstream_;
size_t allocated_ = 0;
public:
explicit TracingResource(std::pmr::memory_resource* up)
: upstream_(up) {}
size_t allocated() const { return allocated_; }
private:
void* do_allocate(size_t bytes, size_t align) override {
allocated_ += bytes;
return upstream_->allocate(bytes, align);
}
void do_deallocate(void* p, size_t bytes, size_t align) override {
upstream_->deallocate(p, bytes, align);
}
bool do_is_equal(const memory_resource& other) const noexcept override {
return this == &other;
}
};
// 使用
TracingResource tracer{std::pmr::new_delete_resource()};
std::pmr::vector<int> v{&tracer};
v.push_back(1); // tracer.allocated() 会增长
五、面试题精选
Q1:allocator 为什么把 allocate 和 construct 分开?
答:因为未初始化的内存不包含对象。容器需要先拿裸内存,然后在真正插入元素时才构造对象(如 vector 的 reserve 只 allocate,push_back 才 construct)。这也让自定义分配器可以在预分配的大块内存上做 placement new。
Q2:SGI 二级分配器为什么选 128 字节为阈值?
答:经验值。多数小对象(int、指针、小型 POD)在 128 字节内。更大对象直接走 malloc,避免内存池膨胀。128 配合 8 字节对齐正好 16 条链表,管理成本可控。
Q3:PMR 与模板 allocator 的核心区别是什么?
答:模板 allocator 在编译期绑定(不同分配器的容器类型不同),PMR 在运行期绑定(所有 pmr::vector<T> 类型相同,运行时决定走哪个 resource)。前者零开销抽象,后者灵活的运行时多态。
Q4:monotonic_buffer_resource 的 deallocate 是空操作,不会内存泄漏吗?
答:不会。它的设计前提是整个资源对象的生命周期对应一个阶段(如一次网络请求、一帧渲染)。阶段结束时资源对象析构,一次性释放所有内存。这是有意为之——换取分配速度的极致。
Q5:如何让 pmr::vector 使用栈上的缓冲区?
答:给 monotonic_buffer_resource 传一个栈 buffer 数组的指针和大小:
char buf[4096];
std::pmr::monotonic_buffer_resource pool{buf, 4096};
std::pmr::vector<int> v{&pool};
v.reserve(1024); // 全部从栈上分配,没有 malloc
Q6:如何让自定义分配器兼容 C++11 以下的代码?
答:实现 rebind 机制:
template <typename T>
struct MyAlloc {
template <typename U>
struct rebind { using other = MyAlloc<U>; };
};
// 旧版容器通过 rebind<U>::other 获取分配 U 类型的分配器
// C++17 起 rebind 被 allocator_traits 自动推导,不再需要
Q7:std::allocator 在 C++17 之后有什么变化?
答:C++17 弃用了 construct、destroy、max_size 等成员方法(由 traits 提供)。同时 PMR 成为推荐方案,但 std::allocator<T> 仍然是默认分配器,保持向后兼容。
Q8:写一个最少实现的 PMR memory_resource 需要实现几个虚函数?
答:三个纯虚私有函数:do_allocate、do_deallocate、do_is_equal。加上公开的 allocate / deallocate / is_equal 是基类已实现的,子类不露虚函数。
总结
| 年代 | 方案 | 特点 |
|---|---|---|
| C++98 | std::allocator | 默认方案,包装 ::operator new/delete |
| 实践中 | SGI 二级分配器 | 非标但影响深远的 16 链表 + 内存池 |
| C++11 | allocator_traits | 统一接口,降低自定义成本 |
| C++17 | PMR | 运行期多态,4 种资源开箱即用 |
选择建议:
- 默认开发:用
std::allocator,不需要动 - 小对象高频分配,单线程:
unsynchronized_pool_resource - 多线程高频分配:
synchronized_pool_resource - 短期大量分配不回收:
monotonic_buffer_resource - 想监控或者 mock 内存:继承
memory_resource,一行钩子
122

被折叠的 条评论
为什么被折叠?



