分析高效記憶體池的實現方式

weixin_34320159發表於2018-06-06

1. 記憶體池的目的

  • 提高程式效率
  • 減少執行時間
  • 避免記憶體碎片

為什麼會產生記憶體碎片?
1.內部碎片是採用固定大小的記憶體分割槽,當一個程式不能完全使用分配給它的固定記憶體分割槽時,就會產生記憶體碎片。
2.外部碎片是由於可分配的連續記憶體太小,不能滿足任何程式的需求,從而不能被程式使用。

再扯一個概念:段頁式記憶體分配方式
將程式的記憶體區域分成不同的段,段由多個固定大小的頁組成。那麼通過頁表機制,段內的頁就不需要儲存在同一塊記憶體區域

11319096-f15a3f146c9592e7.png
段頁式記憶體分配方式

預設記憶體管理
當我們呼叫new在堆上分配一些記憶體的時候,系統收到了分配記憶體的請求,就會根據一定的演算法在空閒記憶體塊裡尋找適合該記憶體大小的記憶體塊。如果記憶體塊過大,就會將記憶體塊分割成適合的記憶體塊。釋放記憶體塊後,記憶體塊就會重新被放入空閒記憶體塊中。預設記憶體管理裡還用到了多執行緒的應用,每次分配和釋放記憶體的時候都需要加鎖,這樣就損耗了效能。

可見,如果應用程式頻繁地在堆上分配和釋放記憶體,則會導致效能的損失,並且會使系統中出現大量的記憶體碎片,降低記憶體的利用率

2. 記憶體池的定義和分類

2.1 定義

顧名思義,我們在系統申請一塊適合的記憶體作為記憶體池,應用程式對記憶體的分配和釋放都在這個記憶體池裡實現,只有當記憶體池不夠大需要動態擴增的時候才會呼叫系統的分配記憶體函式,其他時間對記憶體的一切操作都是用應用程式控制的。

2.2 分類

從執行緒安全形度看,有多執行緒記憶體池單執行緒記憶體池
從記憶體池可分配大小來看,有固定大小記憶體池(在這裡的固定指的是每一次和系統申請的記憶體的大小都是固定的,而不是這個記憶體池大小隻能那麼大)和可變記憶體池

3. 固定記憶體池

3.1 固定記憶體池的簡要理解

固定記憶體池由一系列固定大小的記憶體塊組成,每一個記憶體塊裡又包含了固定數量和大小的記憶體單元。其實在記憶體池初次生成的時候,我們只是向系統申請了一塊記憶體塊,返回一個指標作為整個記憶體池的頭指標。後面隨著記憶體池的不斷擴大,我們通過指標將記憶體池連線在一起。

因為每一次系統都是分配固定大小的記憶體,所以系統的分配效率高。

11319096-a1be20dbc0ac6e97.png
固定記憶體池

固定記憶體池就像一個連結串列一樣,將記憶體塊一個一個聯絡到一起。
當我們要需要一個記憶體單元的時候,就會隨著連結串列去檢視每一個記憶體塊的頭資訊,如果記憶體塊裡有空閒的記憶體單元,將該地址返回,並且將頭資訊裡的空閒單元改成下一個空閒單元。
當應用程式釋放某記憶體單元,就會到對應的記憶體塊的頭資訊裡修改該記憶體單元為空閒單元。

3.2 固定記憶體池的優點(效能優化方面)
  1. 記憶體池中的記憶體塊大小相等,所以在分配的時候不需要太複雜的演算法和多執行緒的保護,也減少了維護系統空閒記憶體表的開銷
  2. 單個記憶體池塊是連續的記憶體空間,提升了程式效能。
  3. 申請的記憶體塊大小相等,有利於控制頁邊界對齊和記憶體對齊
3.3 固定記憶體池的實現
3.3.1 MemoryPool和MemoryBlock宣告
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 );
};
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() {}
};
3.3.2 該記憶體池總體機制的理解
  1. 有一個MemoryPool類作為記憶體池,它擁有一個指向第一個MemoryBlock的頭指標。當我們有許多記憶體塊的時候,一個記憶體塊由pNext指標指向下一個記憶體塊。

  2. 記憶體塊由記憶體塊結構體與記憶體單元構成,這些記憶體單元的大小固定(在這裡用nSize表示),MemoryBlock結構體不維護那些已經分配的記憶體單元的資訊,它的nFree成員記錄未分配記憶體單元的數量nFirst記錄第一個未分配的記憶體單元的編號,每個記憶體單元編號的前兩個位元組記錄了緊跟它的下一個記憶體單元的編號,這樣記憶體單元就一個個被連結起來。

  3. 當有新的記憶體請求時,記憶體池會使用pBlock去遍歷記憶體塊,查詢記憶體塊中nFree大於0的記憶體塊,找到了對應的記憶體塊後,我們再根據nFirst找到第一個空閒的記憶體單元,在返回這個記憶體單元的地址之前,將nFirst的值改為取到的記憶體單元的前兩個位元組的值(也就是它的下一個記憶體單元),再將nFree減1,最後才將剛才定位到的記憶體地址返回給呼叫者。

  4. 如果現有記憶體塊找不到空閒記憶體單元,MemoryPool就會從堆上申請分配一個記憶體塊,立即進行初始化(nSize為所有記憶體單元的大小,nFree為n-1,因為立馬要分配一個空閒單元,所以就先減1,nFirst為1,因為為0的馬上就要分配出去了),MemoryBlock的建構函式主要的作用是將編號為0之後的記憶體單元連結在一起。因為每個記憶體單元大小固定(為MemoryPool的nUnitSize),所以要定位到記憶體單元就通過它的頭兩位與記憶體單元大小的乘積作為偏移值進行定位。那麼定位要從哪個地方開始呢?思考一下我們的aData[1]的作用,它是MemoryBlock結構體的最後一個位元組。所以實質上,MemoryBlock結構體的最後一個位元組也用做被分配出去的分配單元的一部分。因為整個記憶體塊由MemoryBlock結構體和整數個分配單元組成,這意味著記憶體塊的最後一個位元組會被浪費。所以我們可以從aData[1]的位置開始,每個nUnitSize大小取其頭兩位元組,記錄它後面自由單元的序號。因為剛開始所有分配單元都是自由的,所以這個編號就是自身編號加1,即位置上緊跟其後的單元的編號。

  5. 當記憶體被釋放的時候,記憶體重新回到記憶體池。MemoryPool根據記憶體單元的地址遍歷所有記憶體塊,判斷該記憶體單元是否在記憶體塊的範圍內。注意重新加回去的時候,MemoryBlock的nFree+1,nFirst可能會改變。如果這個記憶體塊內都是空閒的,那麼就會將它返回給堆。因為這個記憶體單元被放入記憶體塊,那麼證明這個記憶體塊一定有空閒空間,所以我們將頭指標指向該記憶體塊,方便下一次查詢。

3.3.3 細節剖析
11319096-451dc4bbf05d28ac.png
MemoryPool的建構函式

每個分配單元在自由狀態時,其頭兩個位元組用來存放"其下一個自由分配單元的編號"。即每個分配單元"最少"有"兩個位元組",這就是⑤處賦值的原因。④處是將大於4個位元組的大小_nUnitSize往上"取整到"大於nUnitSize的最小的MEMPOOL ALIGNMENT的倍數(前提是MEMPOOL_ALIGNMENT為2的倍數)。如_nUnitSize為11時,MEMPOOL_ALIGNMENT為8,nUnitSize為16;MEMPOOL_ALIGNMENT為4,nUnitSize為12;

當向MemoryPool提出記憶體請求時

void* MemoryPool::Alloc()
{
    if ( !pBlock )           //1
    {
            ……                          
    }

    MemoryBlock* pMyBlock = pBlock;
    while (pMyBlock && !pMyBlock->nFree )//2
        pMyBlock = pMyBlock->pNext;

    if ( pMyBlock )         //3
    {
        char* pFree = pMyBlock->aData+(pMyBlock->nFirst*nUnitSize);         
        pMyBlock->nFirst = *((USHORT*)pFree);
                            
        pMyBlock->nFree--;  
        return (void*)pFree;
    }
    else                    //4
    {
        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回收記憶體時

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

    MemoryBlock* pMyBlock = pBlock;

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

    pMyBlock->nFree++;                    //2
    *((USHORT*)pFree) = pMyBlock->nFirst;  //3
    pMyBlock->nFirst = (USHORT)(((ULONG)pFree-(ULONG)(pBlock->aData)) / nUnitSize);//4

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

一個分配單元的記憶體地址無論是在分配後,還是處於自由狀態時,一直都不會變化。變化的只是其狀態(已分配/自由),以及當其處於自由狀態時在自由分配單元連結串列中的位置。

參考:
記憶體池技術介紹(圖文並茂,非常清楚)
C++ 實現高效能記憶體池
極高效記憶體池實現 (cpu-cache)
固定大小塊的記憶體池設計

相關文章