一 为什么要使用内存池技术
主要有两个原因:1、减少new、delete次数,减少运行时间;2、避免内存碎片。
1、效率
c语言中使用malloc/free来分配内存,c++中使用new/delete来分配内存,他们的内存申请与释放都是与操作系统进行交互的。具体的内容在严蔚敏数据结构的第八章有相关讲述,主要就是系统要维护一个内存链表,当有一个内存申请过来时,根据相应的分配算法在链表中找个一个合适的内存分配给它。这些算法有的是分配最先找到的不小于申请内存的内存块,有的是分配最大的内存块,有的是分配最接近申请内存大小的内存块。分配的内存块可能会大于所申请的内存大小,这样还有进行切割,将剩余的内存插入到空闲链表中。当释放的时候,系统可能要对内存进行整理,判断free的内存块的前后是否有空闲,若有的话还要进行合并。此外,new/delete还要考虑多线程的情况。总之一句话,调用库中的内存分配函数,十分的耗时~~
2、内存碎片
什么是内存碎片内,从字面意思就很好理解了,就是内存不再是一整块的了,而是碎了。因为连续的这种new/delete操作,一大块内存肯能就被分割成小的内存分配出去了,这些小的内存都是不连续的。当你再去分配大的连续内存的时候,尽管剩余内存的总和可能大于所要分配的内存大小,但系统就找不到连续的内存了,所以导致分配错误。malloc的时候会导致返回null,而new的时候再vc6.0中返回null,vs2003以上则是抛出异常。
二 原理
要解决上述两个问题,最好的方法就是内存池技术。具体方法就是大小固定、提前申请、重复利用。
因为内存的申请和释放是很低效的,所以我们只在开始时申请一块大的内存(在该块内存不够用时在二次分配),然后每次需要时都从这块内存中取出,并标记下这块内存被用了,释放时标记此内存被释放了。释放时,并不真的把内存释放给操作系统,只要在一大块内存都空闲的时候,才释放给操作系统。这样,就减少了new/delete的操作次数,从而提高了效率。
在调用内存分配函数的时候,大部分时间所分配的内存大小都是一定的,所以可以采用每次都分配固定大小的内存块,这样就避免了内存碎片产生的可能。
三 实现机制描述
(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个分配单元的起始位置,这样在对齐方式不同的各种平台上都可以工作。