内存池设计与实现总结

Posted TechMap

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了内存池设计与实现总结相关的知识,希望对你有一定的参考价值。

一、优化原理

       c++编程实践中,不可避免地用到大量堆上内存。例如在程序中维护一个图的数据结构时,每次新增或者删除一个图的节点,都需要从内存堆上分配或者释放一定的内存;在维护一个动态数组时,如果动态数组的大小不能满足程序需要时,也要在内存堆上分配新的内存空间。

1.1 默认内存管理的不足

       默认的内存管理函数new/delete或malloc/free在堆上分配和释放内存会产生额外的开销。

  •        链状结构方面。当操作系统接收到分配内存请求时,首先查找内部维护的内存空闲块表,并且需要根据一定的算法(例如分配最先找到的不小于申请大小的内存块给请求者,或者分配最适于申请大小的内存块,或者分配最大空闲的内存块等)找到合适大小的空闲内存块;然后将已有内存块切割成已分配和空闲两部分;最后,系统更新内存空闲块表,完成一次内存分配。同理,释放内存时,系统把释放的内存块重新加入到空闲内存块表中。如果有可能的话,合并相邻的空闲块。

  •       内存管理方面。 默认的内存管理函数涉及线程安全问题,每次分配和释放内存时加锁,增加了系统开销。

      综上,如果应用程序频繁地在堆上分配和释放内存,会导致性能的损失,且会产生大量的内存碎片,降低内存的利用率。虽然,默认的分配和释放优化了相关算法,但这些内存管理算法面向通用、复杂、广泛的情况,需要额外工作。因此,设计实现适合自身特定的自定义内存池则可以获得更好的性能。

1.2  内存池的定义和分类

      应用程序可以通过系统的内存分配调用,预先一次性申请适当大小的内存作为一个内存池,之后应用程序对内存的分配和释放则可以通过这个内存池来完成。只有当内存池大小需要动态扩展时,再次调用系统的内存分配函数,其他时间对内存的一切操作都在内存池中。

      线程安全的角度,分为单线程内存池和多线程内存池。单线程内存池整个生命周期只被一个线程使用,不需要考虑互斥访问题;多线程内存池有可能被多个线程共享,需要在每次分配和释放内存时加锁。

      内存池可分配内存单元大小的角度,分为固定内存池和可变内存池。前者指应用程序每次从内存池中分配出来的内存单元大小事先已经确定,是固定不变的;后者指每次分配的内存单元大小可以按需变化,应用范围更广,而性能比固定内存池要低。

1.3 内存池工作原理示例

      下面以可变内存池为例说明内存池的工作原理,内存池由一系列固定大小的内存块组成,每一个内存块又包含了固定数量和大小的内存单元。内存池初次生成时,只向系统申请了一个内存块,返回的指针作为整个内存池的头指针。之后随着应用程序对内存的不断需求,内存池判断需要动态扩大时,再次向系统申请新的内存块,并把所有这些内存块通过指针链接起来。

  • 针对特殊情况,例如需要频繁分配释放固定大小的内存对象时,不需要复杂的分配算法和多线程保护,也不需要维护内存空闲表的额外开销,从而获得较高的性能;

  • 由于开辟一定数量的连续内存空间作为内存池块,因而一定程度上提高了程序局部性,提升了程序性能;

  • 比较容易控制页边界对齐和内存字节对齐,解决了内存碎片问题。

二、实现机理

 2.1 代码展示(Show me the code,SMC)

//内存块
struct MemoryBlock
{
    int             m_nSize;          //内存单元总个数
    int             m_nFree;          //剩余内存单元个数
    int             m_nFirst;         //第一个空闲内存单元
    MemoryBlock*    m_pNext;          //下一个内存块的地址
    BYTE            m_aData[1];       //内存单元起始地址
};
// 空闲单元头
struct SecHead{
    int   m_nSecNext;       //下一个空闲片
    int   m_nSecUnitCount;  //此空闲片的单元数
};
//内存池
class MemoryPool
{
private:
    MemoryBlock*   m_pBlock;           //起始内存块地址
    int            m_nUnitSize;        //每一个内存单元的大小
    int            m_nInitUnitCount;   //初始内存块中内存单元个数
    int            m_nGrowUnitCount;   //新增内存块中内存单元个数
    int            m_nBlockCount;      //内存块个数 

    int            m_nMaxBlockCound;   //内存块个数上限 
};

2.2 总体机制

(1)在运行过程中,MemoryPool内存池可能会有多个用来满足内存申请请求的内存块,这些内存块是从进程堆中开辟的一个较大的连续内存区域,它由一个MemoryBlock结构体和多个可供分配的内存单元组成,所有内存块组成一个内存块链表,MemoryPool的m_pBlock是这个链表的头。对每个内存块,都可以通过其头部的MemoryBlock结构体的m_pNext成员访问紧跟在其后面的那个内存块。

(2)每个内存块由两部分组成,即一个MemoryBlock结构体和多个内存分配单元。这些内存分配单元大小固定(由MemoryPool的m_nUnitSize表示),MemoryBlock结构体并不维护已经分配的单元的信息;相反,只维护没有分配的自由分配单元信息。成员中m_nFree记录内存块中的自由分配单元,而m_nFirst则记录下一个可供分配的单元的编号。每一个自由分配单元的头(SecHead)记录了下一个自由分配单元的编号,这样内存块中的所有自由分配单元被链接起来。

2.3 细节剖析

1.初始化

内存池设计与实现总结

       每个分配单元在自由状态时,其头两个字节用来存放"下一个自由分配单元的编号"和“空闲单元数目"。即每个分配单元"最少"有"四个字节"。

2.内存分配

       首先,定位到该内存块现在可以用的自由分配单元处,然后修改MemoryBlock的nFree信息,以及修改此内存块的自由存储单元链表的信息。分配后,内存单元头4个字节也被调用函数利用,而在自由状态时,则用来存放维护信息,即下一个自由分配单元的编号。

3.内存回收

       首先,遍历内存块链表,确定该待回收分配单元所属内存块指针,并获得前序和后序共闲内存单元;然后更新内存单元数据,合并相连空闲单元;最后更新内存块中的初始空闲单元以及再次链接内存块中的空间单元。

2.4 使用方法

       内存池主要有两个对外接口函数,即alloc和free。alloc返回所申请的分配单元,free则将传入的指针代表的分配单元的内存回收给内存池。 

三、总结

      内存的申请和释放对一个应用程序的整体性能影响极大,甚至会成为应用程序的瓶颈。为了消除内存申请和释放引起的瓶颈,往往需要针对内存使用的实际情况提供一个合适的内存池。利用应用程序内存实际使用场景中的某些"特性",消除系统内存机制中不必要的操作,从而提升应用程序的整体性能。


 





以上是关于内存池设计与实现总结的主要内容,如果未能解决你的问题,请参考以下文章

细节拉满,80 张图带你一步一步推演 slab 内存池的设计与实现

实现高并发内存池

Golang多级内存池设计与实现

高并发内存池——基于Google开源项目tcmalloc的简洁实现

高并发内存池——基于Google开源项目tcmalloc的简洁实现

高并发内存池——基于Google开源项目tcmalloc的简洁实现