博客主题:如何设计一个定长内存池
个人主页:https://blog.csdn.net/sobercq
CSDN专栏:https://blog.csdn.net/sobercq/category_12884309.html
Gitee链接:https://gitee.com/yunshan-ruo/high-concurrency-memory-pool
文章目录
- 前言
- 定长内存池
- 1.如何实现定长?
- 2.分配内存
- 3.回收内存
- 3.如何New对象
- 4.如何释放对象
- 5. 优化
- 性能测试
前言
上期我们说到malloc,我们知道malloc,在C/C++中动态申请内存都是通过malloc去申请内存, 并且malloc就是一个内存池。但是malloc因为要兼容通用,它什么场景下都可以用,但是什么场景下都可以用就意味着什么场景下都不会有很高的性能,所以我们要设计一个针对点一定场景下的内存池,来实现高性能。
所以本期我们就先设计一个定长内存池,先熟悉一下简单内存池是如何控制的,第二他会作为我们后面高并发内存池的一个基础组件。
那定长内存池就是针对固定大小内存块的申请和释放的内存池,由于定长内存池只需要支持固定大小内存块的申请和释放,因此我们可以将其性能做到极致,并且在实现定长内存池时不需要考虑内存碎片等问题,因为我们申请/释放的都是固定大小的内存块。
定长内存池
1.如何实现定长?
我们先创建和Test.cpp,ObjectPool.h,那如何实现定长,其一我们可以采用非类型模板参数来实现定长。
template<size_t N>
class ObjectPool
{
};
其二呢,我们也可以通过类型模板参数,采用这种方法设计我们也把它叫做对象池,即ObjectPool,在创建对象池时,对象池可以根据传入的对象类型的大小来实现“定长”,比如,创建定长内存池时传入的对象类型是内置类型,那么该内存池就只支持内置类型字节大小的内存块的申请和释放,如果是自定义类型,那我们的内存块大小就是sizeof(T)。
template<class T>
class ObjectPool
{
};
2.分配内存
首先,我们的内存池需要去堆上申请一块内存,这块内存假设我们已经申请下来了,我们要管理这块内存。那如何分配这大块内存呢?
如图所示,我们每次只需要在头部位置加对应大小的字节就可以了。
所以为了方便管理我们申请下来的大块内存,就需要用一个指针来管理。
//void* _memory
char* _memory
但是void这个类型没有意义,又不能加也不能减,还要强转,所以我们取最小的char,方便我们去切内存块,char指针+1也是+1字节。容易切分内存。
3.回收内存
那我们使用完这些划分后的内存块后,应该如何管理呢?我们可以用一个自由链表来管理内存,将回收来的内存块想象成一个链表的结点。
通过上图,我们能明确知道,这个对象池的构造了。
template <class T>
class ObjectPool
{
private:
char* _memory = nullptr; //管理系统申请的大块内存
void* _freelist = nullptr; //管理回收内存
};
3.如何New对象
我们既然已经知道我们的内存池的构造了,接下来就是如何去new一个对象。
刚才我们也说过,对象池,其大小是一个T的大小,所以我们可以先通过malloc去申请一块大点的空间,然后将其分配给对象。
T* New()
{
T* Obj = nullptr;
if (_memory == nullptr)
{
_memory = (char*)malloc(128 * 1024);//128K内存块
if (_memory == nullptr)
{
throw std::bad_alloc();//申请失败,抛异常
}
}
Obj = (T*)_memory;
_memory += sizeof(T);
return Obj;
}
我们创建一个T*的对象出来,然后通过malloc申请一块空间,申请失败就抛异常,成功我们就赋值给对象,然后_memory就加上对应大小的字节数。
但是,我们怎么知道我们malloc申请下来的空间数都用完了呢?
所以我们可以再创建一个成员变量_remainBytes来监视我们的内存块,如果我们的剩余字节数无法再分配出一个内存块的话,就要再通过malloc去申请一个内存块。
T* New()
{
T* Obj = nullptr;
//剩余字节数不够分配,再创建一个空间
if (_remainBytes < sizeof(T))
{
_memory = (char*)malloc(128 * 1024);//128K内存块
if (_memory == nullptr)
{
throw std::bad_alloc();//申请失败,抛异常
}
_remainBytes = 128 * 1024;
}
//直接分配内存
Obj = (T*)_memory;
_remainBytes -= sizeof(T);
_memory += sizeof(T);
return Obj;
}
4.如何释放对象
第一次回收内存,我们只要让对象的头4字节或者8字节为空。而32位平台下指针的大小是4个字节,64位平台下指针的大小是8个字节。而指针指向数据的类型,决定了指针解引用后能向后访问的空间大小,因此我们这里需要的是一个指向指针的指针,这里使用二级指针就行了。
void Delete(T* obj)
{
_freelist = obj;
//令对象的指针区为空
*(void**)obj = nullptr;
}
回收对象并不只有第一次,也有很多次,回收对象并不只有第一次,所以多次以后我们就要多批处理。因为是内存块要回收到自由链表当中,我们可以选择头插和尾插,以前学习链表我们就学过要,尾插要找尾,更麻烦,而头插就方便更多。
所以我们这里就和链表一样直接头插。
void Delete(T* obj)
{
if(_freelist == nullptr)
{
//第一次插入
_freelist = obj;
//令对象的指针区为空
*(void**)obj = nullptr;
}
else
{
//其余插入
*(void**)obj = _freelist;
_freelist = obj;
}
}
那这段代码还有没有优化空间呢?我们可以发现头插其实根本不用分第一次和n次,因为不影响我们的操作。
所以我们可以直接去掉if else
void Delete(T* Obj)
{
//令对象的指针区为空
*(void**)Obj = _freelist;
_freelist = Obj;
}
回收后我们还需要重复利用我们自由链表上的内存,所以在划分内存前,我们需要先检查一步,我们的自由链表是否为空。不为空,我们直接把自由链表中的内存块拿出来使用。
//重复利用自由链表
if (_freelist != nullptr)
{
//自由链表头删
void* next = *((void**)_freelist);
Obj = (T*)_freelist;
_freelist = next;
return Obj;
}
else
{
//....
}
5. 优化
但,根据上述情况我们确实写完了大部分,但可能存在一种情况,我自由链表回收内存的时候无法开辟字节空间怎么办?也就是sizeof(T) < * (void **) 的情况。所以为了保证我们自由链表能有4/8的字节空间,我们还需要在分配内存的时候保证一个最低限度。
//直接分配内存
Obj = (T*)_memory;
size_t ObjSize = sizeof(T) > sizeof(void*) ? sizeof(T) : sizeof(void*);
_remainBytes -= ObjSize;
_memory += ObjSize;
其次我们还需要处理一下对象。在释放对象时,应该显示调用该对象的析构函数清理该对象,因为该对象可能还管理着其他某些资源,如果不对其进行清理那么这些资源将无法被释放,就会导致内存泄漏。
同理,当内存块切分出来后,我们也应该使用定位new,显示调用该对象的构造函数对其进行初始化。
//定位new
new(Obj)T;
return Obj;
void Delete(T* Obj)
{
//清理对象资源
Obj->~T();
//令对象的指针区为空
*(void**)Obj = _freelist;
_freelist = Obj;
}
3.我们能否跳过malloc直接去堆上申请内存呢?答案是可以的,就是用接口去使用,VirtualAlloc(按页去申请),Linux可以调用brk或mmap函数。
在使用前我们得包括对应的头文件。
我们可以为了通用在前面先使用上条件编译,从而让我们的内存池在Linux和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;
}
对应的,我们需要修改一下部分代码,将malloc去除。
//剩余字节数不够分配,再创建一个空间
if (_remainBytes < sizeof(T))
{
_remainBytes = 128 * 1024;
//_memory = (char*)malloc(128 * 1024);//128K内存块
_memory = (char*)SystemAlloc(_remainBytes);
if (_memory == nullptr)
{
throw std::bad_alloc();//申请失败,抛异常
}
}
这样我们就可以直接从堆上申请空间了。那这里我们到底搞了多少内存呢?
首先,这些是我们的计算机单位,23就1B,210就1024B = 1KB,而VirtualAlloc中kpage << 13,就相当于213 * kpage, 即 4096 * kpage。
所以实际上这里给到的内存就有128 * 1024 * 4096 bytes= 512 * 1024 *1024 = 512MB。
当然这里会过大,所以我们可以调整一下。
我自己测试在visual 2022 x64情况下可以申请到 128 * 1024 * 32 * 1024 = 4gb。
x86情况下则是128mb。
我没有很仔细去分配,只是大概的测试了一下,所以各位友友如果有问题不妨降低一下申请的字节数。
性能测试
这里是一个测试性能用的代码段。
new 和 delete 测试:使用 std::vector<TreeNode*> 容器存储通过 new 分配的 TreeNode 对象。在每轮测试结束后,通过 delete 逐一释放对象,并清空 v1 容器。
对象池(ObjectPool)测试:创建一个 ObjectPool 对象池,使用对象池的 New() 方法分配对象,用 Delete() 方法释放对象。同样在每轮测试结束后,清空 v2 容器。
void TestObjectPool()
{
// 申请释放的轮次
const size_t Rounds = 3;
// 每轮申请释放多少次
const size_t N = 100000;
//new和delete
size_t begin1 = clock();
std::vector<TreeNode*> v1;
v1.reserve(N);
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();
//我们的内存池
ObjectPool<TreeNode> TNPool;
size_t begin2 = clock();
std::vector<TreeNode*> v2;
v2.reserve(N);
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 < 100000; ++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;
}
通过测试还是明显的看出我们的内存池速度会更快些,当然这是debug的情况下, 如果我们切换到release,可以更明显看到差别。
结尾:
完整代码在我的gitee仓库中,可以点击文章开头的链接跳转,关于定长内存池我们就结束了。