前言
标准容器 vector、list、map 都要申请和释放内存。这些内存从哪里来,标准库并没有写死,而是抽象成一个可替换的组件------分配器(allocator) 。容器只依赖分配器提供"申请 N 个元素的空间""释放这块空间"两个动作,具体策略(malloc、内存池、共享内存、竞技场 arena)全部交给外部决定。
这个设计的意义在于:把"内存策略"与"数据结构"解耦 。一个用自由链表(free list)做小对象缓存的分配器,能让 std::list<int> 的性能提高数倍,而容器代码一行都不用改。
本文从分配器的抽象接口讲起,讲清 allocator_traits 为什么存在、rebind 解决什么问题,再给出一份可直接编译运行的自定义分配器实现。
一、为什么需要分配器抽象
先看裸 new / delete 的三个问题:
- 每次都向操作系统或通用堆申请 。
std::list<int>插入 100 万个节点 = 100 万次operator new,每次都要走堆管理器加锁、查找空闲块,开销巨大。 - 内存碎片。大量小对象反复申请释放,会把堆切得七零八落,最终"总空闲够但拿不出连续块"。
- 每个对象都带元信息 。通用堆为了
free时需要知道大小,通常在每个块前面放一个 header,小对象的额外开销比例极高(一个 16 字节的对象可能要额外 16 字节 header)。
分配器允许我们针对具体场景做优化。经典的 SGI STL 二级分配器思路是:大于 128 字节走一级(直接 malloc),小于等于 128 字节走二级(按 8 字节对齐切成 16 个自由链表)。这就是分配器存在的第一个理由。
第二个理由是对象的构造与释放要分开 。容器扩容时需要"申请原始内存 → 在上面逐个构造元素"两步。如果只有 new,构造和分配就绑死了,vector 扩容时就没法做到"多申请一块,把旧元素 move 过去,再析构旧的",只能每个元素都重新构造一遍。
二、分配器的接口演进
2.1 从"重接口"到"最小接口"
早期 std::allocator 要求提供一整套成员:allocate / deallocate / construct / destroy / max_size / rebind。C++17 起 construct、destroy、max_size 被弃用,C++20 正式删除 。原因是这些成员本就可以由 std::allocator_traits 提供默认实现,强制要求反而增加了编写自定义分配器的负担。
2.2 C++11 之后的"最小接口"
现代分配器只需要四个要素:
| 成员 | 作用 |
|---|
|--------------|------------------------------|
| value_type | 元素类型,allocator_traits 据此推导 |
|---------------|----------------------------------|
| allocate(n) | 分配 n * sizeof(T) 字节,返回未构造的原始内存 |
|--------------------|----------------------|
| deallocate(p, n) | 释放从 allocate 得到的空间 |
|---------------------|-------------------|
| 拷贝构造 / operator== | 判断两个分配器实例能否互相释放内存 |
如果自定义分配器只提供 allocate / deallocate ,std::allocator_traits 会自动补上 construct、destroy、max_size、select_on_container_copy_construction、rebind 等一大堆默认行为。这就是 allocator_traits 的核心价值------向后兼容 + 减轻实现负担。
2.3 allocator_traits 与 rebind
为什么容器不直接调用 alloc.allocate(n),而要通过 std::allocator_traits<Alloc>::allocate(alloc, n)?
- 为了在不改变分配器定义的前提下,给它补上缺失的成员;
- 为了统一调用点,让标准库可以后续演进(例如增加
allocate_at_least)。
rebind 解决的问题 :std::list<T> 的节点类型是 ListNode<T>,不是 T。容器拿到的是 allocator<T>,却需要 allocator<ListNode<T>>。allocator_traits<Alloc>::rebind_alloc<U> 就是从 Alloc 得到"元素类型换成 U 的分配器"的标准途径。
cpp
template<class T>
struct MyAlloc {
using value_type = T;
T* allocate(std::size_t n);
void deallocate(T* p, std::size_t n);
// 现代写法:提供模板化拷贝构造,rebind 由 traits 自动完成
template<class U> MyAlloc(const MyAlloc<U>&) noexcept {}
};
// allocator_traits<MyAlloc<int>>::rebind_alloc<double> == MyAlloc<double>
注意那个 template<class U> MyAlloc(const MyAlloc<U>&) 转换构造函数------它是让 rebind_alloc 能工作的关键,少了它,容器会在编译期报"无法从 MyAlloc<int> 转换到 MyAlloc<Node>"。
三、有状态分配器与传播策略
分配器可以携带状态,比如指向同一个内存池的指针。要让它工作,所有 rebind 出来的版本都必须能访问到同一份状态:
cpp
template<class T>
class PoolAlloc {
public:
using value_type = T;
explicit PoolAlloc(Pool* pool) noexcept : pool_(pool) {}
template<class U>
PoolAlloc(const PoolAlloc<U>& other) noexcept : pool_(other.pool()) {} // 传递状态
T* allocate(std::size_t n);
void deallocate(T* p, std::size_t n) noexcept;
Pool* pool() const noexcept { return pool_; }
private:
Pool* pool_;
};
template<class T, class U>
bool operator==(const PoolAlloc<T>& a, const PoolAlloc<U>& b) noexcept {
return a.pool() == b.pool(); // 能互相释放的前提:同一个池
}
为什么必须重载 ==? 因为 std::vector 在 move 赋值时,如果两个容器的分配器不相等,就不能把内存块直接"偷"过来,必须逐元素搬移。分配器相等是"可以偷内存"的必要条件。
几个传播 traits(决定容器拷贝/移动/交换时分配器怎么处理):
| traits | 含义 |
|---|
|------------------------------------------|-----------------|
| propagate_on_container_copy_assignment | 拷贝赋值时用源分配器替换自己的 |
|------------------------------------------|-----------------------------|
| propagate_on_container_move_assignment | move 赋值时是否可替换(true 才能偷内存) |
|-------------------|---------------------------------|
| is_always_equal | 所有实例都相等 → 容器可以省掉存储分配器的空间(空基类优化) |
std::allocator 的 is_always_equal 为 true,因此 std::vector<int> 默认不额外存储分配器对象 ,sizeof(std::vector<int>) 仍是 3 个指针。
四、代码实战
实战 1:最小的可用分配器
cpp
// minimal_alloc.cpp --- g++ -std=c++17 minimal_alloc.cpp -o minimal_alloc
#include <cstddef>
#include <iostream>
#include <memory>
#include <new>
#include <vector>
template<class T>
struct LoggingAlloc {
using value_type = T;
LoggingAlloc() noexcept = default;
template<class U> LoggingAlloc(const LoggingAlloc<U>&) noexcept {} // rebind 必需
T* allocate(std::size_t n) {
std::cout << "allocate " << n * sizeof(T) << " bytes\n";
return static_cast<T*>(::operator new(n * sizeof(T))); // 只分配,不构造
}
void deallocate(T* p, std::size_t n) noexcept {
std::cout << "deallocate " << n * sizeof(T) << " bytes\n";
::operator delete(p); // 只释放,不析构
}
};
template<class T, class U>
bool operator==(const LoggingAlloc<T>&, const LoggingAlloc<U>&) noexcept { return true; }
int main() {
std::vector<int, LoggingAlloc<int>> v;
for (int i = 0; i < 10; ++i) v.push_back(i);
std::cout << "size=" << v.size() << " cap=" << v.capacity() << "\n";
return 0;
}
运行输出能直观看到 vector 的倍增扩容 过程:容量按 1、2、4、8、16 增长,每次扩容都伴随一次 allocate 加一次 deallocate。这也顺便解释了为什么 vector 要 reserve。
实战 2:一个定长块自由链表分配器(freelist allocator)
这是 SGI STL 二级分配器的核心思想,写一个简化版本:
cpp
// freelist_alloc.cpp --- g++ -std=c++17 freelist_alloc.cpp -o freelist_alloc
#include <cstddef>
#include <iostream>
#include <list>
#include <vector>
// 每个空闲块**复用自身的存储**保存 next 指针 ------ 零额外开销
// 这是自由链表分配器的精髓:不为链表节点额外分配内存
struct FreeNode {
FreeNode* next;
};
template<std::size_t BlockSize, std::size_t ChunkCount = 64>
class FixedPool {
public:
FixedPool() = default;
FixedPool(const FixedPool&) = delete;
FixedPool& operator=(const FixedPool&) = delete;
~FixedPool() {
// 只释放 chunk 链表,块本身跟着 chunk 一起释放
while (chunks_) {
void* p = chunks_;
chunks_ = *static_cast<void**>(chunks_);
::operator delete(p);
}
}
void* allocate() {
if (!free_) refill();
FreeNode* node = free_;
free_ = node->next; // 摘下头节点 ------ O(1)
return node;
}
void deallocate(void* p) noexcept {
auto* node = static_cast<FreeNode*>(p);
node->next = free_; // 头插归还 ------ O(1)
free_ = node;
}
// 槽位大小必须 >= 指针大小(否则存不下 next),
// 并且向上取整到 max_align_t 的倍数,保证每一块都满足最大对齐
static constexpr std::size_t slotSize() {
constexpr std::size_t base =
BlockSize < sizeof(FreeNode) ? sizeof(FreeNode) : BlockSize;
constexpr std::size_t align = alignof(std::max_align_t);
return (base + align - 1) / align * align;
}
private:
void refill() {
// 一次向系统申请一大块,切成 ChunkCount 个块挂到自由链表上
const std::size_t slot = slotSize();
char* raw = static_cast<char*>(::operator new(slot * ChunkCount));
// 用第一块的空间保存 chunk 链表指针
*reinterpret_cast<void**>(raw) = chunks_;
chunks_ = raw;
for (std::size_t i = 1; i < ChunkCount; ++i) {
auto* node = reinterpret_cast<FreeNode*>(raw + i * slot);
node->next = free_;
free_ = node;
}
}
FreeNode* free_ = nullptr;
void* chunks_ = nullptr;
};
// 把 pool 引用注入到分配器里
template<class T, class PoolT>
class PoolAlloc {
public:
using value_type = T;
explicit PoolAlloc(PoolT* pool) noexcept : pool_(pool) {}
template<class U>
PoolAlloc(const PoolAlloc<U, PoolT>& other) noexcept : pool_(other.pool()) {}
T* allocate(std::size_t n) {
if (n != 1) {
// 池只服务于单元素分配(list/map 节点);批量走通用堆
return static_cast<T*>(::operator new(n * sizeof(T)));
}
return static_cast<T*>(pool_->allocate());
}
void deallocate(T* p, std::size_t n) noexcept {
if (n != 1) { ::operator delete(p); return; }
pool_->deallocate(p);
}
PoolT* pool() const noexcept { return pool_; }
private:
PoolT* pool_;
};
template<class T, class U, class P>
bool operator==(const PoolAlloc<T, P>& a, const PoolAlloc<U, P>& b) noexcept {
return a.pool() == b.pool();
}
template<class T, class U, class P>
bool operator!=(const PoolAlloc<T, P>& a, const PoolAlloc<U, P>& b) noexcept {
return !(a == b);
}
int main() {
// list 节点的真实大小由实现决定,这里取一个上界即可
using NodePool = FixedPool<sizeof(void*) * 4>;
NodePool pool;
{
using Alloc = PoolAlloc<int, NodePool>;
std::list<int, Alloc> lst{Alloc(&pool)};
for (int i = 0; i < 5; ++i) lst.push_back(i);
std::cout << "list size = " << lst.size() << "\n";
} // list 析构,节点全部归还到 pool,但 pool 不向系统释放
return 0;
}
这个分配器的意义在于:100 万次 push_back 只向系统申请 1000000 / 64 ≈ 15625 次大块,把堆操作次数降低两个数量级,而且因为块大小固定,几乎不产生碎片。
实战 3:C++17 的 pmr ------ 标准库自带的多态分配器
如果不方便给每个容器写分配器类型参数,可以用 std::pmr(polymorphic memory resource):
cpp
alignas(std::max_align_t) unsigned char buffer[4096]; // 栈上缓冲区
std::pmr::monotonic_buffer_resource pool{buffer, sizeof(buffer)};
std::pmr::vector<int> v{&pool}; // 只传运行期指针
for (int i = 0; i < 100; ++i) v.push_back(i); // 完全不碰全局堆
// pool 析构时整块回收,无需逐次 free
pmr 把分配器从"类型的一部分"变成"运行期对象":所有 pmr 容器共享同一个类型,运行时通过 memory_resource 指针分派。代价是每个容器多存一个指针,且分派走虚函数。
常见坑点
坑点 1:allocate 之后立刻构造对象
cpp
template<class T>
T* allocate(std::size_t n) {
T* p = static_cast<T*>(::operator new(n * sizeof(T)));
for (std::size_t i = 0; i < n; ++i) new (&p[i]) T(); // ❌ 分配器不该构造
return p;
}
分配器只负责原始内存 ,构造是容器(或 construct_at)的事。在分配器里构造会让 reserve 被迫构造全部元素,语义就错了;deallocate 时容器还会先析构再交给分配器,造成双重析构。
✅ 正确写法:只分配,不构造。
cpp
T* allocate(std::size_t n) { return static_cast<T*>(::operator new(n * sizeof(T))); }
void deallocate(T* p, std::size_t) noexcept { ::operator delete(p); }
坑点 2:分配器生命周期短于容器
cpp
std::vector<int, PoolAlloc<int, PoolT>> makeVec() {
PoolT pool; // ❌ 局部对象
std::vector<int, PoolAlloc<int, PoolT>> v{PoolAlloc<int, PoolT>(&pool)};
return v; // 返回后 pool 已析构,v 持有悬空指针
}
✅ 正确写法:分配器持有的池必须活得比容器久(静态存储期,或由调用方持有)。
坑点 3:allocate 的 n 与 deallocate 的 n 不一致
标准要求 deallocate(p, n) 的 n 与 allocate(n) 相同。有状态池分配器如果按 n 走不同代码路径(如实战 2 里 n != 1 的分支),一旦不匹配就会拿 operator delete 去释放池里的块,或者反过来------立刻崩溃。
最后提醒一句:std::allocator 直接使用 ::operator new,现代 glibc / jemalloc / tcmalloc 对小对象已有很好的缓存。除非 profile 显示分配确实是瓶颈,否则不要盲目替换。先测量,再优化。
总结
| 概念 | 要点 |
|---|
|------|----------------------------------------------------------------|
| 最小接口 | value_type + allocate + deallocate + 转换构造 + operator== |
|--------------------|-----------------------|
| allocator_traits | 补齐默认实现,让自定义分配器只需写两个函数 |
|----------|----------------------------------------------|
| rebind | 容器需要把 allocator<T> 变成 allocator<Node<T>> |
|--------|----------------------------|
| 有状态分配器 | 必须重载 operator==,且生命周期要够长 |
|-----------|-------------------|
| 传播 traits | 决定容器拷贝/移动时能否"偷"内存 |
|-------|---------------------|
| pmr | 类型无关的多态分配器,代价是多一个指针 |
分配器的价值不在"能编译",而在把内存策略变成可插拔的一等公民。理解了"容器只认 allocate/deallocate 与 construct/destroy",你就理解了标准库几乎所有容器的内存行为。