Allocator 是 STL 容器与底层内存系统之间的抽象层:容器只负责对象构造与数据结构逻辑,内存的获取与归还交给 allocator。默认的 std::allocator 覆盖了绝大多数场景,但在高频分配、生命周期集中或需要内存审计的场景下,理解并定制这一层是最后的性能杠杆。本文梳理 allocator 的接口契约、自定义要点与 C++17 引入的 PMR,并用实测数据检验常见的性能假设。

接口契约

C++11 起,一个 allocator 的最小要求只有四个要素:

  • value_type:元素类型别名;
  • allocate(n):分配足以容纳 n 个 value_type 的未构造内存,失败抛 std::bad_alloc;
  • deallocate(p, n):归还 allocate 返回的内存,n 必须与分配时一致;
  • operator==:判定两个 allocator 实例是否可以互相释放内存。

其余成员——pointer、rebind、construct、destroy——都由 allocator_traits 按需补全:rebind 通过模板形参模式自动推导,对象构造则直接落到 std::construct_at。std::allocator 的 construct/destroy 成员在 C++17 弃用、C++20 移除,自定义 allocator 几乎不需要插手对象生命周期。

一个无状态、可全局统计的计数 allocator 只需要这么多代码:

1
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
35
36
37
#include <cstddef>
#include <cstdio>
#include <new>
#include <vector>

template <typename T>
class CountingAllocator {
public:
using value_type = T;

T* allocate(std::size_t n) {
if (n > SIZE_MAX / sizeof(T)) {
throw std::bad_alloc{};
}
++live_allocations;
return static_cast<T*>(
::operator new(n * sizeof(T)));
}

void deallocate(T* p, std::size_t) noexcept {
--live_allocations;
::operator delete(p);
}

friend bool operator==(
const CountingAllocator&,
const CountingAllocator&) noexcept {
return true;
}

static inline int live_allocations = 0;
};

std::vector<int, CountingAllocator<int>> vec;
vec.push_back(42);
printf("live = %d\n",
CountingAllocator<int>::live_allocations);

C++20 起 operator!= 可由 == 自动合成;更老的标准需要手写一份。

相等性与传播

Allocator 分两类。无状态(stateless)allocator 如 std::allocator,所有实例等价,operator== 恒为 true。有状态(stateful)allocator 携带数据——内存池、资源指针、统计计数——实例之间是否相等必须显式定义,语义是"两个实例可以互相释放对方的内存"。相等性判断错了,容器行为直接未定义。

容器在拷贝赋值、移动赋值和交换时如何对待 allocator,由三个传播 trait 控制,外加一个相等性声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
template <typename T>
class MyAllocator {
public:
using value_type = T;

// 拷贝赋值时,目标容器的 allocator
// 被源容器的替换
using propagate_on_container_copy_assignment =
std::true_type;

// 移动赋值时转移 allocator
using propagate_on_container_move_assignment =
std::true_type;

// 交换容器时一并交换 allocator
using propagate_on_container_swap =
std::true_type;

// 所有实例相等(即无状态)
using is_always_equal = std::true_type;
};

默认值全部为 false_type。此时拷贝赋值后目标容器保留自己的 allocator;而交换是硬约束——propagate_on_container_swap 为 false 时,交换两个 allocator 不相等的容器是未定义行为,标准连兜底语义都不给。

手写一个 Arena Allocator

Arena(区域分配器)从预分配的大块内存中线性分配、整体释放,适合游戏引擎、编译器、请求处理这类大量短命对象集中出现的场景。它也是暴露 stateful allocator 全部难点的好例子。

难点在所有权:allocator 会被容器拷贝(std::list 还会 rebind 出节点类型的 allocator),如果 allocator 直接持有缓冲区,默认拷贝会导致双重释放。正确的结构是 Arena 拥有内存、allocator 只持观察指针:

1
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <cstddef>
#include <cstdint>
#include <memory>
#include <new>
#include <vector>

class Arena {
public:
explicit Arena(std::size_t bytes)
: buffer_(static_cast<std::byte*>(
::operator new(bytes)))
, cursor_(buffer_)
, end_(buffer_ + bytes) {}

~Arena() { ::operator delete(buffer_); }

Arena(const Arena&) = delete;
Arena& operator=(const Arena&) = delete;

void* allocate(std::size_t bytes,
std::size_t alignment) {
auto addr = reinterpret_cast<std::uintptr_t>(
cursor_);
std::size_t padding =
(alignment - addr % alignment)
% alignment;
if (padding + bytes >
static_cast<std::size_t>(end_ - cursor_)) {
throw std::bad_alloc{};
}
cursor_ += padding + bytes;
return cursor_ - bytes;
}

void reset() noexcept { cursor_ = buffer_; }

private:
std::byte* buffer_;
std::byte* cursor_;
std::byte* end_;
};

template <typename T>
class ArenaAllocator {
public:
using value_type = T;

explicit ArenaAllocator(
std::shared_ptr<Arena> arena) noexcept
: arena_(std::move(arena)) {}

template <typename U>
ArenaAllocator(const ArenaAllocator<U>& other)
: arena_(other.arena_) {}

T* allocate(std::size_t n) {
return static_cast<T*>(arena_->allocate(
n * sizeof(T), alignof(T)));
}

void deallocate(T*, std::size_t) noexcept {
// 区域整体释放,单独释放是空操作
}

template <typename U>
bool operator==(
const ArenaAllocator<U>& rhs) const noexcept {
return arena_ == rhs.arena_;
}

private:
template <typename U>
friend class ArenaAllocator;

std::shared_ptr<Arena> arena_;
};

int main() {
auto arena = std::make_shared<Arena>(64 * 1024);
std::vector<int, ArenaAllocator<int>> vec{
ArenaAllocator<int>{arena}};

for (int i = 0; i < 100; ++i) {
vec.push_back(i);
}
}

这个设计逐条回应了 stateful allocator 的契约:shared_ptr 让拷贝共享同一块 Arena,析构安全;跨类型的 operator== 比较 Arena 指针,同一区域的两个 allocator 视为相等,容器在 rebind 出的节点 allocator 之间转移内存没有障碍;deallocate 是空操作,意味着内存要等 Arena 析构或 reset() 才真正归还——vector 扩容时旧缓冲区不释放,用空间换时间,这正是 Arena 的本意。如果业务需要交换容器,还要把 propagate_on_container_swap 声明为 std::true_type。

写到这里可以看出问题:所有权、相等性、rebind、传播策略,每一环都要自己扛。这就是 PMR 被发明出来的原因。

PMR:多态内存资源

C++17 的 PMR(Polymorphic Memory Resources)把分配策略从模板参数移到运行时。std::pmr::memory_resource 提供分配、释放和相等性虚接口;std::pmr::polymorphic_allocator<T> 只保存一个资源指针。因此,容器可在构造时选择资源,不必为每种策略生成一套容器类型。

资源适用场景特性
monotonic_buffer_resource一批短命对象只分配、不单独释放,整体回收
unsynchronized_pool_resource单线程、反复分配小块池化复用
synchronized_pool_resource多线程共享资源池化,带同步开销
new_delete_resource()默认上游直通全局 new/delete

资源可指定上游:当前资源耗尽或需要取得新块时,向上游申请。

一次性生命周期:Monotonic Buffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <cstddef>
#include <memory_resource>
#include <vector>

int main() {
std::byte buffer[1024];
std::pmr::monotonic_buffer_resource r{
buffer, sizeof(buffer)};

std::pmr::vector<int> vec{&r};
for (int i = 0; i < 100; ++i) {
vec.push_back(i * 2);
}
}

缓冲耗尽后会使用默认上游 new_delete_resource();vec 析构不会归还单块内存,只有 r.release() 或 r 析构时才整体回收。资源必须比所有使用它的对象活得久。若元素自身还会动态分配,例如字符串,应使用 std::pmr::vector<std::pmr::string>,让元素的内部内存也走同一资源。

性能:测量优先

"PMR 一定更快"是值得怀疑的假设。以下基准用 Google Benchmark、-O2 优化,测量向容器填充 n 个字符串的总耗时:

1
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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <benchmark/benchmark.h>
#include <cstdint>
#include <memory_resource>
#include <string>
#include <vector>

static void BM_Default(benchmark::State& state) {
for (auto _ : state) {
std::vector<std::string> vec;
for (std::int64_t i = 0;
i < state.range(0); ++i) {
vec.emplace_back(
"test string " + std::to_string(i));
}
benchmark::DoNotOptimize(vec.data());
}
}

static void BM_Monotonic(
benchmark::State& state) {
static std::byte buffer[2 * 1024 * 1024];
std::pmr::monotonic_buffer_resource r(
buffer, sizeof(buffer));
for (auto _ : state) {
std::pmr::vector<std::pmr::string> vec{&r};
for (std::int64_t i = 0;
i < state.range(0); ++i) {
vec.emplace_back(
"test string " + std::to_string(i));
}
benchmark::DoNotOptimize(vec.data());
r.release();
}
}

static void BM_Pool(benchmark::State& state) {
std::pmr::synchronized_pool_resource pool;
for (auto _ : state) {
std::pmr::vector<std::pmr::string> vec{
&pool};
for (std::int64_t i = 0;
i < state.range(0); ++i) {
vec.emplace_back(
"test string " + std::to_string(i));
}
benchmark::DoNotOptimize(vec.data());
}
}

BENCHMARK(BM_Default)->Range(100, 10000);
BENCHMARK(BM_Monotonic)->Range(100, 10000);
BENCHMARK(BM_Pool)->Range(100, 10000);

一组单机实测样本(只看趋势,不当作通例),glibc 默认 malloc 下相对 std::allocator 的耗时比:

元素数monotonicpool
100–512≈1.0–1.03x≈1.16x
4096快约 1.6 倍≈1.09x
10000快约 2.2 倍慢约 1.4 倍

换用 jemalloc(LD_PRELOAD 或链接 -ljemalloc)后格局反转:std::allocator 提速最明显,PMR 反而相对落后——monotonic 慢 10–20%,pool 慢 20–110%。jemalloc 本身就是高质量的通用分配器,默认路径已经很快,PMR 的多态分发与池管理开销在对比中被放大。

这组数据能推出的结论:

  1. malloc 越弱,绕开它的收益越大。分配器质量高时,定制策略的边际收益趋近于零。
  2. monotonic 的赢面在海量短命对象加一次性释放,且大块复用时分配成本趋近于零。
  3. pool 需要稳定的分配模式才能体现块复用价值,中等规模下与默认打平是常态。
  4. PMR 的核心价值是多态与生命周期控制,不是裸性能。

与智能指针配合

std::allocate_shared 可以让控制块和对象一起从指定 allocator 分配,但这恰恰放大了资源生命周期问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <memory>
#include <memory_resource>
#include <print>

struct Widget {
int data;
};

// 错误:resource 在函数返回时析构,
// shared_ptr 的控制块恰恰来自它
std::shared_ptr<Widget> make_widget_bad() {
std::pmr::monotonic_buffer_resource r{1024};
std::pmr::polymorphic_allocator<Widget> alloc{
&r};
return std::allocate_shared<Widget>(alloc, 42);
}

// 正确:资源由调用方持有,活得比对象久
std::shared_ptr<Widget> make_widget(
std::pmr::monotonic_buffer_resource& r) {
std::pmr::polymorphic_allocator<Widget> alloc{
&r};
return std::allocate_shared<Widget>(alloc, 42);
}

C++20 另外补齐了 uses-allocator 构造的工具:std::make_obj_using_allocator<T>(alloc, args...) 与 std::uses_allocator_construction_args,让任意类型在构造时接收 allocator,不必手写转发样板。

标准演进速览

标准变化
C++11allocator_traits 与最小 allocator 要求,旧式接口全面收编
C++17PMR(memory_resource、polymorphic_allocator、内置资源);std::allocator 的 construct/destroy 弃用
C++20上述弃用成员移除;polymorphic_allocator 增加 allocate_bytes/new_object 系列;aligned new 全面可用
C++23allocator_traits::allocate_at_least 尺寸反馈,分配器可返回实际容量供 sized deallocation 使用

陷阱清单

陷阱 1:交换不相等的 stateful allocator。 默认传播策略下,交换两个 allocator 不相等的容器是未定义行为:

1
2
3
4
std::pmr::monotonic_buffer_resource r1, r2;
std::pmr::vector<int> a{&r1}, b{&r2};

std::swap(a, b); // 未定义行为

解法要么声明 propagate_on_container_swap,要么保证资源相等(同一 memory_resource*)。

陷阱 2:资源悬垂。 由 memory_resource 分配的对象活得比资源本身久——典型如前文的 make_widget_bad。规则只有一条:资源生命周期必须覆盖所有由它分配的对象。用 AddressSanitizer(clang++ -fsanitize=address)一查一个准。

陷阱 3:元素不走你的资源。 std::pmr::vector<std::string> 只让容器本身的缓冲走 PMR,std::string 元素的堆分配仍然走全局 new。想要整条链路统一,元素也得用 std::pmr::string。

陷阱 4:类型边界。 std::pmr::vector<int> 与 std::vector<int> 是不同类型,无法直接传给接收 std::vector<int>& 的接口,跨 ABI 边界(插件、动态库)更是不能混用。PMR 适合留在模块内部。

陷阱 5:对齐。 ::operator new 只保证 __STDCPP_DEFAULT_NEW_ALIGNMENT__(通常 16 字节),对 over-aligned 类型(如 alignas(32) 的 SIMD 结构)必须走 aligned new:

1
2
3
4
5
6
7
8
9
10
11
12
// 错误:忽略对齐要求
T* allocate(std::size_t n) {
return static_cast<T*>(
::operator new(n * sizeof(T)));
}

// 正确:over-aligned 类型走 aligned new
T* allocate(std::size_t n) {
return static_cast<T*>(::operator new(
n * sizeof(T),
std::align_val_t{alignof(T)}));
}

结论

从 std::allocator 到手写 arena,allocator 的谱系跨度很大,但决策路径是清晰的:

  1. 默认优先:99% 的场景 std::allocator 加一个高质量全局分配器(jemalloc、mimalloc)已经够用,LD_PRELOAD 就能验证。
  2. 测量给定制发资格证:profiler 证明分配是热点,才轮到 allocator 出场。
  3. 定制优先选 PMR:所有权、相等性、传播这些难题标准库都替你解决了。
  4. 手写 allocator 只留给模板场景:编译期注入、类型级特化这类必须出现在类型系统里的需求。
  5. 一切结论以自己的基准为准:本文的数据也只代表那台机器上的那次运行。

简单第一。解决真问题,别追虚幻性能。