实战:内存池(内存分配器+性能优化)
说实话,内存管理这个话题,很多C++开发者写了三五年代码都不一定真正碰过。但你要是做游戏引擎、高频交易、或者嵌入式实时系统,那就绕不过去了。
我当年刚接手一个网络服务器项目时,发现程序每秒钟要new/delete几万次小对象。性能监控一看,光内存分配就占了30%的CPU时间。嗯,这就是典型的「内存碎片+分配开销」问题。后来我花了三天写了个内存池,直接把那部分的延迟降到了原来的十分之一。
今天咱们就来聊聊,怎么自己动手写一个轻量级的内存池。
为什么需要内存池?
先想一个问题:为什么系统自带的malloc/new不够快?
原因其实就两点:
- 系统调用开销:每次分配都要进内核态,或者至少走一次glibc的复杂算法
- 内存碎片:频繁分配释放不同大小的块,堆会变得支离破碎
说白了,通用分配器要照顾各种场景,必然有额外开销。而我们的内存池只干一件事——固定大小对象的快速分配与回收。这就好比你去超市买一瓶水,和直接去水龙头接水,效率当然不一样。
核心设计思路
内存池的原理其实很简单:
- 一次性向系统申请一大块连续内存(比如4KB或更大)
- 把这块内存切成固定大小的槽位(slot)
- 用一个空闲链表把这些槽位串起来
- 分配时从链表头部取一个,释放时再插回去
我习惯把这种设计叫做「无锁单链表内存池」——当然,这里说的无锁是指单线程场景下不需要加锁。多线程版本我们后面再聊。
关键点:内存池只适合分配固定大小的对象。如果你需要分配不同大小的块,那就得用更复杂的伙伴系统或者slab分配器了。
代码实现
下面是我在实际项目中用过的简化版。去掉了一些边界检查和调试信息,保留了核心逻辑。
// memory_pool.h
#pragma once
#include <cstddef>
#include <cstdint>
#include <cassert>
class MemoryPool {
public:
// 构造函数:每个对象大小,每块内存包含的对象数
MemoryPool(size_t obj_size, size_t block_objs = 64)
: obj_size_(obj_size), block_objs_(block_objs) {
// 保证对象至少能存下一个指针(用于空闲链表)
if (obj_size_ < sizeof(void*)) {
obj_size_ = sizeof(void*);
}
allocate_block();
}
~MemoryPool() {
// 释放所有内存块
Block* cur = block_list_;
while (cur) {
Block* next = cur->next;
::free(cur);
cur = next;
}
}
void* allocate() {
if (!free_list_) {
allocate_block();
}
void* ptr = free_list_;
free_list_ = *reinterpret_cast<void**>(free_list_);
return ptr;
}
void deallocate(void* ptr) {
if (!ptr) return;
*reinterpret_cast<void**>(ptr) = free_list_;
free_list_ = ptr;
}
private:
struct Block {
Block* next;
// 后面紧跟数据区
};
void allocate_block() {
size_t block_size = sizeof(Block) + obj_size_ * block_objs_;
Block* block = static_cast<Block*>(::malloc(block_size));
block->next = block_list_;
block_list_ = block;
// 将新块中的所有对象串成空闲链表
char* data = reinterpret_cast<char*>(block) + sizeof(Block);
for (size_t i = 0; i < block_objs_; ++i) {
void* obj = data + i * obj_size_;
*reinterpret_cast<void**>(obj) = free_list_;
free_list_ = obj;
}
}
size_t obj_size_;
size_t block_objs_;
Block* block_list_ = nullptr;
void* free_list_ = nullptr;
};
使用提示:这个内存池不支持单个对象的构造函数/析构函数调用。如果你需要构造复杂对象,建议在allocate之后用placement new,在deallocate之前手动调用析构函数。
性能对比
我拿这个内存池和系统的malloc做了一组简单测试。分配/释放100万个8字节的对象,结果如下:
| 分配器 | 耗时(毫秒) | 内存碎片 |
|---|---|---|
| malloc/free | 约 320 ms | 高 |
| MemoryPool | 约 12 ms | 几乎为零 |
看到了吧?快了将近30倍。而且内存池分配的对象在物理上也是连续的,对CPU缓存更友好。
避坑指南
我曾经踩过一个坑:在多线程环境下直接用了这个单链表版本。结果两个线程同时allocate,free_list_被并发修改,直接导致链表断裂。程序跑着跑着就崩了,查了两天才定位到问题。
所以这里要提醒几点:
- 单线程场景:上面的代码完全够用,性能极佳
- 多线程场景:要么加锁(用std::mutex或自旋锁),要么用线程局部存储(每个线程一个内存池)
- 内存泄漏:别忘了在析构函数里释放所有Block。我见过有人只释放了空闲链表,结果整个Block链表都丢了
警告:千万不要把内存池返回的指针传给free()!那会导致堆损坏。内存池管理的所有内存,必须通过它的deallocate()归还。
内存池的工作流程
下面这张图展示了内存池的核心运作方式。我画了个简化的流程图,方便你理解:
进阶优化思路
如果你觉得上面的版本太简单,想搞点更高级的,这里有几个方向:
- 对齐优化:保证每个对象按16字节或32字节对齐,对SIMD指令友好
- 批量分配:一次返回多个连续对象,减少链表操作次数
- 缓存亲和性:尽量让同一个线程分配的对象在物理上连续
- 统计信息:记录分配次数、峰值使用量,方便调优
我个人觉得,对于大部分应用场景,上面那个基础版本已经够用了。别一上来就搞太复杂,先把核心逻辑跑通,再根据性能瓶颈逐步优化。
嗯,内存池这个话题其实还能聊很多,比如怎么和STL容器结合、怎么处理可变大小对象等等。但今天咱们先把基础打牢。你把这个代码跑一遍,再改改参数试试,很快就能体会到内存池的威力。