内存池技术

 看到一篇关于内存池技术的介绍文章,受益不浅,转贴至此。html

  原贴地址:http://www.ibm.com/developerworks/cn/linux/l-cn-ppp/index6.htmllinux

 6.1 自定义内存池性能优化的原理算法

  如前所述,读者已经了解到"堆"和"栈"的区别。而在编程实践中,不可避免地要大量用到堆上的内存。例如在程序中维护一个链表的数据结构时,每次新增或者删除一个链表的节点,都须要从内存堆上分配或者释放必定的内存;在维护一个动态数组时,若是动态数组的大小不能知足程序须要时,也要在内存堆上分配新的内存空间。编程

6.1.1 默认内存管理函数的不足数组

利用默认的内存管理函数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的声明以下:


class MemoryPool
{
private:
    MemoryBlock*   pBlock;
    USHORT          nUnitSize;
    USHORT          nInitSize;
    USHORT          nGrowSize;

public:
                     MemoryPool( USHORT nUnitSize,
                                  USHORT nInitSize = 1024,
                                  USHORT nGrowSize = 256 );
                    ~MemoryPool();

    void*           Alloc();
    void            Free( void* p );
};

MemoryBlock为内存池中附着在真正用来为内存请求分配内存的内存块头部的结构体,它描述了与之联系的内存块的使用信息:


struct MemoryBlock
{
    USHORT          nSize;
    USHORT          nFree;
    USHORT          nFirst;
    USHORT          nDummyAlign1;
    MemoryBlock*  pNext;
    char            aData[1];

	static void* operator new(size_t, USHORT nTypes, USHORT nUnitSize)
	{
		return ::operator new(sizeof(MemoryBlock) + nTypes * nUnitSize);
	}
	static void  operator delete(void *p, size_t)
	{
		::operator delete (p);
	}

	MemoryBlock (USHORT nTypes = 1, USHORT nUnitSize = 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); ④
    else if ( _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);
							
        pMyBlock->nFree--;	
        return (void*)pFree;
    }
    else                    ④
    {
        if ( !nGrowSize )
            return NULL;

		pMyBlock = new(nGrowSize, nUnitSize) FixedMemBlock(nGrowSize, nUnitSize);
        if ( !pMyBlock )
            return NULL;

        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-4 MemoryPool的结构


图6-4 MemoryPool的结构

能够看到,原来的第1个可供分配的单元(m编号处)已经显示为被分配的状态。而pBlock的nFirst已经指向原来m单元下一个自由分配单元的编号,即k。

(3)MemoryPool回收内存时:


void MemoryPool::Free( void* pFree )
{
    ……

    MemoryBlock* pMyBlock = pBlock;

    while ( ((ULONG)pMyBlock->aData > (ULONG)pFree) ||
         ((ULONG)pFree >= ((ULONG)pMyBlock->aData + pMyBlock->nSize)) )①
    {
         ……
    }

    pMyBlock->nFree++;                     ②
    *((USHORT*)pFree) = pMyBlock->nFirst;  ③
    pMyBlock->nFirst = (USHORT)(((ULONG)pFree-(ULONG)(pBlock->aData)) / nUnitSize);④

    if (pMyBlock->nFree*nUnitSize == pMyBlock->nSize )⑤
    {
        ……
    }
    else
    {
        ……
    }
}

如前所述,回收分配单元时,可能会将整个内存块返回给进程堆,也可能将被回收分配单元所属的内存块移至内存池的内存块链表的头部。这两个操做都须要修改链表结构。这时须要知道该内存块在链表中前一个位置的内存块。

①处遍历内存池的内存块链表,肯定该待回收分配单元(pFree)落在哪个内存块的指针范围内,经过比较指针值来肯定。

运行到②处,pMyBlock即找到的包含pFree所指向的待回收分配单元的内存块(固然,这时应该还须要检查pMyBlock为NULL时的情 形,即pFree不属于此内存池的范围,所以不能返回给此内存池,读者能够自行加上)。这时将pMyBlock的nFree递增1,表示此内存块的自由分 配单元多了一个。

③处用来修改该内存块的自由分配单元链表的信息,它将这个待回收分配单元的头两个字节的值指向该内存块原来的第一个可分配的自由分配单元的编号。

④处将pMyBlock的nFirst值改变为指向这个待回收分配单元的编号,其编号经过计算此单元的起始位置相对pMyBlock的aData位置的差值,而后除以步长(nUnitSize)获得。

实质上,③和④两步的做用就是将此待回收分配单元"真正回收"。值得注意的是,这两步其实是使得此回收单元成为此内存块的下一个可分配的自由分配 单元,即将它放在了自由分配单元链表的头部。注意,其内存地址并无发生改变。实际上,一个分配单元的内存地址不管是在分配后,仍是处于自由状态时,一直 都不会变化。变化的只是其状态(已分配/自由),以及当其处于自由状态时在自由分配单元链表中的位置。

⑤处检查当回收完毕后,包含此回收单元的内存块的全部单元是否都处于自由状态,且此内存是否处于内存块链表的头部。若是是,将此内存块整个的返回给进程堆,同时修改内存块链表结构。

注意,这里在判断一个内存块的全部单元是否都处于自由状态时,并无遍历其全部单元,而是判断nFree乘以nUnitSize是否等于 nSize。nSize是内存块中全部分配单元的大小,而不包括头部MemoryBlock结构体的大小。这里能够看到其用意,即用来快速检查某个内存块 中全部分配单元是否所有处于自由状态。由于只需结合nFree和nUnitSize来计算得出结论,而无须遍历和计算全部自由状态的分配单元的个数。

另外还需注意的是,这里并不能比较nFree与nInitSize或nGrowSize的大小来判断某个内存块中全部分配单元都为自由状态,这是因 为第1次分配的内存块(分配单元个数为nInitSize)可能被移到链表的后面,甚至可能在移到链表后面后,由于某个时间其全部单元都处于自由状态而被 整个返回给进程堆。即在回收分配单元时,没法断定某个内存块中的分配单元个数究竟是nInitSize仍是nGrowSize,也就没法经过比较 nFree与nInitSize或nGrowSize的大小来判断一个内存块的全部分配单元是否都为自由状态。

以上面分配后的内存池状态做为例子,假设这时第2个内存块中的最后一个单元须要回收(已被分配,假设其编号为m,pFree指针指向它),如图6-5所示。

不难发现,这时nFirst的值由原来的0变为m。即此内存块下一个被分配的单元是m编号的单元,而不是0编号的单元(最早分配的是最新回收的单 元,从这一点看,这个过程与栈的原理相似,即先进后出。只不过这里的"进"意味着"回收",而"出"则意味着"分配")。相应地,m的"下一个自由单元" 标记为0,即内存块原来的"下一个将被分配出去的单元",这也代表最近回收的分配单元被插到了内存块的"自由分配单元链表"的头部。固然,nFree递增 1。

图6-5 分配后的内存池状态

处理至⑥处以前,其状态如图6-6所示。

这里须要注意的是,虽然pFree被"回收",可是pFree仍然指向m编号的单元,这个单元在回收过程当中,其头两个字节被覆写,但其余部分的内容 并无改变。并且从整个进程的内存使用角度来看,这个m编号的单元的状态仍然是"有效的"。由于这里的"回收"只是回收给了内存池,而并无回收给进程 堆,所以程序仍然能够经过pFree访问此单元。可是这是一个很危险的操做,由于首先该单元在回收过程当中头两个字节已被覆写,而且该单元可能很快就会被内 存池从新分配。所以回收后经过pFree指针对这个单元的访问都是错误的,读操做会读到错误的数据,写操做则可能会破坏程序中其余地方的数据,所以须要格 外当心。

接着,须要判断该内存块的内部使用状况,及其在内存块链表中的位置。若是该内存块中省略号"……"所表示的其余部分中还有被分配的单元,即nFree乘以nUnitSize不等于nSize。由于此内存块不在链表头,所以还须要将其移到链表头部,如图6-7所示。

图6-7 因回收引发的MemoryBlock移动

若是该内存块中省略号"……"表示的其余部分中所有都是自由分配单元,即nFree乘以nUnitSize等于nSize。由于此内存块不在链表头,因此此时须要将此内存块整个回收给进程堆,回收后内存池的结构如图6-8所示。


图6-8 回收后内存池的结构

一个内存块在申请后会初始化,主要是为了创建最初的自由分配单元链表,下面是其详细代码:


MemoryBlock::MemoryBlock (USHORT nTypes, USHORT nUnitSize)
	: nSize  (nTypes * nUnitSize),
	  nFree  (nTypes - 1),                     ④
	  nFirst (1),                              ⑤
	  pNext  (0)
{
		char * pData = aData;                  ①
		for (USHORT i = 1; i < nTypes; i++) ②
		{
			*reinterpret_cast<USHORT*>(pData) = i; ③
			pData += nUnitSize;
		}
}

这里能够看到,①处pData的初值是aData,即0编号单元。可是②处的循环中i倒是从1开始,而后在循环内部的③处将pData的头两个字节 值置为i。即0号单元的头两个字节值为1,1号单元的头两个字节值为2,一直到(nTypes-2)号单元的头两个字节值为(nTypes-1)。这意味 着内存块初始时,其自由分配单元链表是从0号开始。依次串联,一直到倒数第2个单元指向最后一个单元。

还须要注意的是,在其初始化列表中,nFree初始化为nTypes-1(而不是nTypes),nFirst初始化为1(而不是0)。这是由于第 1个单元,即0编号单元构造完毕后,马上会被分配。另外注意到最后一个单元初始并无设置头两个字节的值,由于该单元初始在本内存块中并无下一个自由分 配单元。可是从上面例子中能够看到,当最后一个单元被分配并回收后,其头两个字节会被设置。

图6-9所示为一个内存块初始化后的状态。

图6-9 一个内存块初始化后的状态

当内存池析构时,须要将内存池的全部内存块返回给进程堆:


MemoryPool::~MemoryPool()
{
    MemoryBlock* pMyBlock = pBlock;
    while ( pMyBlock )
    {
        ……
    }
}

6.2.4 使用方法

分析内存池的内部原理后,本节说明如何使用它。从上面的分析能够看到,该内存池主要有两个对外接口函数,即Alloc和Free。Alloc返回所 申请的分配单元(固定大小内存),Free则回收传入的指针表明的分配单元的内存给内存池。分配的信息则经过MemoryPool的构造函数指定,包括分 配单元大小、内存池第1次申请的内存块中所含分配单元的个数,以及内存池后续申请的内存块所含分配单元的个数等。

综上所述,当须要提升某些关键类对象的申请/回收效率时,能够考虑将该类全部生成对象所需的空间都从某个这样的内存池中开辟。在销毁对象时,只须要 返回给该内存池。"一个类的全部对象都分配在同一个内存池对象中"这一需求很天然的设计方法就是为这样的类声明一个静态内存池对象,同时为了让其全部对象 都从这个内存池中开辟内存,而不是缺省的从进程堆中得到,须要为该类重载一个new运算符。由于相应地,回收也是面向内存池,而不是进程的缺省堆,还须要 重载一个delete运算符。在new运算符中用内存池的Alloc函数知足全部该类对象的内存请求,而销毁某对象则能够经过在delete运算符中调用 内存池的Free完成。

6.2.5 性能比较

为了测试利 用内存池后的效果,经过一个很小的测试程序能够发现采用内存池机制后耗时为297 ms。而没有采用内存池机制则耗时625 ms,速度提升了52.48%。速度提升的缘由能够归结为几点,其一,除了偶尔的内存申请和销毁会致使从进程堆中分配和销毁内存块外,绝大多数的内存申请 和销毁都由内存池在已经申请到的内存块中进行,而没有直接与进程堆打交道,而直接与进程堆打交道是很耗时的操做;其二,这是单线程环境的内存池,能够看到 内存池的Alloc和Free操做中并无加线程保护措施。所以若是类A用到该内存池,则全部类A对象的建立和销毁都必须发生在同一个线程中。但若是类A 用到内存池,类B也用到内存池,那么类A的使用线程能够没必要与类B的使用线程是同一个线程。

另外,在第1章中已经讨论过,由于内存池技术使得同类型的对象分布在相邻的内存区域,而程序会常常对同一类型的对象进行遍历操做。所以在程序运行过程当中发生的缺页应该会相应少一些,但这个通常只能在真实的复杂应用环境中进行验证。

 


图6-7 因回收引发的MemoryBlock移动

6.3 本章小结

内存的申请和释放对一个应用程序的总体性能影响极大,甚至在不少时候成为某个应用程序的瓶颈。消除内存申请和释放引发的瓶颈的方法每每是针对内存使 用的实际状况提供一个合适的内存池。内存池之因此可以提升性能,主要是由于它可以利用应用程序的实际内存使用场景中的某些"特性"。好比某些内存申请与释 放确定发生在一个线程中,某种类型的对象生成和销毁与应用程序中的其余类型对象要频繁得多,等等。针对这些特性,能够为这些特殊的内存使用场景提供量身定 作的内存池。这样可以消除系统提供的缺省内存机制中,对于该实际应用场景中的没必要要的操做,从而提高应用程序的总体性能。



图6-6 处理至⑥处以前的内存池状态


图6-5 分配后的内存池状态

相关文章
相关标签/搜索