C++ STL 之 allocator 与内存池详解

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 只拿裸内存,不构造对象。构造由容器的 constructstd::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 二级分配器"。它不是标准,但思想被无数项目借鉴。

架构

用户请求 n 字节

n > 128 bytes?

一级分配器
调用 malloc

二级分配器
查自由链表

free_list[i]
是否有空闲块?

弹出链表头节点
返回给用户

从内存池
refill 一批块

内存池剩余
是否满足需求?

切割内存池
分配一块给用户
剩余挂入链表

调用 malloc
补充内存池
(若失败则从
更大链表借)

核心设计

  • 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_typeallocatedeallocate,剩下的 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 体系

pmr::vector

polymorphic_allocator

memory_resource*
(运行时多态)

new_delete_resource
(默认,调 operator new)

monotonic_buffer_resource
(最快,不回收)

synchronized_pool_resource
(线程安全池)

unsynchronized_pool_resource
(非线程安全池)

向上游资源
申请大块

四种资源详解

资源线程安全回收行为适用场景
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::vectorpmr::stringpmr::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 弃用了 constructdestroymax_size 等成员方法(由 traits 提供)。同时 PMR 成为推荐方案,但 std::allocator<T> 仍然是默认分配器,保持向后兼容。

Q8:写一个最少实现的 PMR memory_resource 需要实现几个虚函数?

:三个纯虚私有函数:do_allocatedo_deallocatedo_is_equal。加上公开的 allocate / deallocate / is_equal 是基类已实现的,子类不露虚函数。


总结

年代方案特点
C++98std::allocator默认方案,包装 ::operator new/delete
实践中SGI 二级分配器非标但影响深远的 16 链表 + 内存池
C++11allocator_traits统一接口,降低自定义成本
C++17PMR运行期多态,4 种资源开箱即用

选择建议:

  • 默认开发:用 std::allocator,不需要动
  • 小对象高频分配,单线程unsynchronized_pool_resource
  • 多线程高频分配synchronized_pool_resource
  • 短期大量分配不回收monotonic_buffer_resource
  • 想监控或者 mock 内存:继承 memory_resource,一行钩子
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Ricky_Theseus

感谢大家,祝您生活愉快

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值