前言
对象池(object pool)要解决的问题是:某类对象会被频繁创建和销毁,而每次new/delete都要进入分配器、可能触发系统调用、还可能造成堆碎片。对象池预先申请一批槽位,创建时复用空闲槽,销毁时不真正归还内存,而是把槽位放回空闲表。所谓「自动回收」,指的是用 RAII 句柄包装对象:句柄析构时自动调用对象析构函数并把槽位还给池,调用方不需要手写release。
常见的误解有三个。第一,认为「用了对象池一定更快」。池省掉的是分配器开销,但如果对象构造本身很重,或者池的锁竞争激烈,收益可能被抵消,必须结合具体场景测量。第二,认为「池管理的对象自然线程安全」。池最多保证「取还槽位」这个动作互斥,对象内部的数据竞争仍要自己处理。第三,认为「池里的对象可以随便memcpy」。池用 placement new 构造对象,槽位搬移会让指向对象的指针失效,也可能破坏非平凡类型的内部状态。
本文以 C++17 为准,实现一个固定类型、可自动归还、带互斥保护的对象池,并逐条说明对齐、析构、生命周期和扩容这四个容易出错的点。
一、对象池解决什么,不解决什么
一次new T通常包含:向分配器请求内存、对齐、调用构造函数;delete则调用析构、归还内存。通用分配器要兼顾各种大小和线程场景,开销不可避免。对象池把「申请内存」这一步批量做掉,运行期只做「从空闲表取一个下标 + placement new」。
| 方案 | 分配开销 | 释放开销 | 碎片风险 | 适用 |
|---|
new/delete | 通用分配器 | 通用分配器 | 有 | 通用 |
| 对象池 | 取空闲下标 | 析构 + 还下标 | 低(固定块) | 同类型、高频短生命周期 |
std::pmr池资源 | 资源内部管理 | 资源统一释放 | 取决于资源 | 批量、同生命周期 |
对象池不解决「对象太大」「对象生命周期差异极大」「需要不同大小混合分配」的问题。它最适合的是「同一类型、数量可控、频繁创建销毁」的场景,例如粒子、连接上下文、临时消息节点。
二、槽位布局与空闲表
池的存储是一组槽位。每个槽位提供「足够大且对齐正确」的原始字节,对象通过 placement new 构造在这块字节上:
// 适用于 C++17
struct Slot {
alignas(T) unsigned char storage[sizeof(T)];
bool inUse = false;
};alignas(T)保证槽位起始地址满足T的对齐要求,sizeof(T)保证空间足够。这两点缺一不可:只给sizeof(T)而不给对齐,在某些平台上构造出地址未对齐的对象是未定义行为(undefined behavior, UB)。
空闲表用std::vector<std::size_t>存下标。取对象时从尾部弹出一个下标,还对象时把下标压回尾部。用下标而不是指针,是因为槽位容器可能扩容,指针会失效;下标始终稳定。
槽位容器选择std::deque<Slot>而不是std::vector<Slot>,这是关键决策。std::vector扩容时会把已有元素搬到新内存,而槽位里可能正躺着活跃对象,按字节搬移非平凡类型的对象是 UB,指向它们的指针也会失效。std::deque的push_back不会使已有元素的引用失效,正好满足需求。
三、RAII 自动归还
池返回的不是裸指针,而是带自定义删除器的std::unique_ptr:
// 适用于 C++17
using Handle = std::unique_ptr<T, Deleter>;删除器保存池指针和槽位下标。Handle析构时,unique_ptr调用删除器,删除器转调池的release:先执行p->~T(),再把inUse置假、下标压回空闲表。这样即使中途抛异常,栈展开也会触发Handle析构,槽位不会泄漏。
删除器为什么不用std::function?std::function有类型擦除和可能的堆分配,体积也大;一个只含两个字段的小型可调用对象更轻。删除器必须可拷贝构造(unique_ptr的要求之一),这里两个指针/下标字段天然可拷贝。
四、完整实现与生命周期细节
// 适用于 C++17
#include <cstddef>
#include <deque>
#include <memory>
#include <mutex>
#include <new>
#include <utility>
#include <vector>
template <typename T>
class ObjectPool {
struct Slot {
alignas(T) unsigned char storage[sizeof(T)];
bool inUse = false;
};
public:
class Deleter {
public:
Deleter() = default;
Deleter(ObjectPool* pool, std::size_t index) : pool_(pool), index_(index) {}
void operator()(T* p) const noexcept {
if (pool_ != nullptr) {
pool_->release(index_, p);
}
}
private:
ObjectPool* pool_ = nullptr;
std::size_t index_ = 0;
};
using Handle = std::unique_ptr<T, Deleter>;
explicit ObjectPool(std::size_t initial = 8) {
grow(initial);
}
~ObjectPool() {
for (Slot& slot : slots_) {
if (slot.inUse) {
reinterpret_cast<T*>(slot.storage)->~T();
slot.inUse = false;
}
}
}
ObjectPool(const ObjectPool&) = delete;
ObjectPool& operator=(const ObjectPool&) = delete;
template <typename... Args>
Handle acquire(Args&&... args) {
std::lock_guard<std::mutex> lock(mutex_);
std::size_t index = 0;
if (!free_.empty()) {
index = free_.back();
free_.pop_back();
} else {
index = slots_.size();
grow(1); // 已持锁,grow 内部不再加锁
}
Slot& slot = slots_[index];
try {
::new (static_cast<void*>(slot.storage)) T(std::forward<Args>(args)...);
} catch (...) {
free_.push_back(index); // 构造失败,归还槽位
throw;
}
slot.inUse = true;
return Handle(reinterpret_cast<T*>(slot.storage), Deleter(this, index));
}
private:
void release(std::size_t index, T* p) noexcept {
std::lock_guard<std::mutex> lock(mutex_);
p->~T();
slots_[index].inUse = false;
free_.push_back(index);
}
void grow(std::size_t count) {
for (std::size_t i = 0; i < count; ++i) {
slots_.emplace_back();
free_.push_back(slots_.size() - 1);
}
}
std::deque<Slot> slots_;
std::vector<std::size_t> free_;
std::mutex mutex_;
};使用时:
// 适用于 C++17
struct Particle {
float x = 0.0f;
float y = 0.0f;
explicit Particle(float x_, float y_) : x(x_), y(y_) {}
};
void demo() {
ObjectPool<Particle> pool(4);
auto p = pool.acquire(1.0f, 2.0f); // 槽位不够时自动扩容
p->x += 1.0f;
// p 离开作用域:~Particle() 执行,槽位回到空闲表
}逐段核对。acquire先从空闲表取下标;空闲表为空时,用slots_.size()作为新槽位下标,再grow(1)追加。grow只做emplace_back和记录下标,不构造T。构造T放在锁内,保证槽位的inUse标记与对象状态一致。构造抛异常时把下标放回空闲表,避免槽位永久丢失。release先析构再标记空闲,顺序不能反:若先标记空闲,另一个线程可能立刻取走槽位并覆盖对象,随后析构就会作用在已被覆盖的内存上。
生命周期、对齐与线程安全
生命周期:池必须比所有Handle活得久。若池先析构,Handle析构时通过删除器访问已销毁的池对象,是 UB。工程上可以用std::shared_ptr<ObjectPool>配合删除器持有weak_ptr,或者明确规定「池是进程级单例」。析构函数里对仍处于inUse的对象调用析构,只是兜底,不能替代「先销毁句柄」的约束。
对齐:alignas(T)已处理槽位对齐。但若T含有过度对齐成员(例如alignas(64)的缓存行字段),Slot的整体对齐会随之提高,std::deque的分配器需要尊重这一点;标准库的std::allocator在 C++17 起支持过度对齐类型,但具体实现仍需以工具链文档为准。更稳妥的做法是把T的对齐要求显式传给槽位类型。
线程安全:acquire和release共用一把std::mutex,取还槽位是线程安全的。但两个线程拿到不同对象后,对各自对象的访问不需要锁;若它们共享同一个对象,仍要自行同步。池不是「对象内容的保护者」。
容量:池只增不减。若某次峰值创建了大量对象,槽位会一直保留。需要回收内存时,应在业务低峰期整体重建池,或改用std::pmr::unsynchronized_pool_resource这类标准设施,由资源统一释放。
常见坑点
1. 用std::vector<Slot>存活跃对象
❌std::vector<Slot> slots_;,扩容时按字节搬移正在使用的对象。 ✅ 用std::deque<Slot>或预先分配固定容量,保证槽位地址稳定。
2. 池比句柄先销毁
❌ 池是局部对象,Handle被存到全局容器里,池析构后句柄才释放。 ✅ 让池的生命周期覆盖所有句柄,或用shared_ptr管理池。
3. 归还时忘记调用析构
❌release里只把inUse置假,不执行p->~T(),持有资源的对象泄漏。 ✅ 先p->~T(),再标记空闲并归还下标。
4. 槽位没有对齐
❌ 用char buffer[sizeof(T)]直接 placement new,地址可能不满足alignof(T)。 ✅ 用alignas(T) unsigned char storage[sizeof(T)]。
5. 以为池保证对象线程安全
❌ 两个线程通过池各取一个对象,却同时读写同一个共享字段。 ✅ 池只保护槽位分配;对象内部共享状态仍需互斥量或原子操作。
6. 手动 release 后又让智能指针释放
❌pool.release(idx, p);之后unique_ptr仍持有p,析构时二次归还。 ✅ 只用Handle管理生命周期,不手动调用release。
7. 构造异常导致槽位丢失
❌ 从空闲表弹出下标后直接构造,构造函数抛异常,下标既没归还也没标记。 ✅ 用 try/catch 捕获,构造失败时把下标压回空闲表。
8. 池无限增长
❌ 峰值创建十万个对象后,池永久占着十万个槽位。 ✅ 设定容量上限或空闲超时回收,低峰期整体重建。
总结
| 组成 | 作用 | 关键约束 |
|---|
Slot | 原始存储 + 使用标记 | alignas(T)、sizeof(T) |
std::deque | 槽位容器 | 元素地址稳定 |
| 空闲表 | 复用下标 | 只存下标,不存指针 |
| placement new | 在槽位上构造 | 需要<new> |
| 删除器 | 自动归还 | 池必须比句柄活得久 |
| 互斥量 | 保护取还 | 不保护对象内容 |
对象池的「自动回收」并不神秘,本质是把「析构 + 归还」挂到 RAII 句柄的析构函数上。真正决定它是否可靠的是四个细节:槽位地址必须稳定、对齐必须正确、析构必须先于标记空闲、池必须比句柄活得久。把这四点写进注释和类型设计里,比事后排查悬垂指针和双重析构省力得多。