前言
内存池(memory pool)要解决的问题很具体:通用分配器(general-purpose allocator,也就是malloc/free或operator new/operator delete)为了应付任意大小、任意生命周期的请求, 内部必须维护元数据、分箱(bin)、合并空闲块(coalescing),并且为了线程安全通常要加锁。 如果你的程序是"反复申请、释放大量同尺寸、短生命周期的小对象",这些都是纯开销。
一个常见误解是"内存池就是自己写个operator new"。不是。重载operator new只是换了入口, 真正决定性能的是池的分配策略:块大小如何取整、空闲块如何组织、何时向系统批量取内存。 另一个误解是"内存池一定更快"——单线程下它确实通常更快,但多线程共享同一个池时, 一把互斥量带来的竞争可能比malloc的线程缓存还糟。
本文分三层讲:先讲定长池(fixed-size pool)的核心结构"侵入式空闲链表", 再给出一个完整可编译的实现,最后介绍 C++17 标准库里已经提供的池设施std::pmr。
一、核心思想:侵入式空闲链表
定长池只需要三样东西:
- 一整块从系统申请来的连续内存,按固定块大小切成 N 份;
- 一条空闲块链表,把所有未分配的块串起来;
- 一个"下一个空闲块"的头指针。
关键技巧是侵入式(intrusive):next指针直接存放在空闲块自身的字节里, 而不是额外开一个数组。块被分配出去时,那块内存里的next就被用户的对象覆盖; 块被归还时,再在原地重建next。这样每个块的开销是零——没有每块的元数据, 也没有额外的链表节点。
这套结构决定了池的复杂度都是 O(1):分配是从链表头摘一个,释放是往链表头插一个, 没有任何查找或合并。代价是:
- 块大小固定,只能服务一种尺寸(或一个尺寸档位)的请求;
- 块大小的下限是
sizeof(void*),否则装不下next指针; - 池本身不保证线程安全,多线程共享时必须自己加锁。
| 对比项 | operator new/delete | 定长内存池 |
|---|---|---|
| 单次分配复杂度 | 实现相关,需要在空闲链/分箱里查找 | O(1) 摘头 |
| 每块元数据 | 有(大小、边界标记等,实现定义) | 无 |
| 合并空闲块 | 支持 | 不需要(块等大) |
| 内存碎片 | 会产生外部碎片 | 池内的块等大,无外部碎片 |
| 线程安全 | 标准要求可多线程使用 | 默认不安全,需自行加锁 |
| 归还给系统的时机 | 视实现而定 | 通常等池析构/release() |
二、对齐与块尺寸
内存池最容易写错的地方是对齐。placement new(new (p) T(...))要求p满足alignof(T); 如果块大小取成 9 字节这种不是对齐数倍数的值,第 2 块开始的地址就不再是 8 的倍数, 在上面构造double或指针成员就是未定义行为(UB)。
正确的做法是把块大小向上取整到alignof(std::max_align_t)的倍数 (std::max_align_t在<cstddef>中)。alignof的结果一定是 2 的幂, 所以可以用位运算做取整:
constexpr std::size_t align_up(std::size_t n, std::size_t align) noexcept { return (n + align - 1) & ~(align - 1); }C++17 的::operator new(size)返回的内存至少满足alignof(std::max_align_t), 所以按这个对齐取整,池内任意块都可以安全地承载"基本对齐"的类型。 如果类型是过度对齐(over-aligned)的(比如带alignas(64)), 就需要 C++17 引入的对齐版本:::operator new(size, std::align_val_t(align))和对应的::operator delete(ptr, std::align_val_t(align))(都在<new>里)。
三、完整实现
下面是一个可直接编译的定长池,以及叠在它上面的类型安全包装。
// 适用:C++17;GCC 13 / Clang 17 / MSVC 19.3x 均可编译 // 编译:g++ -std=c++17 -Wall -Wextra -pedantic pool.cpp -o pool #include <cstddef> #include <iostream> #include <new> #include <utility> namespace { constexpr std::size_t align_up(std::size_t n, std::size_t align) noexcept { return (n + align - 1) & ~(align - 1); } // 空闲块就地存放的结构:一个指针,指向下一个空闲块 struct FreeNode { FreeNode* next; }; } // namespace class FixedPool { public: FixedPool(std::size_t block_size, std::size_t block_count) : block_size_(align_up(block_size < sizeof(FreeNode) ? sizeof(FreeNode) : block_size, alignof(std::max_align_t))), block_count_(block_count), free_head_(nullptr), raw_(nullptr) { if (block_count_ == 0) { return; } // 一次性向系统要一整块内存,之后不再和分配器打交道 raw_ = static_cast<unsigned char*>(::operator new(block_size_ * block_count_)); for (std::size_t i = 0; i < block_count_; ++i) { void* block = raw_ + i * block_size_; ::new (block) FreeNode{free_head_}; // 在空闲块上建立 FreeNode 对象 free_head_ = static_cast<FreeNode*>(block); } } ~FixedPool() { ::operator delete(raw_); } // 传 nullptr 是合法的空操作 FixedPool(const FixedPool&) = delete; FixedPool& operator=(const FixedPool&) = delete; // 池耗尽时返回 nullptr,而不是抛异常 void* allocate() noexcept { if (free_head_ == nullptr) { return nullptr; } FreeNode* node = free_head_; free_head_ = node->next; return node; } void deallocate(void* p) noexcept { if (p == nullptr) { return; } ::new (p) FreeNode{free_head_}; // 重新建立 FreeNode,结束被归还对象的生命周期 free_head_ = static_cast<FreeNode*>(p); } std::size_t block_size() const noexcept { return block_size_; } std::size_t block_count() const noexcept { return block_count_; } std::size_t available() const noexcept { // O(n),仅供调试/统计 std::size_t n = 0; for (const FreeNode* p = free_head_; p != nullptr; p = p->next) { ++n; } return n; } private: std::size_t block_size_; // 已按 max_align_t 取整 std::size_t block_count_; FreeNode* free_head_; // 空闲链表头 unsigned char* raw_; // 整块原始内存 }; // 类型安全包装:负责构造与析构,FixedPool 只负责字节 template <class T> class ObjectPool { public: static_assert(alignof(T) <= alignof(std::max_align_t), "本示例只支持基本对齐的类型;过度对齐类型请改用 C++17 的 aligned operator new"); explicit ObjectPool(std::size_t count) : pool_(sizeof(T), count) {} template <class... Args> T* create(Args&&... args) { void* p = pool_.allocate(); if (p == nullptr) { return nullptr; } try { return new (p) T(std::forward<Args>(args)...); } catch (...) { pool_.deallocate(p); // 构造函数抛异常时把块还回去,否则这块内存永久泄露 throw; } } void destroy(T* obj) noexcept { if (obj == nullptr) { return; } obj->~T(); // 显式调用析构函数 pool_.deallocate(static_cast<void*>(obj)); } std::size_t available() const noexcept { return pool_.available(); } private: FixedPool pool_; }; struct Vec2 { double x; double y; Vec2(double x_, double y_) noexcept : x(x_), y(y_) {} }; int main() { ObjectPool<Vec2> pool(4); std::cout << "块大小 = " << sizeof(Vec2) << ",初始可用 = " << pool.available() << '\n'; Vec2* a = pool.create(1.0, 2.0); Vec2* b = pool.create(3.0, 4.0); if (a != nullptr && b != nullptr) { std::cout << a->x + b->y << '\n'; // 1.0 + 4.0 } pool.destroy(a); pool.destroy(b); std::cout << "释放后可用 = " << pool.available() << '\n'; return 0; }逐行核对几个点:
::new (block) FreeNode{free_head_}是布局 new(placement new),
它不分配内存,只在给定地址上构造对象;FreeNode是聚合类型,用花括号初始化列表。
block_size_ < sizeof(FreeNode) ? sizeof(FreeNode) : block_size保证块至少装得下一个指针。- 四个成员按
block_size_、block_count_、free_head_、raw_的顺序声明,
构造函数的初始化列表顺序与之一致,避免-Wreorder警告(成员初始化顺序永远按声明顺序,不按列表顺序)。
ObjectPool::create的try/catch(...)里throw;是重新抛出当前异常,类型不变。
四、标准库里已有的池:std::pmr
C++17 在<memory_resource>里提供了现成的池设施,不必都自己写:
std::pmr::memory_resource:抽象基类,公开allocate/deallocate/is_equal,
子类实现do_allocate/do_deallocate/do_is_equal。
std::pmr::unsynchronized_pool_resource:单线程用,无锁,内部按尺寸档位管理多个小块池。std::pmr::synchronized_pool_resource:多线程用,需要同步开销。std::pmr::pool_options:可选项,有max_blocks_per_chunk和largest_required_pool_block
两个std::size_t成员,具体取值由实现决定。
// 需要 C++17 及以上 #include <cstddef> #include <memory_resource> #include <vector> int main() { std::pmr::unsynchronized_pool_resource pool; // 单线程,别跨线程用 std::pmr::polymorphic_allocator<int> alloc{&pool}; std::pmr::vector<int> v{alloc}; for (int i = 0; i < 1000; ++i) { v.push_back(i); // 扩容时的旧缓冲会归还给 pool,由 pool 复用 } pool.release(); // 一次性把所有内存还给上游(upstream) return 0; }libstdc++ 里还有一个历史遗留的扩展__gnu_cxx::__pool_alloc(头文件<ext/pool_allocator.h>), 那是 SGI STL 时代的池式分配器;标准库的std::allocator现在只是operator new的薄包装, 本身不做池化——这一点常被搞混。
常见坑点
- 块大小没有对齐取整
❌ 直接按用户给的sizeof(T)切块,T 为 9 字节时第 2 块起地址不再对齐, 在上面 placement new 是 UB。
✅ 用align_up(size, alignof(std::max_align_t))把所有块对齐到max_align_t。
- 只归还内存,不调用析构函数
❌pool.deallocate(p);直接丢掉一个持有std::string成员的对象,它的堆内存永远不会释放。
✅p->~T(); pool.deallocate(p);—— 先显式析构,再还内存,顺序不能反。
- 同一块内存释放两次
❌ 归还两次会把同一个块两次插入空闲链表,链表形成环,下一次allocate可能拿到重复地址。
✅ 建立所有权约定:destroy之后立刻把指针置空,接口文档写清"不得重复释放"。 此外,给池类显式= delete拷贝构造与拷贝赋值,能从类型层面杜绝 "两个池对象管同一块内存"引发的双重释放。
- 块大小小于
sizeof(void*)
❌ 用户申请 4 字节块并直接写FreeNode,越界覆盖相邻块的next字段。
✅ 用sizeof(FreeNode)做下限,并在文档里说明块实际会被放大。
- 构造抛异常时内存没归还
❌void* p = pool.allocate(); return new (p) T(args...);—— T 的构造抛了异常,p就永久丢失,池越用越少。
✅ 用try/catch(...)捕获,pool.deallocate(p)后重新throw。
- 把池当成线程安全的
❌ 多个线程直接共享一个FixedPool,free_head_的读改写没有任何同步—— 这是数据竞争,标准不保证任何行为,实际表现是链表断裂、块丢失或崩溃。
✅ 单线程用不加锁的池(每个线程一个thread_local池),必须共享时加std::mutex, 或直接用std::pmr::synchronized_pool_resource。
- 池对象先销毁,对象指针还在用
❌FixedPool析构后仍在访问之前allocate出来的块——那些内存已经还给系统, 访问是 UB。
✅ 池的生存期必须覆盖所有从它分配的对象;把它做成成员变量,声明在所有使用者之前。
- 过度对齐类型直接塞进池
❌ 对struct alignas(64) CacheLine { ... };用ObjectPool, 即使块大小取整到 64 的倍数,::operator new(size)也不保证 64 字节对齐。
✅ 改用 C++17 的::operator new(size, std::align_val_t(64)), 并在对应的::operator delete(ptr, std::align_val_t(64))里成对使用。
总结
| 关键点 | 结论 |
|---|---|
| 空闲链表 | 用侵入式链表,next直接存在空闲块里,零额外开销 |
| 块大小 | 下限sizeof(void*),并向上取整到alignof(std::max_align_t)的倍数 |
| 分配/释放 | 都是 O(1),无查找、无合并 |
| 对象生命周期 | placement new 构造,显式p->~T()析构,异常时要归还内存 |
| 线程安全 | 池本身不安全,共享必须加锁或用std::pmr::synchronized_pool_resource |
| 现成设施 | C++17 的std::pmr已提供池,除非有特殊需求不必手写 |
内存池的本质是用"块尺寸固定"这个约束换掉通用分配器的查找、合并和锁开销。 只要你的场景真的是同尺寸小对象高频分配,这笔交易就划算;一旦对象尺寸五花八门, 就该退回通用分配器或改用std::pmr的分档池。