C++程序里最不起眼却也最影响性能的操作之一,就是小对象的分配与释放。一个网络服务器每秒可能创建销毁上百万个几十字节的消息对象,一个游戏引擎每帧都在生成大量的临时数学向量。如果你直接用new和delete,这些操作最终都会走到操作系统默认的分配器上,而默认分配器为了通用性付出了相当大的代价。本文就来聊聊小对象分配到底慢在哪里,以及如何用一个手写的内存池分配器把这块性能吃回来。

一、默认分配器到底慢在哪里
先说结论:new本身并不慢,慢的是它背后那一整套通用逻辑。系统的malloc要处理任意大小的分配请求,从几个字节到几个GB都得支持,所以它内部必须维护复杂的元数据结构,常见实现如ptmalloc、tcmalloc、jemalloc都各有自己的一套chunk管理、bin分级和线程缓存机制。每次调用malloc时,分配器要查找合适的空闲块、更新元数据,free时还要考虑相邻块的合并以减少碎片。
对大对象来说,这些开销摊薄之后可以忽略不计。但小对象就不一样了,假设一个对象只有16字节,分配器为它维护的元数据可能就有8到16字节,实际内存占用直接翻倍,这还没算对齐带来的填充。更糟的是大量小对象的申请释放会让内存碎片快速累积,页表命中率下降,缓存局部性变差,这些隐形成本比元数据开销更致命。
另一个容易被忽视的点是多线程竞争。标准malloc内部有全局锁或arena锁,多个线程同时分配内存时会在这里排队。线程数一多,锁竞争就会成为热点,你可能用perf分析半天CPU占用,最后发现相当一部分时间耗在malloc内部。
二、内存池的核心设计思想
内存池的思路很直接:既然小对象的分配模式是可预测的,那就干脆绕开通用分配器,自己管理一块大内存。具体做法分三步。第一步,一次性向系统申请一大块连续内存,比如按页或者按几KB为单位;第二步,把这块内存切成大小相同的块;第三步,用空闲链表把没用的块串起来,分配就是从链表头摘一个,释放就是挂回链表头。
这个设计的精妙之处在于把元数据藏进了空闲块本身。块在空闲状态下,它的前几个字节用来存下一个空闲块的指针;块被分配出去之后,这块内存归用户使用,指针自然就消失了。也就是说空闲链表几乎不占额外内存,分配和释放都是操作链表头,时间复杂度严格O(1),没有任何查找过程。
按块大小分级是另一个关键点。不同对象大小差异很大,把所有对象都塞进同一个尺寸的块里会浪费空间。常见做法参考tcmalloc,按8字节对齐划分若干级别,比如8、16、32、64、128字节,每次分配时找到能装下的最小级别。超过最大级别的大对象直接走系统分配器,内存池只管小对象,各司其职。
三、完整实现:固定大小内存池
先实现一个最基础也最实用的版本:固定块大小的内存池。它适合某个类被大量创建销毁的场景,比如消息对象、树节点、链表节点。整个实现不到一百行,但已经具备了生产可用性。
#include <cstddef>
#include <vector>
class FixedPool {
public:
// blockSize: 每块大小 chunkSize: 每次向系统申请的块数
explicit FixedPool(size_t blockSize, size_t chunkSize = 256)
: blockSize_(blockSize < sizeof(void*) ? sizeof(void*) : blockSize),
chunkSize_(chunkSize) {}
void* allocate() {
if (!freeList_) {
allocateChunk(); // 空闲块用完,再申请一大块
}
void* result = freeList_;
freeList_ = *static_cast<void**>(freeList_); // 摘下链表头
return result;
}
void deallocate(void* p) {
if (!p) return;
// 把释放的块挂回链表头
*static_cast<void**>(p) = freeList_;
freeList_ = p;
}
private:
void allocateChunk() {
size_t totalSize = blockSize_ * chunkSize_;
char* chunk = static_cast<char*>(::operator new(totalSize));
chunks_.push_back(chunk);
// 把这块内存切成链表串起来
for (size_t i = 0; i <chunkSize_; ++i) {
void* node = chunk + i * blockSize_;
*static_cast<void**>(node) = freeList_;
freeList_ = node;
}
}
size_t blockSize_;
size_t chunkSize_;
void* freeList_ = nullptr;
std::vector<char*> chunks_; // 记录大块,析构时统一释放
};
注意实现里的几个细节。blockSize_做了下限保护,至少要能塞下一个指针,否则空闲链表没法工作。chunks_保存了所有向系统申请的大块地址,析构时统一释放,避免内存泄漏。释放块挂回链表头而不是尾部,这样刚释放的块下次最先被复用,缓存命中率更高。
使用的时候配合placement new,就能完整替代默认的分配流程:
struct TreeNode {
int value;
TreeNode* left;
TreeNode* right;
};
FixedPool pool(sizeof(TreeNode));
TreeNode* createNode(int v) {
TreeNode* node = static_cast<TreeNode*>(pool.allocate());
new (node) TreeNode{v, nullptr, nullptr}; // placement new构造对象
return node;
}
void destroyNode(TreeNode* node) {
node->~TreeNode(); // 手动调用析构
pool.deallocate(node);
}
四、对接标准库:写一个自己的allocator
手写分配函数虽然直接,但更优雅的方式是封装成标准库认识的分配器,这样所有STL容器都能直接受益。C++11之后allocator的接口非常精简,只需要实现几个类型定义和两个函数。
template <typename T>
class PoolAllocator {
public:
using value_type = T;
PoolAllocator() = default;
template <typename U>
PoolAllocator(const PoolAllocator<U>>&) noexcept {}
T* allocate(size_t n) {
if (n == 1) {
// 单对象走内存池
return static_cast<T*>(pool().allocate());
}
// 批量分配退回系统分配器
return static_cast<T*>(::operator new(n * sizeof(T)));
}
void deallocate(T* p, size_t n) noexcept {
if (n == 1) {
pool().deallocate(p);
} else {
::operator delete(p);
}
}
// 按类型维护独立的内存池实例
static FixedPool& pool() {
static FixedPool instance(sizeof(T));
return instance;
}
};
template <typename T, typename U>
bool operator==(const PoolAllocator<T>&, const PoolAllocator<U>&) noexcept {
return true;
}
template <typename T, typename U>
bool operator!=(const PoolAllocator<T>& a, const PoolAllocator<U>& b) noexcept {
return !(a == b);
}
// 使用示例
std::list<TreeNode, PoolAllocator<TreeNode>> tree;
这里有个实用的技巧:pool()函数内用函数级静态变量,每种类型T自动拥有一个独立的内存池,块大小天然匹配对象大小,不需要额外配置。批量分配的场景(比如vector扩容时一次申请多块)直接退回系统分配器,避免复杂化池的设计。
五、进阶优化与常见陷阱
固定大小池解决不了混合大小对象的问题,这时候可以升级为分级内存池:内部维护多个固定池,每个池负责一个尺寸级别,分配时按请求大小路由到对应池。路由逻辑就是简单的位运算或查表,开销可以忽略。这个结构再往下走,就是tcmalloc的核心思想,可见工业级分配器并不是遥不可及的黑魔法。
多线程方面有两条路。简单做法是给池加一把互斥锁,因为临界区极短只是摘链表头,锁竞争通常可接受。追求极致性能就上线程本地缓存:每个线程持有自己的小池子,分配释放完全无锁,只有本地池子耗尽或溢出时才去全局池搬运一批块,这正是tcmalloc的ThreadCache机制。C++11的thread_local关键字让这个实现变得非常简单。
最后提醒几个常见陷阱。第一,内存池析构前必须确保所有从池里分配的对象都已销毁,否则会访问已释放的内存,可以在调试版本里加计数器检查。第二,从池里分配的内存绝不能传给普通的free或delete,反过来系统分配的内存也不能还给池,建议用类型系统(比如不同的指针类型或RAII包装)从源头隔离。第三,不要过早优化,先用性能分析工具确认malloc真的是瓶颈再动手,否则一个简单问题引入内存池反而增加了维护负担。
总结一下,内存池的本质是用分配模式的确定性换性能:预分配摊薄系统调用成本,空闲链表消灭查找开销,尺寸分级控制内存浪费。掌握这套思路之后,无论你是想优化现有项目,还是想读懂tcmalloc的源码,都会顺畅很多。
C++内存池小对象分配memory pool修改时间:2026-09-14 01:54:56