一、为什么需要内存池?
常规的new/delete操作存在两个主要问题:
性能开销大:每次new都需要向操作系统申请内存,delete需要归还给系统,这涉及内核态与用户态的切换,在高频次调用时性能损耗明显。
内存碎片:频繁分配和释放大小不一的内存块,会导致内存空间被分割成大量不连续的小块,形成碎片,降低内存利用率。
内存池的核心思想是 “预分配、再利用”:提前向系统申请一块大块内存,然后由内存池自主管理这块内存的分配与回收,避免频繁与操作系统交互,同时减少碎片。
二、定长内存池的设计思路
定长内存池专门用于管理固定大小的对象(如链表节点、树节点等),其设计围绕两个核心组件:
- 内存块预分配:一次性申请大块内存,避免频繁系统调用。
- 自由链表(Free List):维护已释放的对象内存块,实现内存复用。
具体工作流程:
- 当需要分配对象时,先检查自由链表是否有可用内存块,若有则直接复用;
- 若自由链表为空,则从预分配的大块内存中分割出一块新内存;
- 当释放对象时,不直接归还给系统,而是将其加入自由链表,等待下次复用。
三、定长内存池的代码实现
以下是一个基于模板的定长内存池实现,支持任意类型的对象管理:
#include <iostream>
#include <vector>
#include <time.h>
using std::cout;
using std::endl;#ifdef _WIN32
#include<windows.h>
#else
//
#endif// 跨平台内存分配(Windows/Linux)
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32// Windows使用VirtualAlloc分配内存(按页分配,1页=4KB)void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else// Linux可使用brk或mmapvoid* ptr = mmap(nullptr, kpage * 4096, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
#endifif (ptr == nullptr)throw std::bad_alloc();return ptr;
}template<class T>
class ObjectPool
{
public:// 分配对象T* New(){T* obj = nullptr;// 1. 优先从自由链表获取内存if (_freeList){void* next = *((void**)_freeList); // 取自由链表下一个节点obj = (T*)_freeList;_freeList = next; // 移动自由链表头}else{// 2. 自由链表为空,从预分配内存中分割if (_remainBytes < sizeof(T)){// 预分配128KB内存(可根据需求调整)_remainBytes = 128 * 1024;_memory = (char*)SystemAlloc(_remainBytes >> 13); // 按页计算(128KB=32页)if (_memory == nullptr){throw std::bad_alloc();}}// 分割内存(若对象大小小于指针,按指针大小分配,保证链表节点能存储地址)obj = (T*)_memory;size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_memory += objSize;_remainBytes -= objSize;}// 3. 调用对象构造函数(placement new)new(obj)T;return obj;}// 释放对象void Delete(T* obj){// 1. 调用对象析构函数obj->~T();// 2. 将内存块加入自由链表(头插法)*(void**)obj = _freeList; // 存储下一个自由节点地址_freeList = obj; // 更新自由链表头}private:char* _memory = nullptr; // 指向当前未分配的大块内存size_t _remainBytes = 0; // 大块内存中剩余的字节数void* _freeList = nullptr; // 自由链表头(存储可复用的内存块)
};
四、核心细节解析
1 SystemAlloc
// 跨平台内存分配(Windows/Linux)
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32// Windows使用VirtualAlloc分配内存(按页分配,1页=4KB)void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else// Linux可使用brk或mmapvoid* ptr = mmap(nullptr, kpage * 4096, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0);
#endifif (ptr == nullptr)throw std::bad_alloc();return ptr;
}
这段代码是一个跨平台的内存分配函数,用于向操作系统直接申请内存(绕过 C 标准库的malloc),是定长内存池实现中 “预分配大块内存” 的底层支持
2 New()
T* New(){T* obj = nullptr;// 1. 优先从自由链表获取内存if (_freeList){void* next = *((void**)_freeList); // 取自由链表下一个节点obj = (T*)_freeList;_freeList = next; // 移动自由链表头}else{// 2. 自由链表为空,从预分配内存中分割if (_remainBytes < sizeof(T)){// 预分配128KB内存(可根据需求调整)_remainBytes = 128 * 1024;_memory = (char*)SystemAlloc(_remainBytes >> 13); // 按页计算(128KB=32页)if (_memory == nullptr){throw std::bad_alloc();}}// 分割内存(若对象大小小于指针,按指针大小分配,保证链表节点能存储地址)obj = (T*)_memory;size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_memory += objSize;_remainBytes -= objSize;}// 3. 调用对象构造函数(placement new)new(obj)T;return obj;}
这段 T* New() 函数是定长内存池的核心分配逻辑,负责高效地为 T 类型对象分配内存。它的设计巧妙之处在于优先复用已释放的内存(通过自由链表),仅在必要时从预分配的大块内存中分割新空间,从而避免频繁的系统调用。下面逐行解析其核心逻辑:
函数的核心目标是:用尽可能低的开销(避免系统调用)为 T 类型对象分配一块内存,并调用其构造函数。
流程分为三步:
- 优先从「自由链表」中获取可复用的内存块;
- 若自由链表为空,从预分配的大块内存中分割新内存;
- 在分配的内存上调用 T 的构造函数
if (_freeList)
{void* next = *((void**)_freeList); // 取自由链表下一个节点obj = (T*)_freeList;_freeList = next; // 移动自由链表头
}
自由链表(_freeList):本质是一个单链表,存储所有已释放的内存块(这些内存块曾用于存储 T 类型对象,现在可复用)。
操作逻辑:
从链表头取一个节点(_freeList)作为当前要分配的 obj,然后将链表头更新为下一个节点(_freeList = next),完成复用。自由链表为空时,从预分配内存中分割
((void**)_freeList)
- 直接利用obj自身的内存空间的前几个字节,
- 存储 “下一个空闲对象的地址”(即链表的 “指针域”)。
为什么用 void** 转换?
- void* 不能被解引用(无法直接访问它指向的内存内容),而且不能直接给void* 指向的内存赋值(因为编译器不知道这块内存的类型和大小)。
- 自由链表的每个节点(内存块)需要存储「下一个节点的地址」(才能形成链表)。因此,每个内存块的起始位置会被当作一个 void* 指针,用于存放链表的「下一个节点地址」。
- (void**)_freeList 表示:将当前内存块的起始地址看作一个 “指向指针的指针”,通过 ((void*)_freeList) 即可取出下一个节点的地址。
else
{// 若剩余内存不足,重新申请大块内存if (_remainBytes < sizeof(T)){_remainBytes = 128 * 1024;//_memory = (char*)malloc(_remainBytes);_memory = (char*)SystemAlloc(_remainBytes >> 13);//等价于 _remainBytes / 8192(因为1页=8KB=2^13字节)//计算结果为16(128KB / 8KB = 16页),即申请16页内存if (_memory == nullptr){throw std::bad_alloc();}}// 分割内存块obj = (T*)_memory;// 若对象大小 < 指针大小,按指针大小分配(保证能存下链表节点地址)size_t objSize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);_memory += objSize; // 移动内存指针到下一个可用位置_remainBytes -= objSize; // 减少剩余内存计数
}
if里很好理解,都是为了申请内存
分割内存的细节:
为什么要比较 sizeof(T) 和 sizeof(void*)?
因为当内存块被释放时,需要作为自由链表的节点存储「下一个节点的地址」(一个指针)。如果 T 类型的大小小于指针大小(例如 T 是 char),直接用 sizeof(T) 分配内存会导致存储指针时越界。因此,强制按「指针大小」分配,确保内存块有足够空间存储链表节点信息。
分割后,_memory 指针向后移动 objSize 字节,_remainBytes 减去相应大小,记录剩余可用内存。
//调用构造函数
new(obj)T;
普通 new 会做两件事:1. 分配内存;2. 调用构造函数。
这里用 placement new(定位 new),仅在已分配的内存(obj 指向的地址)上调用 T 的构造函数,完成对象初始化。
这是内存池的关键:内存分配由池管理,构造函数仅负责对象的逻辑初始化。
可以理解上述所有操作都是在给这一步做准备
3 Delete()
// 释放对象void Delete(T* obj){// 1. 调用对象析构函数obj->~T();// 2. 将内存块加入自由链表(头插法)*(void**)obj = _freeList; // 存储下一个自由节点地址_freeList = obj; // 更新自由链表头}
这段 Delete 函数是定长内存池释放对象的核心逻辑,负责将不再使用的对象内存回收至 “自由链表”(供下次复用),同时确保对象资源被正确清理。
obj->~T();
显式调用析构函数,如果上述的构造函数是最后准备的结果,那现在这个析构就是准备的第一步
// 头插:将当前内存块作为新的链表头
*(void**)obj = _freeList; // 步骤1:当前块存储下一个空闲块的地址
_freeList = obj; // 步骤2:更新链表头为当前块
上面讲解过了为什么要*(void**),而这里原理一样,是为了将删除的空内存作为_freeList的头节点
4 测试
#include "objectpool.hpp"void TestObjectPool()
{// 申请释放的轮次const size_t Rounds = 5;// 每轮申请释放多少次const size_t N = 100000;std::vector<TreeNode*> v1;v1.reserve(N);size_t begin1 = clock();for (size_t j = 0; j < Rounds; ++j){for (int i = 0; i < N; ++i){v1.push_back(new TreeNode);}for (int i = 0; i < N; ++i){delete v1[i];}v1.clear();}size_t end1 = clock();std::vector<TreeNode*> v2;v2.reserve(N);ObjectPool<TreeNode> TNPool;size_t begin2 = clock();for (size_t j = 0; j < Rounds; ++j){for (int i = 0; i < N; ++i){v2.push_back(TNPool.New());}for (int i = 0; i < N; ++i){TNPool.Delete(v2[i]);}v2.clear();}size_t end2 = clock();cout << "new cost time:" << end1 - begin1 << endl;cout << "object pool cost time:" << end2 - begin2 << endl;
}int main() {TestObjectPool();return 0;
}
如果把释放次数加0变成1000000,两者差别更明显:
五、定长内存池与普通new的区别
对比维度 | 普通 new/delete | 定长内存池 |
---|---|---|
内存来源 | 每次分配直接向系统申请,释放直接归还给系统 | 预先向系统申请大块内存,后续分配从该块中分割 |
内存复用机制 | 无主动复用逻辑,依赖系统内存管理器的优化 | 通过自由链表记录释放的内存块,下次分配优先复用 |
支持的对象大小 | 任意大小(按需分配) | 仅支持固定大小(针对特定类型 T) |
分配 / 释放效率 | 低。每次操作可能涉及系统调用(用户态→内核态切换)和复杂的内存块搜索 | 高。分配 / 释放均为 O (1) 的链表操作,仅在内存不足时触发一次系统调用 |
内存碎片 | 严重。频繁分配 / 释放不同大小内存会产生大量细碎空闲块 | 几乎无碎片。所有内存块大小统一,释放后可被同类型对象复用 |
适用场景 | 低频次、大小不固定的内存分配(如偶尔创建大对象) | 高频次、同类型小对象的分配 / 释放(如链表节点、树节点) |
额外内存开销 | 有(系统需维护内存块元信息,如大小、边界标记) | 几乎无(利用对象自身内存存储自由链表指针) |