18.3 C++内存高级话题-内存池概念、代码实现和详细分析

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内存来使用,那肯定是不可以的。
    当整个程序运行即将结束退出的时候,建议把分配出去的内存真正释放掉,这是一个比较好的习惯。这个内存池所占用的内存如何写代码来真正地释放掉,这个问题留给读者,相信只要细心一点,实现出这段代码并不困难。
    本节所讲的内存池代码虽然以教学和演示为主,但要在实际的项目中使用这段代码也是可以的,请读者自己把握。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值