Allocator分配器机制
# 39.Allocator分配器机制
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. std::allocator 的默认实现
- 4. 自定义分配器的正确姿势
- 5. PMR 与多态内存资源
- 6. 三种标准 memory_resource
- 7. scoped_allocator 的嵌套传播
- 8. 分配器与异常安全的共生
- 9. 各场景分配器选型
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 游戏引擎的内存碎片风暴
某游戏引擎的粒子系统——每帧生成数千个粒子,每个粒子是一个 std::vector 中的元素。用默认的 std::allocator——每帧数千次 new/delete。上线一个月后出现周期性的帧率掉帧——30ms 的尖峰每隔约 20 秒出现一次:
// ====== 事故代码 V1:默认分配器导致碎片化 ======
struct Particle { glm::vec3 pos, vel; float life; /* ~40 字节 */ };
std::vector<Particle> particles;
void update_frame() {
particles.clear(); // ① 析构——但不释放内存 (capacity 保留)
particles.reserve(5000);
for (int i = 0; i < 5000; ++i) {
particles.emplace_back(...); // ② 无需分配——capacity 足够 ✅
}
}
// 这里没问题——vector 的 capacity 避免了每帧分配
// 但场景加载时:
std::vector<std::vector<Particle>> scenes(100);
for (auto& scene : scenes) {
scene.resize(5000); // ① 5000 次 operator new(每个 Particle 约 40B)
}
// 100 个场景 × 5000 次分配 = 500000 次小分配 → glibc malloc 碎片化
// → 后续的分配变慢——碎片越多,查找 free list 越慢
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
修复——用自定义池分配器免除每元素分配:一个场景的 5000 个 Particle 从同一块预分配池中获取——零碎片。
# 1.2 PMR 的一次性释放惊人行为
团队改用 std::pmr::monotonic_buffer_resource——把场景加载内存全部在预分配池中完成。但发现场景切换时所有 PMR 对象全部失效:
// ====== 事故代码 V2:monotonic_buffer 的全局释放 ======
std::array<std::byte, 50'000'000> buffer;
std::pmr::monotonic_buffer_resource pool(buffer.data(), buffer.size());
std::pmr::vector<std::pmr::string> names(&pool);
names.emplace_back("player_1"); // string 的字符内存在 pool 中
names.emplace_back("npc_42");
// 场景结束——希望释放这 50MB
pool.release(); // ⚠️ 一次性释放所有内存——names 和里面的所有 string 全部失效!
// names.size() 仍然是 2——但元素指向的是 pool 中已被回收的内存 → SIGSEGV
2
3
4
5
6
7
8
9
10
11
根因:monotonic_buffer_resource 不跟踪单个分配——它只记住「分配了多少」。release() 把水位线重置为 0——所有从它分配的对象全部失效。它不是 GC——没有「逐个释放」的能力——只有「全局重置」。
# 1.3 七个待解疑问
① std::allocator 内部做了什么? allocate/deallocate/rebind 是什么? → 第 3 章
② 自定义分配器怎么实现? 最小的兼容接口是什么? → 第 4 章
③ PMR 和传统的模板分配器有什么区别? 什么时候该用 PMR? → 第 5 章
④ monotonic_buffer / unsynchronized_pool 分别适用于什么场景? → 第 6 章
⑤ 嵌套容器(string in vector) 的分配器如何传播? scoped_allocator 是什么? → 第 7 章
⑥ 分配失败时的容器行为是什么? 强异常安全怎么保? → 第 8 章
⑦ 分配器和 jemalloc / tcmalloc 是什么关系? 一个替换分配器策略够吗? → 第 9 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 分配器的四层金字塔
┌──────────────────────────────────────────────────────────┐
│ 分配器四层金字塔 │
│ │
│ ┌──────────────────────────────────────────────────┐ │
│ │ ④ PMR memory_resource (运行时多态) │ │
│ │ 切换分配器不改变容器类型——零代码重编译 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ ③ 自定义 allocator (编译期模板参数) │ │
│ │ 池分配器 / 共享内存分配器 / 调试分配器 │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ ② std::allocator (默认——静态分发到 ::new/delete) │ │
│ ├──────────────────────────────────────────────────┤ │
│ │ ① operator new / delete (底层——通常是 malloc/free) │ │
│ └──────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 2.2 为何这么切
疑惑:为什么 C++17 引入 PMR——之前的模板分配器不够用吗?
论证:
- 模板分配器导致类型改变——
vector<int, MyAlloc>和vector<int>是不同的类型。 不能用同一个函数接口接受两种分配器的 vector。如果你想在运行时切换分配器——模板方案做不到。 - PMR 把分配器做成运行时多态——
pmr::vector<int>只有一个类型。 传入不同的memory_resource*即可切换底层策略——不需要重编译。 - 传统分配器要求 rebind——
std::allocator<T>的内部需要用到std::allocator<U>(如 list 的节点分配)。这个转换在模板分配器里需要显式声明rebind——PMR 中由虚函数自动处理。
结论:模板分配器是「编译期选择」,PMR 是「运行期选择」。 如果需要同一个类型但底层分配策略可变——用 PMR。如果不需要——模板分配器更小更快(无虚函数开销)。
# 3. std::allocator 的默认实现
# 3.1 allocate 与 deallocate 的最小接口
// libstdc++ 的 std::allocator 简化版
template <typename T>
class allocator {
public:
using value_type = T;
T* allocate(size_t n) {
// ① 直接调 operator new ——路径 → malloc → brk/mmap
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* p, size_t n) {
// ② 不调用析构——只释放内存
::operator delete(p);
// 注意:n 参数在 C++17 之前是必需的(分配器需要知道大小释放)
// C++17 之后变成可选
}
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
关键:allocate 不构造对象,deallocate 不析构对象。构造和析构是容器的职责。分配器只管「裸内存」。
# 3.2 rebind 的类型转换魔术
疑惑:如果容器用 allocator<T>——内部需要 allocator<ListNode> 怎么办?
论证——rebind 机制:
// std::list<int, std::allocator<int>> 的内部:
// 需要分配的不是 int——而是 _List_node<int>(包含 prev/next+data)
// rebind 把 allocator<int> 转换为 allocator<_List_node<int>>
template <typename T>
class allocator {
template <typename U>
struct rebind {
using other = allocator<U>; // 同一个分配策略——用于不同类型
};
};
// 容器内部用法:
using NodeAlloc = typename std::allocator_traits<Alloc>::template rebind_alloc<Node>;
NodeAlloc node_alloc(original_alloc); // 从 T 的分配器创建 Node 的分配器
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rebind 保证了同一个分配策略可以用于不同大小的对象——这对节点型容器(list、map、unordered_map)至关重要——它们分配的是内部节点类型,不是用户看到的 value_type。
# 3.3 为什么 std::allocator 是无状态的
static_assert(sizeof(std::allocator<int>) == 1); // 空类——EBO 优化为 0
// vector 继承 allocator(EBO)——不增加 sizeof
// vector<int> 的 sizeof = 24(3 指针),不是 32(如果有 allocator 成员)
2
3
4
无状态的好处:默认构造、拷贝、赋值全是平凡操作。容器的 move 构造可以不搬移分配器。
# 3.4 构造与析构分离
C++17 弃用、C++20 移除的 construct/destroy 成员——说明构造/析构不是分配器的职责:
// ❌ C++17 前——分配器负责构造
alloc.construct(ptr, args...); // placement new——已弃用
// ✅ C++17 之后——容器直接调 placement new
::new (ptr) T(std::forward<Args>(args)...);
2
3
4
5
# 4. 自定义分配器的正确姿势
# 4.1 最小兼容的分配器实现
template <typename T>
class PoolAllocator {
std::vector<std::aligned_storage_t<sizeof(T), alignof(T)>> pool_;
size_t next_ = 0;
public:
using value_type = T;
T* allocate(size_t n) {
if (next_ + n > pool_.size()) {
// 池不够——回退到默认 new
return static_cast<T*>(::operator new(n * sizeof(T)));
}
T* p = reinterpret_cast<T*>(&pool_[next_]);
next_ += n;
return p;
}
void deallocate(T* p, size_t n) noexcept {
// 池中分配的不单独释放——批量回收
// 回退到 new 的用 delete 释放
if (p < reinterpret_cast<T*>(&pool_[0]) ||
p >= reinterpret_cast<T*>(&pool_[pool_.size()])) {
::operator delete(p);
}
}
// rebind 用于同策略的类型转换
template <typename U>
struct rebind { using other = PoolAllocator<U>; };
// 必须支持不同分配器的比较——同类型分配器永远相等(池共享)
bool operator==(const PoolAllocator&) const noexcept { return true; }
bool operator!=(const PoolAllocator&) const noexcept { return false; }
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 4.2 池分配器的实战
// 游戏场景:每帧 5000 个粒子——预分配 10000 个槽
PoolAllocator<Particle> particle_alloc;
particle_alloc.reserve(10000);
std::vector<Particle, PoolAllocator<Particle>> particles(particle_alloc);
particles.reserve(5000);
// 所有 push_back —— 直接从池中获取——零 malloc 调用、零碎片
2
3
4
5
6
7
# 4.3 与容器的集成方式
// 容器的最后一个模板参数就是分配器
std::vector<int, MyAllocator<int>> v(my_alloc);
// C++17 CTAD 让它更简洁
std::vector v(10, 0, my_alloc); // 自动推导为 vector<int, MyAllocator<int>>
2
3
4
5
# 4.4 分配器传播规则
std::vector<int, MyAlloc> v1(my_alloc);
auto v2 = v1; // 拷贝——v2 的分配器也是 v1 的分配器?标准:是
auto v3 = std::move(v1); // 移动——v3 的分配器?标准:也是(move 传播分配器)
2
3
# 4.5 池分配器性能对比
疑惑:池分配器真的能快多少?和 monotonic_buffer 哪个更快?
论证——100 万次 vector<int> 的 push_back + clear 循环:
| 分配器 | push_back | clear | 总时间 | 碎片 | 说明 |
|---|---|---|---|---|---|
std::allocator | 5.2 ms | 0.1 ms | 5.3 ms | 有 | operator new→malloc |
PoolAllocator(预分配) | 1.8 ms | 0 ms | 1.8 ms | 零 | 直接池中取 |
monotonic_buffer | 1.5 ms | —(release) | 1.5 ms | 零 | 水位线下分配 |
unsynchronized_pool | 3.1 ms | 0.5 ms | 3.6 ms | 低 | 线程本地 free list |
关键洞察:池分配器和 monotonic 在分配速度上接近——但池分配器可以逐个回收(deallocate),monotonic 只能全局重置。选择取决于生命周期边界:帧级→monotonic,对象级→池。
# 4.6 分配器传播与 scoped_allocator 的完整推演
# 5. PMR 与多态内存资源
# 5.1 从静态多态到运行时多态
// ❌ 模板方式——类型不同
std::vector<int, PoolAlloc> v1;
std::vector<int, ArenaAlloc> v2;
// v1 和 v2 是不同的类型——不能用统一接口
// ✅ PMR 方式——类型相同
std::pmr::vector<int> v1(&pool);
std::pmr::vector<int> v2(&arena);
// v1 和 v2 完全相同——可以在运行时切换底层 resource
2
3
4
5
6
7
8
9
# 5.2 memory_resource 的虚函数接口
class memory_resource {
public:
virtual ~memory_resource() = default;
void* allocate(size_t bytes, size_t alignment = alignof(max_align_t)) {
return do_allocate(bytes, alignment); // 非虚——可做日志/统计
}
void deallocate(void* p, size_t bytes, size_t alignment) {
return do_deallocate(p, bytes, alignment);
}
bool is_equal(const memory_resource& other) const noexcept {
return do_is_equal(other);
}
private:
virtual void* do_allocate(size_t bytes, size_t alignment) = 0;
virtual void do_deallocate(void* p, size_t bytes, size_t alignment) = 0;
virtual bool do_is_equal(const memory_resource&) const noexcept = 0;
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
虚函数开销:每次分配多一次间接调用(读 vptr + call)。模板分配器编译期直接跳到 operator new——零间接。
# 5.3 pmr::vector 的类型别名
namespace pmr {
template <typename T>
using vector = std::vector<T, std::pmr::polymorphic_allocator<T>>;
// polymorphic_allocator 存 memory_resource*——而非模板策略类型
}
2
3
4
5
# 5.4 运行时切换分配器的能力
// 同一个类型——不同 resource
std::pmr::monotonic_buffer_resource frame_pool(1024 * 1024);
std::pmr::unsynchronized_pool_resource global_pool;
std::pmr::vector<int> v;
v = std::pmr::vector<int>(&frame_pool); // 运行时切到帧池
// ... 帧内操作 ...
v = std::pmr::vector<int>(&global_pool); // 运行时切到全局池
2
3
4
5
6
7
8
# 6. 三种标准 memory_resource
# 6.1 new_delete_resource —— 默认兜底
// 底层仍然是 ::operator new / delete
auto* res = std::pmr::new_delete_resource();
std::pmr::vector<int> v(100, res);
// 等价于 std::vector<int>——但通过虚函数跳转(多一次间接)
2
3
4
# 6.2 monotonic_buffer —— 预分配大块
char buf[10 * 1024 * 1024];
std::pmr::monotonic_buffer_resource pool(buf, sizeof(buf));
// 关键特性:不跟踪单个释放——只有全局 reset
pool.release(); // 所有内存直接失效——水位线归零
2
3
4
5
使用场景:一帧内大量分配+帧结束统一释放(游戏引擎/请求处理)。
# 6.3 unsynchronized_pool —— 线程本地池
// 每线程独立池——无锁
std::pmr::unsynchronized_pool_resource pool;
// 适合:每个线程有自己的工作数据——不需要跨线程共享池
2
3
4
# 6.4 benchmark 性能对比
100 万次 pmr::vector<int> 的 push_back(各 1 个元素):
| 分配器 | 时间 | 说明 |
|---|---|---|
new_delete_resource | 12 ms | 无额外开销 |
monotonic_buffer | 3 ms | 无 free、无碎片——最快 |
unsynchronized_pool | 8 ms | 线程本地池——无锁 |
synchronized_pool | 18 ms | 多线程共享池——有锁 |
# 7. scoped_allocator 的嵌套传播
# 7.1 嵌套容器分配器难题
// 如果 vector<string> 的 string 内部用默认分配器——即使 vector 用了自定义分配器
std::pmr::vector<std::string> v(&pool);
v.emplace_back("hello"); // string 的内部字符存储在默认堆上——不在 pool 中!
2
3
# 7.2 scoped_allocator_adaptor 的递推传播
// scoped_allocator 让分配器递归传播到嵌套容器/元素
std::pmr::vector<std::pmr::string> v(
std::pmr::polymorphic_allocator<std::pmr::string>(&pool)
);
// 使用 scoped_allocator_adaptor 自动传播:
std::scoped_allocator_adaptor<std::pmr::polymorphic_allocator<std::pmr::string>> sa(&pool);
std::vector<std::pmr::string, decltype(sa)> v2(sa);
v2.emplace_back("hello"); // string 内部也在 pool 中!✅
2
3
4
5
6
7
8
# 7.3 什么时候需要 scoped_allocator
| 场景 | 是否需要 |
|---|---|
vector<int>(POD 无内部分配) | ❌ |
vector<string> + 自定义分配器 | ✅ 需要 scoped |
pmr::vector<pmr::string> + pmr | ✅ pmr 自带传播 |
map<int, vector<int>> + 自定义 | ✅ 需要 scoped |
# 8. 分配器与异常安全的共生
# 8.1 allocate 失败的标准行为
// 默认:抛 std::bad_alloc
auto* p = alloc.allocate(too_large);
// noexcept 分配器——返回 nullptr
auto* p = noexcept_alloc.allocate(too_large); // 不抛异常
2
3
4
5
# 8.2 分配失败的回滚策略
// vector::push_back 的强异常安全:
// ① allocate 新内存 → 可能抛 bad_alloc
// ② 移动/拷贝旧元素 → 可能抛(非 noexcept)
// ③ deallocate 旧内存
// 如果 ② 失败 → deallocate 新内存 → 旧 vector 不变 ✅
2
3
4
5
# 9. 各场景分配器选型
# 9.1 高频交易 / 游戏 / 嵌入式 场景对比
| 场景 | 推荐 | 原因 |
|---|---|---|
| 通用(无特殊需求) | std::allocator | 最简单——零心智负担 |
| 每帧临时数据 | monotonic_buffer | 帧结束统一释放——零 free |
| 线程本地工作数据 | unsynchronized_pool | 每线程独立——无锁分配 |
| 全程序生命周期 | new_delete_resource | 依赖 malloc/free——成熟稳定 |
| 共享内存 IPC | 自定义分配器 | 在共享内存段分配 |
| 调试内存泄漏 | 自定义追踪分配器 | 记录每次 alloc/dealloc 的栈 |
# 9.2 与 jemalloc / tcmalloc 的分工
它们不在同一层:
自定义分配器 / PMR ← 容器层——控制「如何组织内存」
operator new / delete ← 接口层——C++ 分配入口
malloc / free ← 实现层——C 分配器
jemalloc / tcmalloc ← 替代 malloc——全局替换 LD_PRELOAD
2
3
4
可以同时用:自定义分配器减少容器碎片,tcmalloc 加速底层 malloc。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | std::allocator 做什么? | 第 3 章:allocate→operator new, deallocate→operator delete, rebind→类型转换 |
| ② | 自定义分配器? | 第 4 章:最小实现 4 成员+rebind+operator==;池分配器实战 |
| ③ | PMR vs 模板? | 第 5 章:PMR=运行时多态(同一类型),模板=编译期选定(不同类型) |
| ④ | 三种 resource? | 第 6 章:new_delete(兜底)、monotonic(帧统一释放)、unsynchronized_pool(线程本地) |
| ⑤ | scoped_allocator? | 第 7 章:分配器递归传播到嵌套容器的元素 |
| ⑥ | 分配失败? | 第 8 章:默认抛 bad_alloc,容器回滚保持强安全 |
| ⑦ | vs jemalloc? | 第 9 章:不同层——自定义分配器选组织、jemalloc 选底层实现 |
案例①修复(碎片风暴):场景加载用池分配器——5000 个 Particle 在同一块预分配内存中创建,零碎片。
案例②修复(PMR 释放):monotonic_buffer 只适合「帧内创建+帧结束丢弃」——不适合长期持有对象。
# 10.2 一次 pmr::vector::push_back 的完整分配链路
v.push_back(42);
① std::pmr::vector<int>::push_back → 检查 capacity
② 需要扩容 → allocator_.allocate(new_capacity)
③ polymorphic_allocator<int>::allocate → resource_->allocate(bytes)
④ memory_resource::allocate → do_allocate(bytes) ← 虚函数跳转
⑤ monotonic_buffer_resource::do_allocate
→ 从内部 buffer 取下一块 → 更新水位线指针
→ 返回指针——零系统调用
2
3
4
5
6
7
8
9
# 10.3 设计哲学回扣
哲学 1:分离关注——内存分配和对象构造是两件独立的事
allocate 只分配裸内存——不调构造函数。deallocate 只释放裸内存——不调析构函数。分配器的职责是「在哪放」,容器的职责是「怎么构造」。 这种分离让同一个分配器可以用于任何可分配的类型——通过 rebind 进行类型转换。
哲学 2:PMR 解决的是「分配器类型膨胀」——一个类型、多种策略
模板分配器的代价是每换一种分配器就产生一个新的容器类型。pmr::vector<int> 只有一个类型——运行时传入不同的 memory_resource*。这是从「编译期多态」到「运行期多态」的经典迁移——牺牲少量性能(一次虚函数调用),换取大量灵活性(一个类型通吃)。
哲学 3:monotonic_buffer 是最极致的「不释放」哲学
monotonic_buffer_resource 从不释放单个分配——只有全局 release()。这个设计告诉你:有时候「不释放」比「精确释放」更快——你不需要维护 free list、不需要处理碎片——一切在同一个连续缓冲区上线性增长。
哲学 4:分配器是可替换的契约——不是魔法
分配器不能解决所有性能问题。它只能控制容器内部元素的放置。如果你的瓶颈是算法复杂度(O(N²) 而非 O(N log N)),换分配器没有用。在优化之前先 profiling——确定瓶颈是分配器还是算法。
# 10.4 速查表合集
分配器选型速查:
| 需求 | 推荐 |
|---|---|
| 默认(无定制) | std::allocator |
| 需要运行时切换策略 | std::pmr::polymorphic_allocator + memory_resource |
| 短期临时数据(帧内) | monotonic_buffer_resource |
| 线程本地池 | unsynchronized_pool_resource |
| 嵌套容器传播 | std::scoped_allocator_adaptor |
卷五收官——八篇脉络回顾:
32.vector扩容真相 → 连续内存、growth factor、reserve
33.deque分段连续 → map+chunk、两端O(1)、cache 折中
34.list与forward_list → 节点链表、splice、哨兵
35.关联容器红黑树 → 五性质、异构查找、extract
36.哈希容器深度 → 拉链vs开放寻址、SIMD探测、rehash
37.迭代器五大类别 → tag dispatch、traits、C++20 sentinel
38.STL算法设计哲学 → introsort三合一、partition、ranges
39.Allocator分配器机制 → 模板vs PMR、monotonic、scoped
2
3
4
5
6
7
8
下一篇:分配器的路径说清了。下一篇进入卷六 40.C++内存模型基石——从多核 CPU 缓存架构到 MESI 协议、Store Buffer 与 Invalidate Queue、为什么 C++ 需要内存模型——并发编程的物理基础。