18.1 C++内存高级话题-new、delete的进一步认识
18.2 C++内存高级话题-new内存分配细节探秘与重载类内operator new、delete
18.3 C++内存高级话题-内存池概念、代码实现和详细分析
18.4 C++内存高级话题-嵌入式指针概念及范例、内存池改进版
18.5 C++内存高级话题-重载全局new/delete、定位new及重载
3.内存池概念、代码实现和详细分析
3.1 内存池的概念和实现原理简介
前面讲解了malloc内存分配原理,体会到了使用malloc这种分配方式来分配内存会产生比较大的内存浪费,尤其是频繁分配小块内存时,浪费更加明显。
所以一个叫作“内存池”的词汇就应运而生,内存池的代码实现千差万别,且代码有一定的复杂度,但是一些核心的实现思想比较统一。请想一想,内存池要解决的主要问题是什么?
· 减少malloc调用次数,这意味着减少对内存的浪费。
· 减少对malloc的调用次数后,能不能提高程序的一些运行效率或者说是运行速度呢?从某种程度上来说,能,但是效率提升并不太多,因为malloc的执行速度其实是极快的,通过测试可以知道这一点。所以,改用内存池来处理内存分配,效率上会比malloc好一点,但好的并不明显。
那么,内存池的实现原理是什么呢?就是用malloc申请一大块内存,分配内存的时候,就从这一大块内存中一点点分配给程序员,当一大块内存差不多用完的时候,再申请一大块内存,然后再一点一点地分配给程序员使用。
请想一想,这种做法当然就有效地减少了malloc的调用次数,从而减少了对内存的浪费。当然,也容易想象,因为是申请一大块内存,然后一小块一小块分配给程序员用,那么这里面就涉及怎样分成一小块一小块以及怎样回收的问题。这就是内存池给程序员带来便利的同时,也带来了代码实现上或者说管理上的难度。
再次强调,内存池的代码实现千差万别,不同的人有不同的写法,但不管怎么说,减少内存浪费是根本,提高程序运行效率是顺带(不是最主要的)的。
3.2 针对一个类的内存池实现演示代码
上一节学习了类的operator new、operator delete操作符的重载,那么能否通过这种重载的手段来实现一个针对某个类的内存池呢?如上一节中的类A。希望用内存池的手段来实现如下这种内存分配的具体实现代码:
A *pa = new A();
delete pa;
这里提供一段相对比较简单,又比较有代表性的,能够体现内存池用途的代码。
class A
{
public:
static void* operator new(size_t size);
static void operator delete(void* phead);
static int m_iCount; //用于分配计数统计,每new一次+1
static int m_iMallocCount; //用于统计malloc次数,每malloc一次+1
private:
A* next;
static A* m_FreePosi; //总是指向一块可以分配出去的内存的首地址
static int m_sTrunkCount; //一次分配多少倍该类的内存
};
void* A::operator new(size_t size)
{
#ifdef MYMEMPOOL
A* ppoint = (A*)malloc(size);
return ppoint;
#endif
//A *ppoint = (A *)malloc(size); //不再用传统方式实现,而是用内存池实现
//return ppoint;
A* tmplink;
if (m_FreePosi == nullptr)
{
//为空,我们要申请内存,申请的是很大一块内存
size_t realsize = m_sTrunkCount * size; //申请m_TrunkCount这么多倍的内存
m_FreePosi = reinterpret_cast<A*>(new char[realsize]); //这是传统new,调用底层传统malloc
tmplink = m_FreePosi;
//把分配出来的这一大块内存链起来,供后续使用
for (; tmplink != &m_FreePosi[m_sTrunkCount - 1]; ++tmplink)
{
tmplink->next = tmplink + 1;
}
tmplink->next = nullptr;
++m_iMallocCount;
}
tmplink = m_FreePosi;
m_FreePosi = m_FreePosi->next;
++m_iCount;
return tmplink;
}
void A::operator delete(void* phead)
{
#ifdef MYMEMPOOL
free(phead);
return;
#endif
//free(phead); //不再用传统方式实现,针对内存池有特别的实现
(static_cast<A*>(phead))->next = m_FreePosi;
m_FreePosi = static_cast<A*>(phead);
}
int A::m_iCount = 0;
int A::m_iMallocCount = 0;
A* A::m_FreePosi = nullptr;
int A::m_sTrunkCount = 5; //一次分配5倍的该类内存作为内存池的大小
上面这段代码希望读者能够仔细阅读和分析,一定要理解这些代码做了什么事情,这样才能达到本节的学习目的和效果。
仔细分析上面这段代码,看一看operator new函数里面做了什么事。
(1)第一次调用operator new成员函数分配内存的时候,if(m_FreePosi==nullptr)条件是成立的,因此会执行该条件内的for循环语句。整个if条件中的代码执行完毕后的情形(示意图)如图所示。
上图看起来整个是一个链表,提前分配了5块内存(每块正好是一个类A对象的大小),然后每一块的next(指针成员变量)都指向下一块的首地址,这样就非常方便从当前的块找到下一块。
跳出if语句并执行if后面的几行代码,这几行代码的含义是:m_FreePosi总是指向下一个能分配的空闲块的开始地址,而后把tmplink返回去,如图。
(2)每次new一个该类(类A)对象,m_FreePosi都会往下走指向下一块空闲待分配内存块的首地址。假设程序员new了5次对象,把内存池中事先准备好的5块内存都消耗光了,m_FreePosi就会指向nullptr了。
此时,程序员第6次new对象的话,那么程序中if(m_FreePosi==nullptr)条件就又成立了,这时程序又会分配出5块内存,并且将新分配的5块内存中的第1块拿出来返回,m_FreePosi指向第2块新分配的内存块。如图所示,深色代表已经分配出去的内存块,浅色代表没有分配出去的内存块。
至此,读者大概明白了内存是怎样分配的。再看一看内存的回收。
下图描述了当前已经new了9次A类对象的情形。
针对下图所示的内存池当前内存分配情形,笔者想把图中左上5块内存中中间的一块(第3块颜色更深一点的)内存释放掉,这时就要看一看operator delete函数里做了什么事。
特别值得提醒读者注意的是,operator delete并不是把内存真正归还给系统,因为把内存真正归还给系统是需要调用free函数的,operator delete做的事情是把要释放的内存块链回到空闲的内存块链表中来。
这里可以这样想象:
(1)由m_FreePosi串起来的这个链(链表)代表的是空闲内存块的链,m_FreePosi指向的是整个链的第一个空闲块的位置,当需要分配内存时,就把这第一个空闲块分配出去,m_FreePosi就指向第二个空闲块。
(2)当回收内存块的时候,m_FreePosi就会立即指向这块回收回来的内存块的首地址,然后让回收回来的这块内存的next指针指向原来m_FreePosi所指向的那个空闲块。所以,m_FreePosi始终是空闲块这个链的第一个空闲块(链表头)。
(3)对于已经分配出去的内存块的next指针指向什么已经没有实际意义了,可以不用理会。已经分配出去的内存块,程序要对它们负责,程序要保证及时地delete它们促使类A的operator delete成员函数被及时执行,从而把不用的内存块归还到内存池中。
将上图中左上的第3块内存回收回来后的情形如下图所示。
请注意,根据对代码的分析,对于一个内存块,只有未被分配出去的时候,它的next指针才有意义(指向下一个未被分配出去的空闲内存块或者指向nullptr),当该内存块被分配出去(被使用)后,其next指针就没有实际意义了,因此对于分配出去的内存块笔者也并未绘制其next指针的指向。
请读者认真阅读代码,发挥想象力,实在读不明白,自己画图来辅助分析,本节的主要任务就是要把内存分配、内存释放的代码搞清楚,因为上面的范例还是属于比较简单的,以后可能会面对复杂得多的代码,所以务必要把这种比较简单的代码学习明白。
现在创建类A对象时所支持的内存池功能就写好了。要如何进行测试呢?在main主函数中写入如下代码:
int main()
{
clock_t start, end; //包含头文件#include <ctime>
start = clock(); //程序从运行到此刻所花费的时间(单位:毫秒)
for (int i = 0; i < 5000000; i++)
{
A* pa = new A();
}
end = clock();
cout << "申请分配内存的次数为:" << A::m_iCount << " 实际malloc次数为:" << A::m_iMallocCount << " 用时(毫秒):" << end - start << endl;
}
虽然每次的执行结果中用时这一项略有不同,但这个数字大概在500~800之间,也就是说,分配了500万次内存,用了大概0.5~0.8s左右的时间,这种new的速度非常快。
如果增加内存池一次分配的内存块数,从而进一步减少malloc的调用次数。看一看效果:
int A::m_sTrunkCount = 500;
这次修改用时在300~500之间,感觉能提升一定的速度,但提升有限,原来的代码是调用了100万次malloc(每次分配5块),现在是调用1万次malloc(每次分配500块),那就是差了99万次,也就才提升200多毫秒,所以可以看到malloc的速度和自己管理内存的速度真的差不多,慢不了多少。
但是,A::m_sTrunkCount的值也不应该设置的太大,否则第一次创建出来的块太多,也浪费时间和内存。那么A::m_sTrunkCount到底多大合适,还真不好说。根据刚才的测试,似乎数字在几十之间就可以了,读者可以自己再测试测试。
如果不用内存池,而用原生的malloc进行内存分配,看一看效率如何。
前面位置增加一个宏定义(类似于一个开关):
#define MYMEMPOOL 1
根据运行结果,500万次内存分配内存才慢这么一点时间,所以结论还是malloc的执行速度慢不了多少。
3.3 内存池代码后续说明
{
//和时间有关的类型:typedef long clock_t
clock_t start, end; //包含头文件#include <ctime>
start = clock(); //程序从运行到此刻所花费的时间(单位:毫秒)
for (int i = 0; i < 15; i++)
{
A* pa = new A();
printf("%p\n", pa);
}
end = clock();
cout << "申请分配内存的次数为:" << A::m_iCount << " 实际malloc次数为:" << A::m_iMallocCount << " 用时(毫秒):" << end - start << endl;
}
现在把调整代码:
int A::m_sTrunkCount = 5; //一次分配5倍的该类内存作为内存池的大小
//#define MYMEMPOOL 1
通过上面的结果不难看到,每个分配的内存地址都是挨着的(间隔4字节),这说明内存池机制在发挥作用(因为内存池是一次分配5块内存,显然这5块内存地址是挨在一起的)。
如果关闭内存池,会发现每次malloc的地址是不一定挨着的。要关闭内存池,只需要把MYMEMPOOL宏定义行的注释取消。来试一试:
通过上面的结果不难看到,每5个分配的内存地址都是挨着的(间隔4字节),这说明内存池机制在发挥作用(因为内存池是一次分配5块内存,显然这5块内存地址是挨在一起的)。
如果关闭内存池,会发现每次malloc的地址是不一定挨着的。要关闭内存池,只需要把MYMEMPOOL宏定义行的注释取消。来试一试:
#define MYMEMPOOL 1
上面这些就是笔者演示的内存池的一个具体实现。
当然,这个内存池代码不完善,例如分配内存的时候是用new分配的,释放内存的时候并没有真正地用delete来释放,而是把这块要释放的内存通过一个空闲链连起来而已。
可以想象,这种内存池技术的实现要是想通过delete来真正释放内存(把内存归还给操作系统),并不容易做到,那么索性就把回收回来的内存攥在手里,需要的时候再分配出去,不需要的时候一直攥在手里(这不属于内存泄漏),只要分配内存时不是一直用new分配下去,那这个内存池即便后续变得很大,但只要内存有分配,有回收,这个内存池耗费的内存空间总归还是有限的。当然假设物理内存有100MB,内存池非要new出120MB内存来使用,那肯定是不可以的。
当整个程序运行即将结束退出的时候,建议把分配出去的内存真正释放掉,这是一个比较好的习惯。这个内存池所占用的内存如何写代码来真正地释放掉,这个问题留给读者,相信只要细心一点,实现出这段代码并不困难。
本节所讲的内存池代码虽然以教学和演示为主,但要在实际的项目中使用这段代码也是可以的,请读者自己把握。