文章目录
- C++ | 定长内存池 | 对象池
- 一、内存池的引入
- 二、代码中的内存池实现 - `ObjectPool`类
- (一)整体结构
- (二)内存分配 - `New`函数
- (三)内存回收 - `Delete`函数
- 三、内存池在`TreeNode`示例中的性能测试演示
- 四、脱离malloc,直接在堆上按页申请空间
- 五、总结
- (一)内存池的优势
- (二)代码的思考
C++ | 定长内存池 | 对象池
在C++编程的世界里,内存管理是一个至关重要的话题。今天,我们就来深入研究一下基于下面这段代码的内存池概念。
先上代码:
#include<iostream>
#include<vector>
#include <time.h>
using std::cout;
using std::endl;
//定长内存池,一次申请N大小的空间
//template<size_t N>
//class ObjectPool {
//};
template<class T>
class ObjectPool {
public:
T* New() {
T* obj = nullptr;
if (_freelist)
//_freelist非空,说明有还回来的内存,优先使用还回来的内存
//可以使用类似无头结点链表的头删
{
void* next = *(void**)_freelist;
obj = (T*)_freelist;
_freelist = next;
}
else
{
if (_remainbytes < sizeof(T)) {
_remainbytes = 128 * 1024;
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr) {
throw std::bad_alloc();
}
}
obj = (T*)_memory; //申请出去的新的空间的起始位置
//_memory += sizeof(T); //新的空间被申请后,剩余空间的起始位置
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize;
_remainbytes -= sizeof(T); //剩余的空间
}
//定位new,显式调用T的构造函数初始化
new(obj)T;
return obj;
}
void Delete(T* obj) {
//显式调用析构函数清理对象
obj->~T();
//就相当于无头结点链表的头插
*(void**)obj = _freelist;
_freelist = *(void**)obj;
}
private:
//void* _memory;
//为了方便地址上的加减运算,使用char类型,char类型占一个字节
char* _memory = nullptr; //指向大块内存的指针
void* _freelist = nullptr; //指向待释放的空间
size_t _remainbytes = 0; // 大块内存在切分过程中剩余字节数
};
struct TreeNode
//二叉树节点
{
int _val;
TreeNode* _left;
TreeNode* _right;
TreeNode()
:_val(0)
, _left(nullptr)
, _right(nullptr)
{}
};
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;
}
一、内存池的引入
在很多C++程序中,频繁地进行内存的分配和释放(例如使用new
和delete
操作符)可能会带来性能上的开销,尤其是在处理大量小对象时。内存池(Memory Pool)技术应运而生,它就像是一个预先准备好的内存资源仓库,能够更高效地管理内存的分配和回收。
二、代码中的内存池实现 - ObjectPool
类
(一)整体结构
我们来看代码中的ObjectPool
类,这是一个模板类(template<class T>
),这意味着它可以为不同类型的对象提供内存池服务。
template<class T>
class ObjectPool {
public:
T* New();
void Delete(T* obj);
private:
//void* _memory;
//为了方便地址上的加减运算,使用char类型,char类型占一个字节
char* _memory = nullptr; //指向大块内存的指针
void* _freelist = nullptr; //指向待释放的空间
size_t _remainbytes = 0; // 大块内存在切分过程中剩余字节数
};
- 成员变量
_memory
:这是一个char*
类型的指针,初始化为nullptr
。它的作用是指向大块内存。选择char*
类型是为了方便进行地址的加减运算,因为char
类型在内存中占用一个字节。这个大块内存就是我们内存池的核心资源,用来存储对象。_freelist
:类型为void*
,初始值是nullptr
。它就像是一个管理空闲内存块的列表,当对象被释放后,相关的内存块会被添加到这个列表中,以便后续的复用。_remainbytes
:这是一个size_t
类型的变量,初始化为0。它记录了大块内存在切分过程中剩余的字节数。这个变量对于判断何时需要重新申请大块内存非常关键。
(二)内存分配 - New
函数
T* ObjectPool::New() {
T* obj = nullptr;
if (_freelist)
//_freelist非空,说明有还回来的内存,优先使用还回来的内存
//可以使用类似无头结点链表的头删
{
void* next = *(void**)_freelist;
obj = (T*)_freelist;
_freelist = next;
}
else
{
if (_remainbytes < sizeof(T)) {
_remainbytes = 128 * 1024;
/*如果空间无法被T整除,可能会出现会有一块内存不够一个T对象的空间,
所以会导致_remainBytes的结果非零,而空间又不够用了,
因此if()中的判定应该是_remainBytes < sizeof(T),
即剩余内存不够一个对象大小时,则重开大块空间,以避免越界访问*/
_memory = (char*)malloc(128 * 1024);
if (_memory == nullptr) {
throw std::bad_alloc();
}
}
obj = (T*)_memory; //申请出去的新的空间的起始位置
//_memory += sizeof(T); //新的空间被申请后,剩余空间的起始位置
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize;
/*由于指针变量的大小是4个字节(32位)或者8个字节(64位),而回收内存的时候是通过
链表回收的,需要存储加一个节点的地址,因此如果当T类型的类型大小小于指针大小的时候
无法存储一个地址,例如32位下指针大小4个字节,而char类型1个字节,这时候回收链表中
只有一个字节的大小的话显然无法存放一个地址,因此需要作比较,如果类型大小小于指针变量
类型大小,则返回一个指针变量的大小。*/
_remainbytes -= sizeof(T); //剩余的空间
}
//定位new,显式调用T的构造函数初始化
new(obj)T;
return obj;
}
- 优先使用空闲内存
- 当调用
New
函数来获取一个对象时,首先会检查_freelist
是否为空(if (_freelist)
)。如果_freelist
不为空,说明有已经回收但还未重新分配的内存。这时候就会采用类似无头结点链表的头删操作来获取内存块。具体来说,先获取_freelist
指向的内存块(通过void* next = *(void**)_freelist;
),然后将这个内存块作为新对象的地址(obj = (T*)_freelist;
),最后更新_freelist
指向该内存块中的下一个空闲内存的指针(_freelist = next;
)。
- 当调用
- 从大块内存分配
- 如果
_freelist
为空,就需要从大块内存_memory
中分配空间。在分配之前,会检查_remainbytes
是否小于sizeof(T)
。如果是,就意味着当前大块内存剩余空间不足以分配一个T
类型的对象了。这时候,会重新申请一块128 * 1024字节的内存(_remainbytes = 128 * 1024; _memory = (char*)malloc(128 * 1024);
),并且在申请失败时抛出std::bad_alloc
异常。 - 在分配内存时,还有一个很巧妙的地方。由于在回收内存时是通过链表来管理的,需要存储下一个节点的地址,而不同机器上指针类型的大小可能不同(32位机器上为4字节,64位机器上为8字节)。所以在计算分配的字节数时,会比较
sizeof(T)
和sizeof(void*)
的大小(size_t objsize = sizeof(T) < sizeof(void*)? sizeof(void*) : sizeof(T);
),取较大值来确保能够正确存储下一个空闲对象的指针。然后将新对象的地址赋给obj
,并更新_memory
和_remainbytes
(obj = (T*)_memory; _memory += objsize; _remainbytes -= sizeof(T);
)。 - 最后,通过定位
new
(new(obj)T;
)显式调用T
的构造函数来初始化这个新对象。
- 如果
(三)内存回收 - Delete
函数
void ObjectPool::Delete(T* obj) {
//显式调用析构函数清理对象
obj->~T();
//就相当于无头结点链表的头插
*(void**)obj = _freelist;
_freelist = *(void**)obj;
/*之所以使用void类型来强制转换,只因为在不同位机器下,地址的字节位数不同
比如在32位下,指针类型的大小是四个字节,而在64位下,指针类型的大小是八个字节
因此如果想要通过二级指针来修改obj的指向,同时兼顾不同机器,使用void类型
来进行强制类型转换。*/
}
- 清理对象资源
- 当调用
Delete
函数来释放对象时,首先会显式调用对象的析构函数(obj->~T();
),这一步确保对象内部的资源被正确清理。
- 当调用
- 归还内存到空闲列表
- 然后,将释放的内存块添加到
_freelist
的头部,这一操作类似于无头结点链表的头插操作。通过*(void**)obj = _freelist; _freelist = *(void**)obj;
来实现。这里使用void*
类型进行强制转换是为了在不同位机器下(地址字节位数不同)能够正确地通过二级指针修改obj
的指向,从而将对象的内存块添加到空闲内存列表中。
- 然后,将释放的内存块添加到
三、内存池在TreeNode
示例中的性能测试演示
struct TreeNode
//二叉树节点
{
int _val;
TreeNode* _left;
TreeNode* _right;
TreeNode()
:_val(0)
, _left(nullptr)
, _right(nullptr)
{}
};
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;
}
-
debug下运行效果:
-
release下运行效果:
可以看到使用对象池的程序运行效率会高不少
- 普通的
new
/delete
操作- 在
TestObjectPool
函数中,首先对普通的new
和delete
操作进行了测试。创建了一个std::vector<TreeNode*>
类型的v1
,并预留了N
个元素的空间(v1.reserve(N);
)。 - 然后通过两层循环,外层循环
Rounds
次,内层循环N
次。在每次内层循环中,使用new
创建一个TreeNode
对象并添加到v1
中(v1.push_back(new TreeNode);
),然后再使用delete
释放这些对象(delete v1[i];
),最后清空v1
向量(v1.clear();
)。通过记录这个过程开始的时间(size_t begin1 = clock();
)和结束的时间(size_t end1 = clock();
),就可以计算出使用普通new
/delete
操作的耗时。
- 在
- 使用内存池的操作
- 接着,创建了一个
ObjectPool<TreeNode>
类型的对象池TNPool
,同样创建了一个std::vector<TreeNode*>
类型的v2
并预留N
个元素的空间。 - 也是通过类似的两层循环,在每次内层循环中,使用对象池的
New
函数获取TreeNode
对象并添加到v2
中(v2.push_back(TNPool.New());
),然后使用对象池的Delete
函数释放对象(TNPool.Delete(v2[i]);
),最后清空v2
向量。同样记录这个过程开始的时间(size_t begin2 = clock();
)和结束的时间(size_t end2 = clock();
),从而得到使用内存池操作的耗时。 - 最后,通过
cout
输出两种方式的耗时(cout << "new cost time:" << end1 - begin1 << endl; cout << "object pool cost time:" << end2 - begin2 << endl;
)。从这个结果可以直观地看到在频繁创建和销毁TreeNode
对象的场景下,使用内存池能够带来明显的性能提升。
- 接着,创建了一个
四、脱离malloc,直接在堆上按页申请空间
#ifdef _WIN32
#include<windows.h>
#else
//
#endif
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
预处理:
#ifdef
指令#ifdef
是C和C++中的预处理指令。它用于条件编译,在这里的含义是“如果定义了(ifdef
是if defined
的缩写)符号_WIN32
”。- 在不同的编译环境下,可能会预定义一些特定的符号。在Windows环境下编译时,通常会预定义
_WIN32
这个符号(在64位Windows下可能还会预定义_WIN64
,但这里只关注_WIN32
相关的逻辑)。
- 包含头文件
<windows.h>
- 当
_WIN32
被定义时(即代码在Windows环境下编译),#include <windows.h>
会被执行。<windows.h>
是一个非常重要的Windows平台的头文件,它包含了大量Windows系统相关的函数声明、数据类型定义、宏定义等内容。例如,Windows下的图形界面编程(使用Windows API)、系统调用、进程和线程相关的操作等很多功能都需要这个头文件中的定义来支持。
- 当
#else
和#endif
#else
是与#ifdef
配合使用的预处理指令,表示“否则”的情况。在这里,如果_WIN32
没有被定义(即代码不是在Windows环境下编译),那么会执行#else
后面的内容。不过在这段代码中,#else
后面只是一个注释(//
),没有实际的代码,可能是预留的用于在非Windows环境下(如Linux、macOS等)添加相关代码的地方。#endif
是#ifdef - #else
结构的结束标志,表示条件编译块的结束。如果没有#endif
,编译器会报错,因为它不知道条件编译块在哪里结束。
SystemAlloc函数:
- 函数声明部分
inline static void* SystemAlloc(size_t kpage)
inline
关键字:这是一个C++ 中的关键字,用于建议编译器将函数内联化。内联函数的主要目的是减少函数调用的开销。当函数被标记为内联时,编译器在编译时会尝试将函数体直接嵌入到调用该函数的地方,而不是像普通函数调用那样进行压栈、跳转等操作。static
关键字:在这里表示函数具有内部链接性,即这个函数只能在当前的源文件中被访问,不能被其他源文件访问。void*
:表示函数的返回类型是一个通用指针(指向未知类型数据的指针)。SystemAlloc
:函数名。size_t kpage
:函数接受一个size_t
类型(无符号整数类型,通常用于表示对象的大小或数组的长度等)的参数kpage
。
#ifdef _WIN32
条件编译部分(Windows平台下的实现)void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
VirtualAlloc
是Windows系统中的一个函数,用于在进程的虚拟地址空间中分配内存。- 第一个参数
0
:表示让系统自动选择分配内存的起始地址。 - 第二个参数
kpage << 13
:这里使用了左移运算符<<
。kpage
是传入的参数,表示某种数量(可能是页数之类的概念),左移13位相当于将kpage
乘以2^13
(即8192)。这意味着以页为单位计算要分配的内存大小(在Windows中,内存页大小通常是4KB或8KB,这里假设是8KB,那么kpage
就表示要分配的页数)。 - 第三个参数
MEM_COMMIT | MEM_RESERVE
:这是内存分配类型的标志组合。MEM_COMMIT
表示提交内存,即将物理存储映射到进程的虚拟地址空间;MEM_RESERVE
表示保留一块地址空间,这块空间可以被后续的操作(如提交内存)使用。 - 第四个参数
PAGE_READWRITE
:表示分配的内存具有可读可写的保护属性。
- 第一个参数
#else
部分(非Windows平台下的占位代码)- 在非Windows平台下(即
_WIN32
未被定义时),这里只是一个注释// linux下brk mmap等
,说明在Linux平台下可能会使用brk
或mmap
等系统调用或函数来实现类似的内存分配功能,但目前没有实际的代码实现。
- 在非Windows平台下(即
- 内存分配失败处理部分
if (ptr == nullptr)
- 这个
if
语句用于检查内存分配是否成功。如果ptr
为nullptr
(即VirtualAlloc
函数在Windows平台下分配内存失败,或者在非Windows平台下如果有实现且分配失败时也应该遵循类似的逻辑),则表示内存分配失败。
- 这个
throw std::bad_alloc();
- 当内存分配失败时,函数会抛出
std::bad_alloc
异常。std::bad_alloc
是C++ 标准库中定义的用于表示内存分配失败的异常类型。当使用new
操作符分配内存失败时,也会抛出这个异常。
- 当内存分配失败时,函数会抛出
- 函数返回部分
return ptr;
- 如果内存分配成功(在Windows平台下
VirtualAlloc
成功或者在非Windows平台下假设的brk
、mmap
等操作成功),函数将返回分配得到的内存地址指针。
- 如果内存分配成功(在Windows平台下
使用这种方式修改后的代码如下:
#ifdef _WIN32
#include<windows.h>
#else
//
#endif
// 直接去堆上按页申请空间
inline static void* SystemAlloc(size_t kpage)
{
#ifdef _WIN32
void* ptr = VirtualAlloc(0, kpage << 13, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
#else
// linux下brk mmap等
#endif
if (ptr == nullptr)
throw std::bad_alloc();
return ptr;
}
template<class T>
class ObjectPool {
public:
T* New() {
T* obj = nullptr;
if (_freelist)
{
void* next = *(void**)_freelist;
obj = (T*)_freelist;
_freelist = next;
}
else
{
if (_remainbytes < sizeof(T)) {
_remainbytes = 128 * 1024;
//_memory = (char*)malloc(128 * 1024);
_memory = (char*)SystemAlloc(_remainbytes >> 13);
if (_memory == nullptr) {
throw std::bad_alloc();
}
}
obj = (T*)_memory; //申请出去的新的空间的起始位置
//_memory += sizeof(T); //新的空间被申请后,剩余空间的起始位置
size_t objsize = sizeof(T) < sizeof(void*) ? sizeof(void*) : sizeof(T);
_memory += objsize;
_remainbytes -= sizeof(T); //剩余的空间
}
//定位new,显式调用T的构造函数初始化
new(obj)T;
return obj;
}
void Delete(T* obj) {
obj->~T();
*(void**)obj = _freelist;
_freelist = *(void**)obj;
}
private:
char* _memory = nullptr; //指向大块内存的指针
void* _freelist = nullptr; //指向待释放的空间
size_t _remainbytes = 0; // 大块内存在切分过程中剩余字节数
};
五、总结
(一)内存池的优势
- 性能提升
- 通过避免频繁的系统级内存分配(
malloc
)和释放(free
)操作,内存池减少了内存管理的开销。在处理大量对象的创建和销毁时,这种优势更加明显。就像我们在TestObjectPool
函数中的测试结果一样,使用内存池操作TreeNode
对象比普通的new
/delete
操作要快很多。
- 通过避免频繁的系统级内存分配(
- 内存碎片减少
- 内存池通过预先分配大块内存,并对其进行有效的管理和复用,减少了内存碎片的产生。这有助于提高内存的利用率,使程序能够更稳定地运行,尤其是在长时间运行的程序或者内存资源紧张的环境中。
(二)代码的思考
- 可扩展性
- 由于
ObjectPool
是一个模板类,它可以方便地应用于各种类型的对象,只要这些对象的构造函数和析构函数定义正确。这为代码的复用和扩展提供了很大的便利,使我们可以在不同的项目中轻松地使用这个内存池来管理不同类型的对象。
- 由于
- 局限性
- 内存池的实现也存在一些局限性。例如,它是基于定长内存池的思想,每次重新申请内存时固定为128 * 1024字节。在处理大小差异很大的对象时,可能会导致内存浪费或者频繁重新申请内存的情况。另外,这个代码没有考虑多线程环境下的并发安全问题,如果在多线程中同时使用这个内存池,可能会导致数据竞争和错误的内存管理操作。