内存池Word格式.docx
《内存池Word格式.docx》由会员分享,可在线阅读,更多相关《内存池Word格式.docx(20页珍藏版)》请在冰豆网上搜索。
利用默认的内存管理函数new/delete或malloc/free在堆上分配和释放内存会有一些额外的开销。
系统在接收到分配一定大小内存的请求时,首先查找内部维护的内存空闲块表,并且需要根据一定的算法(例如分配最先找到的不小于申请大小的内存块给请求者,或者分配最适于申请大小的内存块,或者分配最大空闲的内存块等)找到合适大小的空闲内存块。
如果该空闲内存块过大,还需要切割成已分配的部分和较小的空闲块。
然后系统更新内存空闲块表,完成一次内存分配。
类似地,在释放内存时,系统把释放的内存块重新加入到空闲内存块表中。
如果有可能的话,可以把相邻的空闲块合并成较大的空闲块。
默认的内存管理函数还考虑到多线程的应用,需要在每次分配和释放内存时加锁,同样增加了开销。
可见,如果应用程序频繁地在堆上分配和释放内存,则会导致性能的损失。
并且会使系统中出现大量的内存碎片,降低内存的利用率。
默认的分配和释放内存算法自然也考虑了性能,然而这些内存管理算法的通用版本为了应付更复杂、更广泛的情况,需要做更多的额外工作。
而对于某一个具体的应用程序来说,适合自身特定的内存分配释放模式的自定义内存池则可以获得更好的性能。
6.1.2内存池的定义和分类
自定义内存池的思想通过这个"
池"
字表露无疑,应用程序可以通过系统的内存分配调用预先一次性申请适当大小的内存作为一个内存池,之后应用程序自己对内存的分配和释放则可以通过这个内存池来完成。
只有当内存池大小需要动态扩展时,才需要再调用系统的内存分配函数,其他时间对内存的一切操作都在应用程序的掌控之中。
应用程序自定义的内存池根据不同的适用场景又有不同的类型。
从线程安全的角度来分,内存池可以分为单线程内存池和多线程内存池。
单线程内存池整个生命周期只被一个线程使用,因而不需要考虑互斥访问的问题;
多线程内存池有可能被多个线程共享,因此则需要在每次分配和释放内存时加锁。
相对而言,单线程内存池性能更高,而多线程内存池适用范围更广。
从内存池可分配内存单元大小来分,可以分为固定内存池和可变内存池。
所谓固定内存池是指应用程序每次从内存池中分配出来的内存单元大小事先已经确定,是固定不变的;
而可变内存池则每次分配的内存单元大小可以按需变化,应用范围更广,而性能比固定内存池要低。
6.1.3内存池工作原理示例
下面以固定内存池为例说明内存池的工作原理,如图6-1所示。
图6-1固定内存池
固定内存池由一系列固定大小的内存块组成,每一个内存块又包含了固定数量和大小的内存单元。
如图6-1所示,该内存池一共包含4个内存块。
在内存池初次生成时,只向系统申请了一个内存块,返回的指针作为整个内存池的头指针。
之后随着应用程序对内存的不断需求,内存池判断需要动态扩大时,才再次向系统申请新的内存块,并把所有这些内存块通过指针链接起来。
对于操作系统来说,它已经为该应用程序分配了4个等大小的内存块。
由于是大小固定的,所以分配的速度比较快;
而对于应用程序来说,其内存池开辟了一定大小,内存池内部却还有剩余的空间。
例如放大来看第4个内存块,其中包含一部分内存池块头信息和3个大小相等的内存池单元。
单元1和单元3是空闲的,单元2已经分配。
当应用程序需要通过该内存池分配一个单元大小的内存时,只需要简单遍历所有的内存池块头信息,快速定位到还有空闲单元的那个内存池块。
然后根据该块的块头信息直接定位到第1个空闲的单元地址,把这个地址返回,并且标记下一个空闲单元即可;
当应用程序释放某一个内存池单元时,直接在对应的内存池块头信息中标记该内存单元为空闲单元即可。
可见与系统管理内存相比,内存池的操作非常迅速,它在性能优化方面的优点主要如下。
(1)针对特殊情况,例如需要频繁分配释放固定大小的内存对象时,不需要复杂的分配算法和多线程保护。
也不需要维护内存空闲表的额外开销,从而获得较高的性能。
(2)由于开辟一定数量的连续内存空间作为内存池块,因而一定程度上提高了程序局部性,提升了程序性能。
(3)比较容易控制页边界对齐和内存字节对齐,没有内存碎片的问题。
6.2一个内存池的实现实例
本节分析在某个大型应用程序实际应用到的一个内存池实现,并详细讲解其使用方法与工作原理。
这是一个应用于单线程环境且分配单元大小固定的内存池,一般用来为执行时会动态频繁地创建且可能会被多次创建的类对象或者结构体分配内存。
本节首先讲解该内存池的数据结构声明及图示,接着描述其原理及行为特征。
然后逐一讲解实现细节,最后介绍如何在实际程序中应用此内存池,并与使用普通内存函数申请内存的程序性能作比较。
6.2.1内部构造
内存池类MemoryPool的声明如下:
classMemoryPool
{
private:
MemoryBlock*pBlock;
USHORTnUnitSize;
USHORTnInitSize;
USHORTnGrowSize;
public:
MemoryPool(USHORTnUnitSize,
USHORTnInitSize=1024,
USHORTnGrowSize=256);
~MemoryPool();
void*Alloc();
voidFree(void*p);
};
MemoryBlock为内存池中附着在真正用来为内存请求分配内存的内存块头部的结构体,它描述了与之联系的内存块的使用信息:
structMemoryBlock
USHORTnSize;
USHORTnFree;
USHORTnFirst;
USHORTnDummyAlign1;
MemoryBlock*pNext;
charaData[1];
staticvoid*operatornew(size_t,USHORTnTypes,USHORTnUnitSize)
{
return:
:
operatornew(sizeof(MemoryBlock)+nTypes*nUnitSize);
}
staticvoidoperatordelete(void*p,size_t)
:
operatordelete(p);
MemoryBlock(USHORTnTypes=1,USHORTnUnitSize=0);
~MemoryBlock(){}
此内存池的数据结构如图6-2所示。
图6-2内存池的数据结构
6.2.2总体机制
此内存池的总体机制如下。
(1)在运行过程中,MemoryPool内存池可能会有多个用来满足内存申请请求的内存块,这些内存块是从进程堆中开辟的一个较大的连续内存区域,它由一个MemoryBlock结构体和多个可供分配的内存单元组成,所有内存块组成了一个内存块链表,MemoryPool的pBlock是这个链表的头。
对每个内存块,都可以通过其头部的MemoryBlock结构体的pNext成员访问紧跟在其后面的那个内存块。
(2)每个内存块由两部分组成,即一个MemoryBlock结构体和多个内存分配单元。
这些内存分配单元大小固定(由MemoryPool的nUnitSize表示),MemoryBlock结构体并不维护那些已经分配的单元的信息;
相反,它只维护没有分配的自由分配单元的信息。
它有两个成员比较重要:
nFree和nFirst。
nFree记录这个内存块中还有多少个自由分配单元,而nFirst则记录下一个可供分配的单元的编号。
每一个自由分配单元的头两个字节(即一个USHORT型值)记录了紧跟它之后的下一个自由分配单元的编号,这样,通过利用每个自由分配单元的头两个字节,一个MemoryBlock中的所有自由分配单元被链接起来。
(3)当有新的内存请求到来时,MemoryPool会通过pBlock遍历MemoryBlock链表,直到找到某个MemoryBlock所在的内存块,其中还有自由分配单元(通过检测MemoryBlock结构体的nFree成员是否大于0)。
如果找到这样的内存块,取得其MemoryBlock的nFirst值(此为该内存块中第1个可供分配的自由单元的编号)。
然后根据这个编号定位到该自由分配单元的起始位置(因为所有分配单元大小固定,因此每个分配单元的起始位置都可以通过编号分配单元大小来偏移定位),这个位置就是用来满足此次内存申请请求的内存的起始地址。
但在返回这个地址前,需要首先将该位置开始的头两个字节的值(这两个字节值记录其之后的下一个自由分配单元的编号)赋给本内存块的MemoryBlock的nFirst成员。
这样下一次的请求就会用这个编号对应的内存单元来满足,同时将此内存块的MemoryBlock的nFree递减1,然后才将刚才定位到的内存单元的起始位置作为此次内存请求的返回地址返回给调用者。
(4)如果从现有的内存块中找不到一个自由的内存分配单元(当第1次请求内存,以及现有的所有内存块中的所有内存分配单元都已经被分配时会发生这种情形),MemoryPool就会从进程堆中申请一个内存块(这个内存块包括一个MemoryBlock结构体,及紧邻其后的多个内存分配单元,假设内存分配单元的个数为n,n可以取值MemoryPool中的nInitSize或者nGrowSize),申请完后,并不会立刻将其中的一个分配单元分配出去,而是需要首先初始化这个内存块。
初始化的操作包括设置MemoryBlock的nSize为所有内存分配单元的大小(注意,并不包括MemoryBlock结构体的大小)、nFree为n-1(注意,这里是n-1而不是n,因为此次新内存块就是为了满足一次新的内存请求而申请的,马上就会分配一块自由存储单元出去,如果设为n-1,分配一个自由存储单元后无须再将n递减1),nFirst为1(已经知道nFirst为下一个可以分配的自由存储单元的编号。
为1的原因与nFree为n-1相同,即立即会将编号为0的自由分配单元分配出去。
现在设为1,其后不用修改nFirst的值),MemoryBlock的构造需要做更重要的事情,即将编号为0的分配单元之后的所有自由分配单元链接起来。
如前所述,每个自由分配单元的头两个字节用来存储下一个自由分配单元的编号。
另外,因为每个分配单元大小固定,所以可以通过其编号和单元大小(MemoryPool的nUnitSize成员)的乘积作为偏移值进行定位。
现在唯一的问题是定位从哪个地址开始?
答案是MemoryBlock的aData[1]成员开始。
因为aData[1]实际上是属于MemoryBlock结构体的(MemoryBlock结构体的最后一个字节),所以实质上,MemoryBlock结构体的最后一个字节也用做被分配出去的分配单元的一部分。
因为整个内存块由MemoryBlock结构体和整数个分配单元组成,这意味着内存块的最后一个字节会被浪费,这个字节在图6-2中用位于两个内存的最后部分的浓黑背景的小块标识。
确定了分配单元的起始位置后,将自由分配单元链接起来的工作就很容易了。
即从aData位置开始,每隔nUnitSize大小取其头两个字节,记录其之后的自由分配单元的编号。
因为刚开始所有分配单元都是自由的,所以这个编号就是自身编号加1,即位置上紧跟其后的单元的编号。
初始化后,将此内存块的第1个分配单元的起始地址返回,已经知道这个地址就是aData。
(5)当某个被分配的单元因为delete需要回收时,该单元并不会返回给进程堆,而是返回给MemoryPool。
返回时,MemoryPool能够知道该单元的起始地址。
这时,MemoryPool开始遍历其所维护的内存块链表,判断该单元的起始地址是否落在某个内存块的地址范围内。
如果不在所有内存地址范围内,则这个被回收的单元不属于这个MemoryPool;
如果在某个内存块的地址范围内,那么它会将这个刚刚回收的分配单元加到这个内存块的MemoryBlock所维护的自由分配单元链表的头部,同时将其nFree值递增1。
回收后,考虑到资源的有效利用及后续操作的性能,内存池的操作会继续判断:
如果此内存块的所有分配单元都是自由的,那么这个内存块就会从MemoryPool中被移出并作为一个整体返回给进程堆;
如果该内存块中还有非自由分配单元,这时不能将此内存块返回给进程堆。
但是因为刚刚有一个分配单元返回给了这个内存块,即这个内存块有自由分配单元可供下次分配,因此它会被移到MemoryPool维护的内存块的头部。
这样下次的内存请求到来,MemoryPool遍历其内存块链表以寻找自由分配单元时,第1次寻找就会找到这个内存块。
因为这个内存块确实有自由分配单元,这样可以减少MemoryPool的遍历次数。
综上所述,每个内存池(MemoryPool)维护一个内存块链表(单链表),每个内存块由一个维护该内存块信息的块头结构(MemoryBlock)和多个分配单元组成,块头结构MemoryBlock则进一步维护一个该内存块的所有自由分配单元组成的"
链表"
。
这个链表不是通过"
指向下一个自由分配单元的指针"
链接起来的,而是通过"
下一个自由分配单元的编号"
链接起来,这个编号值存储在该自由分配单元的头两个字节中。
另外,第1个自由分配单元的起始位置并不是MemoryBlock结构体"
后面的"
第1个地址位置,而是MemoryBlock结构体"
内部"
的最后一个字节aData(也可能不是最后一个,因为考虑到字节对齐的问题),即分配单元实际上往前面错了一位。
又因为MemoryBlock结构体后面的空间刚好是分配单元的整数倍,这样依次错位下去,内存块的最后一个字节实际没有被利用。
这么做的一个原因也是考虑到不同平台的移植问题,因为不同平台的对齐方式可能不尽相同。
即当申请MemoryBlock大小内存时,可能会返回比其所有成员大小总和还要大一些的内存。
最后的几个字节是为了"
补齐"
,而使得aData成为第1个分配单元的起始位置,这样在对齐方式不同的各种平台上都可以工作。
6.2.3细节剖析
有了上述的总体印象后,本节来仔细剖析其实现细节。
(1)MemoryPool的构造如下:
MemoryPool:
MemoryPool(USHORT_nUnitSize,
USHORT_nInitSize,USHORT_nGrowSize)
pBlock=NULL;
①
nInitSize=_nInitSize;
②
nGrowSize=_nGrowSize;
③
if(_nUnitSize>
4)
nUnitSize=(_nUnitSize+(MEMPOOL_ALIGNMENT-1))&
~(MEMPOOL_ALIGNMENT-1);
④
elseif(_nUnitSize<
=2)
nUnitSize=2;
⑤
else
nUnitSize=4;
}
从①处可以看出,MemoryPool创建时,并没有立刻创建真正用来满足内存申请的内存块,即内存块链表刚开始时为空。
②处和③处分别设置"
第1次创建的内存块所包含的分配单元的个数"
,及"
随后创建的内存块所包含的分配单元的个数"
,这两个值在MemoryPool创建时通过参数指定,其后在该MemoryPool对象生命周期中一直不变。
后面的代码用来设置nUnitSize,这个值参考传入的_nUnitSize参数。
但是还需要考虑两个因素。
如前所述,每个分配单元在自由状态时,其头两个字节用来存放"
其下一个自由分配单元的编号"
即每个分配单元"
最少"
有"
两个字节"
,这就是⑤处赋值的原因。
④处是将大于4个字节的大小_nUnitSize往上"
取整到"
大于_nUnitSize的最小的MEMPOOL_ALIGNMENT的倍数(前提是MEMPOOL_ALIGNMENT为2的倍数)。
如_nUnitSize为11时,MEMPOOL_ALIGNMENT为8,nUnitSize为16;
MEMPOOL_ALIGNMENT为4,nUnitSize为12;
MEMPOOL_ALIGNMENT为2,nUnitSize为12,依次类推。
(2)当向MemoryPool提出内存请求时:
void*MemoryPool:
Alloc()
if(!
pBlock)①
……
MemoryBlock*pMyBlock=pBlock;
while(pMyBlock&
&
!
pMyBlock->
nFree)②
pMyBlock=pMyBlock->
pNext;
if(pMyBlock)③
char*pFree=pMyBlock->
aData+(pMyBlock->
nFirst*nUnitSize);
pMyBlock->
nFirst=*((USHORT*)pFree);
nFree--;
return(void*)pFree;
else④
nGrowSize)
returnNULL;
pMyBlock=new(nGrowSize,nUnitSize)FixedMemBlock(nGrowSize,nUnitSize);
pMyBlock)
pNext=pBlock;
pBlock=pMyBlock;
return(void*)(pMyBlock->
aData);
MemoryPool满足内存请求的步骤主要由四步组成。
①处首先判断内存池当前内存块链表是否为空,如果为空,则意味着这是第1次内存申请请求。
这时,从进程堆中申请一个分配单元个数为nInitSize的内存块,并初始化该内存块(主要初始化MemoryBlock结构体成员,以及创建初始的自由分配单元链表,下面会详细分析其代码)。
如果该内存块申请成功,并初始化完毕,返回第1个分配单元给调用函数。
第1个分配单元以MemoryBlock结构体内的最后一个字节为起始地址。
②处的作用是当内存池中已有内存块(即内存块链表不为空)时遍历该内存块链表,寻找还有"
自由分配单元"
的内存块。
③处检查如果找到还有自由分配单元的内存块,则"
定位"
到该内存块现在可以用的自由分配单元处。
"
以MemoryBlock结构体内的最后一个字节位置aData为起始位置,以MemoryPool的nUnitSize为步长来进行。
找到后,需要修改MemoryBlock的nFree信息(剩下来的自由分配单元比原来减少了一个),以及修改此内存块的自由存储单元链表的信息。
在找到的内存块中,pMyBlock->
nFirst为该内存块中自由存储单元链表的表头,其下一个自由存储单元的编号存放在pMyBlock->
nFirst指示的自由存储单元(亦即刚才定位到的自由存储单元)的头两个字节。
通过刚才定位到的位置,取其头两个字节的值,赋给pMyBlock->
nFirst,这就是此内存块的自由存储单元链表的新的表头,即下一次分配出去的自由分配单元的编号(如果nFree大于零的话)。
修改维护信息后,就可以将刚才定位到的自由分配单元的地址返回给此次申请的调用函数。
注意,因为这个分配单元已经被分配,而内存块无须维护已分配的分配单元,因此该分配单元的头两个字节的信息已经没有用处。
换个角度看,这个自由分配单元返回给调用函数后,调用函数如何处置这块内存,内存池无从知晓,也无须知晓。
此分配单元在返回给调用函数时,其内容对于调用函数来说是无意义的。
因此几乎可以肯定调用函数在用这个单元的内存时会覆盖其原来的内容,即头两个字节的内容也会被抹去。
因此每个存储单元并没有因为需要链接而引入多余的维护信息,而是直接利用单元内的头两个字节,当其分配后,头两个字节也可以被调用函数利用。
而在自由状态时,则用来存放维护信息,即下一个自由分配单元的编号,这是一个有效利用内存的好例子。
④处表示在②处遍历时,没有找到还有自由分配单元的内存块,这时,需要重新向进程堆申请一个内存块。
因为不是第一次申请内存块,所以申请的内存块包含的分配单元个数为nGrowSize,而不再是nInitSize。
与①处相同,先做这个新申请内存块的初始化工作,然后将此内存块插入MemoryPool的内存块链表的头部,再将此内存块的第1个分配单元返回给调用函数。
将此新内存块插入内存块链表的头部的原因是该内存块还有很多可供分配的自由分配单元(除非nGrowSize等于1,这应该不太可能。
因为内存池的含义就是一次性地从进程堆中申请一大块内存,以供后续的多次申请),放在头部可以使得在下次收到内存申请时,减少②处对内存块的遍历时间。
可以用图6-2的MemoryPool来展示MemoryPool:
Alloc的过程。
图6-3是某个时刻MemoryPool的内部状态。
图6-3某个时刻MemoryPool的内部状态
因为MemoryPool的内存块链表不为空,因此会遍历其内存块链表。
又因为第1个内存块里有自由的分配单元,所以会从第1个内存块中分配。
检查nFirst,其值为m,这时pBlock->
aData+(pBlock->
nFirst*nUnitSize)定位到编号为m的自由分配单元的起始位置(用pFree表示)。
在返回pFree之前,需要修改此内存块的维护信息。
首先将nFree递减1,然后取得pFree处开始的头两个字节的值(需要说明的是,这里aData处值为k。
其实不是这一个字节。
而是以aData和紧跟其后的另外一个字节合在一起构成的一个USHORT的值,不可误会)。
发现为k,这时修改pBlock的nFirst为k。
然后,返回pFree。
此时MemoryPool的结构如图6-4所示。
图6-4MemoryPool的结构
可以看到,原来的第1个可供分配的单元(m编号处)已经显示为被分配的状态。
而pBlock的nFirst已经指向原来m单元下一个自由分配单元的编号,即k。
(3)MemoryPool回收内存时:
voidMemor