目录
一、什么是内存池
1.池化技术
2.内存池
3.内存池主要解决的问题
二、内存池的实现
1.New申请空间
2.Delete释放空间
3.再看New申请空间
4.内存池完整代码
三、内存池性能测试
一、什么是内存池
1.池化技术
所谓 "池化技术",就是程序向系统申请过量的资源,然后⾃⼰管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较⼤的开销,不如提前申请好了,这样使⽤时就会变得⾮常快捷,⼤⼤提⾼程序运⾏效率.
在计算机中,有很多使⽤“池”这种技术的地⽅,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若⼲数量的线程,让它们处于睡眠状态,当接收到客⼾端的请求时,唤醒池中某个睡眠的线程,让它来处理客⼾端的请求,当处理完这个请求,线程⼜进⼊睡眠状态。
2.内存池
内存池是指程序预先从操作系统申请⼀块⾜够⼤内存,此后,当程序中需要申请内存的时候,不是直接向操作系统申请,⽽是直接从内存池中获取;同理,当程序释放内存的时候,并不真正将内存返回给操作系统,⽽是返回内存池。当程序退出(或者特定时间)时,内存池才将之前申请的内存真正释放。
3.内存池主要解决的问题
内存池主要解决的当然是效率的问题。
举个很形象的例子。上大学了,需要生活费了,然后呢,你每天要花假如50块钱,然后你就每天早上给你妈妈打电话要50,一个月就要打30次电话,你妈妈觉得烦死了,你妈妈说这样,我明天给你1500,之后每过1个月我在给你钱,一次就给你1个月的,至于怎么花你自己看着办。
在上面的故事中显然是一次给你1500块,效率会更高。对于操作系统也是一样的,每一次调用malloc申请空间都是有消耗的,还不如我一次给你一大块空间,你自己对这块空间进行一个管理,这样就使得效率大大提升。
二、内存池的实现
我们要写一个内存池,那首先需要用到模版,模版的作用就是告诉我你每次要申请多大块的内存空间。
其次我们当然需要用类封装起来,类的名字就叫ObjectPool,对象池的意思,然后我们成员函数需要什么呢?既然是申请内存的,那肯定需要一个指针_memory,来指向大块的空间,那这个指针是什么类型呢?char?int?还是其它的类型呢?用什么类型我们要明白指针的类型意味着什么,指针的类型意味着对这个指针进行++操作会往后走多少个字节,还有就是解引用可以看到的空间大小,比如说是int类型的指针,进行++操作就会往后走4个字节,解引用就是看4字节的空间。那么就要结合我们内存池的功能来看了,当一个对象申请空间之后,比如说这个对象要申请10字节空间,就意味着我们要从_memory中切10字节的空间给它,那请问,如果是int类型,好不好切?不好切,难道强转一下吗?最好用的还是char类型,因为只有1个字节,你要10字节空间,直接_memory往后加上10就行了。最开始初始化为nullptr。
还需要什么成员呢?当一个对象把内存还回来,我们直接释放吗?当然不是,我们可以连接在一个链表上,然后等后面其它的对象申请空间的时候将挂在链表上的内存给它们使用,那这个链表用一个结构体吗?然后里面定义一个next,一个data?不需要,直接用一个指针_freeList就可以,我们可以让这个指针的头4个字节(或者8个)存下一个内存块的地址,每次还回来一个内存块就让前一个内存块的头4个字节存还回来的内存块地址,最后一个内存块的前4个字节存nullptr。这里也可能是8个字节,如果是64位平台的话。那这个链表什么类型呢?void*就可以。最开始初始化为nullptr。
template<class T>
class ObjectPool
{
public:
private:
char* _memory;
void* _freeList;
};
有了成员变量接下来就要实现功能了,功能是啥呢?无非就是申请和释放内存,下面我们来看看,来个一个对象要申请空间,我们怎么申请。
1.New申请空间
T* New()
{
if (_memory == nullptr)
{
_memory = (char*)malloc(128 * 1024); // 第一次申请,给128K的大块内存
if (_memory == nullptr)
{
throw std::bad_alloc(); //抛异常
}
}
T* obj = (T*)_memory;
_memory += sizeof(T);
return obj;
}
函数名就叫New吧,需要参数吗,不需要,返回值是需要的,把申请的内存空间返回给调用的对象,刚开始的时候,_memory还是空的,因此,当_memory为空说明第一次申请,此时要给_memory开一大块内存,这里我们直接开128K的内存,然后直接将_memory强转为T*赋值给obj即可,T*类型表明解引用后可以看sizeof(T)大小的空间,然后让_memory往后加上T对象的大小。此时就切出去了一个T对象,然后直接把obj返回即可。但是这样写是对的吗?
比如说我切到最后一块,把_memory切完了,_memory加加往后走到了不属于自己的空间,然后又要来申请内存,此时_memory是空吗?不是空呀,虽然后面的内存不是你的,但也不是空,此时你能申请吗?你不能,但我怎么知道_memory有没有被切空?此时要加一个成员变量remainBytes,表示当前的内存块剩余的字节数。
那么此时的代码就需要做改动了,此时我们申请大块内存的判断条件就不应该是_memory为空了,而应该是remainBytes等于0的时候,但是remainBytes==0这个判断条件是对的吗?其实是不对的,因为有可能我申请的对象大小不能被整除,比如说这个对象每次都要5字节,但_memory只有128字节,因为5不能被128整除,因此等到_memory被申请到最后一块的时候只剩下3字节了,此时这个对象在申请空间,但是我们只有3字节,不够一个对象的大小,此时也需要重新开一块大内存然后给对方。因此这里开辟大块内存的判断条件是remainBytes< sizeof(T)的时候,当不够一个对象的大小,就申请空间。
因此我们的代码应该这样写。
#include <iostream>
using std::cout;
using std::endl;
template<class T>
class ObjectPool
{
public:
T* New()
{
// 当剩余的内存不够一个对象大小的时候,就重新开大块空间
if (remainBytes < sizeof(T))
{
_memory = (char*)malloc(128 * 1024); // 第一次申请,给128K的大块内存
if (_memory == nullptr)
{
throw std::bad_alloc(); //抛异常
}
remainBytes = 128 * 1024;
}
T* obj = (T*)_memory;
_memory += sizeof(T);
remainBytes -= sizeof(T);
return obj;
}
private:
size_t remainBytes = 0; // 大块内存切分过程中剩余的字节数
char* _memory = nullptr; // 指向大块内存的指针
void* _freeList = nullptr; // 管理还回来的内存块
};
2.Delete释放空间
下面我们再来写对象把内存还回来的代码。
函数名就叫Delete,不需要返回值,参数当然是需要的,把你要还回来的内存给我传过来。
你把内存给我还回来了,那我就要对还回来的内存进行管理,链接到自由链表_freeList上,如果是第一次还回来,_freeList为nullptr,那我们就需要让_freeList指向还回来的obj,然后把_freeList的前4个字节处放一个nullptr,怎么做呢?直接把_freeList强转成int*类型,然后对_freeList进行解引用,int类型解引用看到的就是4个字节,然后把NULL赋值给_freeList即可。
这样写在32位下是没问题的,但64位下是跑不动的,64位下指针的大小是8个字节,int*的指针解引用只有4个字节,那怎么办呢?我们可以判断一下,如果sizeof(int*)的大小为4,就执行上面的逻辑,否则,就是8个字节,那我们直接*(long long*)obj就好了。
这样写当然是可以,但是还有一种很牛的写法,直接把obj强转为void**,然后解引用。对int*解引用看到的是int大小的空间,对void**解引用看到的是void*大小的空间,void*是什么?是个指针,指针的大小在32位下是4字节,64位下是8字节,是不是很巧妙?这里void**根本不重要,即使是int**、char**都可以,因为指针的大小是固定的。
如果不是第一次我们直接采取头插的方式,让还回来的内存块obj的前几个字节(4或8)存_freeList的地址,此时obj就指向了_freeList,然后再把obj给给_freeList即可。
然而上面的第一次申请和后续的申请可以合并为一种,就是我不管什么情况直接头插,第一次的时候_freeList是空,此时obj前几个字节就指向nullptr,然后再把obj赋值给_freeList,是不是也没问题。
void Delete(T* obj)
{
// 头插
*(void**)obj = _freeList;
_freeList = obj;
}
3.再看New申请空间
有没有这样一种可能,就是一直申请空间,然后切_memory,切的时候也会还一些回来,然后_memory被切空了,按照我们上面的逻辑,此时是不是会重写申请大块空间,但是申请的时候也有可能还回来,就意味着_freeList还存了很多内存块没用,但我们还是去申请大块内存了,此时这_freeList上的内存不就浪费了吗?因此,向我们要空间的时候,我们应该优先把_freeList上的内存块切出去给对方,这样是不是才更合理呢?不然管理_freeList干嘛。
我们先把_freeList指向的下一个内存块存在next里,然后直接把头上的_freeList给给obj,然后再让_freeList指向next,最后把obj返回即可。_freeList的下一个内存块地址在头部前几个字节里面存着。
T* New()
{
T* obj = nullptr;
// 自由链表有内存块,优先去自由链表申请内存
if (_freeList)
{
void* next = *(void**)_freeList;
obj = (T*)_freeList;
_freeList = next;
return obj;
}
else
{
// 当剩余的内存不够一个对象大小的时候,就申请空间
if (remainBytes < sizeof(T))
{
_memory = (char*)malloc(128 * 1024); // 第一次申请,给128K的大块内存
if (_memory == nullptr)
{
throw bad_alloc(); //抛异常
}
remainBytes = 128 * 1024;
}
obj = (T*)_memory;
_memory += sizeof(T);
remainBytes -= sizeof(T);
return obj;
}
}
我们来梳理一下我们的申请空间代码,如果_freeList自由链表有内存块就去_freeList里面申请,如果没有就去内存块_memory里申请,_memory剩余字节数足够一个对象大小,直接切出去,如果不够要申请大块的内存块,再去_memory里申请。
问题1:
此时还有一个问题,那就是可能我这个申请的对象只有3个字节,我们直接切一块空间给它,然后这个3字节的对象还回来的时候就有问题了,有啥问题呢?还回来的时候要链接到_freeList上,怎么连接呢?头插呀,前几个自己存_freeList的地址,然后_freeList在指向obj,但我obj只有3个字节,32位下地址也要4字节,更别提64位了,我根本放不下呀,因此在申请空间的时候,如果对象的字节数不够一个指针类型的大小,我们要补够。
因此直接这样,当T小于一个指针大小,32位4,64位8, 让objSize位一个指针大小,否则就为T的大小,然后让_memory直接 += objSize即可。
比如果T是3个字节,在32位下,我们还是给它申请3字节的空间,但是_memory往后走的时候就要往后走4个字节。此时还回来的时候虽然T是3个字节,但我们是4字节4字节切分的,因此3字节的后面1个字节也是属于它的,因此直接存地址即可。
问题2:
这里我们只是开了空间,但是没有初始化,我们只是还回来了空间但是没有把空间里的内容清理掉。因此我们可以用定位new,显示调用T的构造函数初始化,释放的时候显示调用一下T的析构函数清理对象。
此时这个对象池就比较完美了。
4.内存池完整代码
#include <iostream>
using std::cout;
using std::endl;
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))
{
_memory = (char*)malloc(128 * 1024); // 第一次申请,给128K的大块内存
if (_memory == nullptr)
{
throw std::bad_alloc(); //抛异常
}
remainBytes = 128 * 1024;
}
obj = (T*)_memory;
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 = obj;
}
private:
size_t remainBytes = 0; // 当前内存块剩余的字节数
char* _memory = nullptr; // 指向大块内存的指针
void* _freeList = nullptr; // 管理还回来的内存块
};
三、内存池性能测试
#include "MemPool.hpp"
#include <vector>
struct TreeNode
{
int _val;
TreeNode* _left;
TreeNode* _right;
TreeNode()
:_val(0)
, _left(nullptr)
, _right(nullptr)
{}
};
void TestObjectPool()
{
// 申请释放的轮次
const size_t Rounds = 3;
// 每轮申请释放多少次
const size_t N = 100000;
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;
}
int main()
{
TestObjectPool();
}
上面的代码我相信大家一看就知道在干嘛吧。
这里我们可以看到,我们内存池的效率比malloc块了基本上2倍,new的底层也是malloc。