1. 程式人生 > >STL空間配置器allocator詳解

STL空間配置器allocator詳解

stl六大元件簡介

我們知道,stl有容器,空間配置器,介面卡,迭代器,仿函式以及演算法這6個元件,它們六者關係大概如下:容器通過配置器取得資料儲存空間,演算法通過迭代器獲取容器內容,仿函式可以協助演算法完成不同的策略變化,配接器可以修飾或套界仿函式。

侯捷在《STL原始碼剖析》一書講到:

這裡寫圖片描述

因此我們需要先去學習空間配置器。
預備知識

一般來說,我們習慣的C++記憶體配置和釋放操作是這樣的:

class A {};
A* pa = new A;
//...執行其他操作
delete pa;

這裡面隱含幾個操作,對於new,我們都是先配置記憶體,然後呼叫對應的建構函式;而delete則是先呼叫對應的解構函式,然後釋放記憶體。

這裡寫圖片描述
在這裡,我們先不去看物件的構造和釋放,而將注意力放在記憶體的配置和釋放,也就是alloc這個檔案上,去研究它的程式碼。

在SGI版本的STL中,空間的配置釋放都由< stl_alloc.h > 負責。它的設計思想如下:

    向system heap要求空間
    考慮多執行緒
    考慮記憶體不足的應變措施
    考慮記憶體碎片的問題

PS:關於記憶體碎片,下圖可以解釋:

這裡寫圖片描述
SGI兩層配置器

由於以上的問題,SGI設計了兩層的配置器,也就是第一級配置器和第二級配置器。同時為了自由選擇,STL又規定了 __USE_MALLOC 巨集,如果它存在則直接呼叫第一級配置器,不然則直接呼叫第二級配置器。SGI未定義該巨集,也就是說預設使用第二級配置器

這裡寫圖片描述

需要注意的是,SGI版STL提供了一層更高階的封裝,定義了一個simple _ alloc類,無論是用哪一級都以模板引數alloc傳給simple _ alloc,這樣對外體現都是隻是simple _ alloc

而它的程式碼實現比較簡單,僅僅是呼叫一級或者二級配置器的介面

template<class T, class Alloc = AllocToUse>
class SimpleAlloc
{
public:
    static T* Allocate()
    {
        return (T*)Alloc::Allocate(sizeof(T));
    }

    static T* Allocate(size_t n)
    {
        return n == 0 ? 0 : (T*)Alloc::Allocate(n * sizeof(T));
    }

    static void Deallocate(T* p)
    {
        if (p != NULL)
            return Alloc::Deallocate(p, sizeof(T));
    }

    static void Deallocate(T* p, size_t n)
    {
        return Alloc::Deallocate(p, n * sizeof(T));
    }

};

第一級配置器

直接呼叫malloc和free來配置釋放記憶體,簡單明瞭。

template<int Inst>
class __MallocAllocTemplate //一級空間配置器
{
    typedef void (*OOM_HANDLER)();
private:
    //these funs below are used for "OOM" situations
    //OOM = out of memory
    static void* OOM_Malloc(size_t n); //function
    static void* OOM_Realloc(void *p, size_t newSZ); //function
    static OOM_HANDLER OOM_Handler; //function pointer

public:
    static void* Allocate(size_t n)
    {
        void* ret = malloc(n);
        if (ret == NULL)
            ret = OOM_Malloc(n);
        return ret;
    }

    static void Deallocate(void* p, size_t n)
    {
        free(p);
    }

    static void* Reallocate(void* p, size_t oldSZ, size_t newSZ)
    {
        void* ret = realloc(p, newSZ);
        if (ret == NULL)
            ret = OOM_Realloc(p, newSZ);
        return ret;
    }
    //static void (* set_malloc_handler(void (*f)()))()
    //引數和返回值都是函式指標void (*)()
    static OOM_HANDLER SetMallocHandler(OOM_HANDLER f)
    {
        OOM_HANDLER old = OOM_Handler;
        OOM_Handler = f;
        return old;
    }
};

//讓函式指標為空
template<int Inst>
void (*__MallocAllocTemplate<Inst>::OOM_Handler)() = NULL;

template<int Inst>
void* __MallocAllocTemplate<Inst>::OOM_Malloc(size_t n)
{
    void* ret = NULL;
    void(*myHandler)() = NULL;
    for (;;)
    {
        myHandler = OOM_Handler;
        if (myHandler == NULL)
            throw bad_alloc();
        (*myHandler)();
        ret = malloc(n);
        if (ret != NULL)
            return ret;
    }
}

template<int Inst>
void* __MallocAllocTemplate<Inst>::OOM_Realloc(void* p, size_t newSZ)
{
    void* ret = NULL;
    void(*myHandler)() = NULL;
    for (;;)
    {
        myHandler = OOM_Handler;
        if (myHandler == NULL)
            throw bad_alloc();
        (*myHandler)();
        ret = realloc(p, newSZ);
        if (ret != NULL)
            return ret;
    }
}


typedef __MallocAllocTemplate<0> MallocAlloc; //一級空間配置重新命名

第二級配置器

根據情況來判定,如果配置區塊大於128bytes,說明“足夠大”,呼叫第一級配置器,而小於等於128bytes,則採用複雜記憶體池(memory pool)來管理。

圖示如下:

這裡寫圖片描述

第二級空間配置器的過程,我們重點可以看allocate和deallocate這兩個函式的實現

static void* Allocate(size_t n)
{
    if (n > (size_t)__MAX_BYTES) // 位元組數大於128,呼叫一級空間配置器
        return MallocAlloc::Allocate(n);
    //不然到freelist去找
    Obj* volatile* myFreeList = FreeList + FreeListIndex(n); //定位下標
    Obj* ret = *myFreeList;
    if (ret == NULL)
    {
        void* r = Refill(RoundUP(n));//沒有可用free list 準備裝填
    }
    *myFreeList = ret->freeListLink;
    return ret;
}

可以看出來:

    如果使用者需要的區塊大於128,則直接呼叫第一級空間配置器
    如果使用者需要的區塊大於128,則到自由連結串列中去找
        如果自由連結串列有,則直接去取走
        不然則需要裝填自由連結串列(Refill)

static void Deallocate(void* p, size_t n)
{
    if (n > (size_t)__MAX_BYTES) //區塊大於128, 則直接由第一級空間配置器收回
        MallocAlloc::Deallocate(p, n);
    Obj* volatile* myFreeList = FreeList + FreeListIndex(n);
    Obj* q = (Obj*)p;
    q->freeListLink = *myFreeList;
    *myFreeList = q;
}

釋放操作和上面有點類似:

    如果區塊大於128, 則直接由第一級空間配置器收回
    如果區塊小於等於128, 則有自由連結串列收回

我們在上面重點分析了整體思路,也就是二級配置器如何配置和是否記憶體,他們和一級配置器一樣都提供Allocate和Deallocate的介面(其實還有個Reallocate也是用於分配記憶體,類似於C語言中realloc函式),我們都提到了一點自由連結串列,那麼自由連結串列是個什麼?

這裡寫圖片描述

如上圖所示,自由連結串列是一個指標陣列,有點類似與hash桶,它的陣列大小為16,每個陣列元素代表所掛的區塊大小,比如free _ list[0]代表下面掛的是8bytes的區塊,free _ list[1]代表下面掛的是16bytes的區塊…….依次類推,直到free _ list[15]代表下面掛的是128bytes的區塊

同時我們還有一個被稱為記憶體池地方,以start _ free和 end _ free記錄其大小,用於儲存未被掛在自由連結串列的區塊,它和自由連結串列構成了夥伴系統。

我們之前講了,如果使用者申請小於等於128的區塊,就到自由連結串列中取,但是如果自由連結串列對應的位置沒了怎麼辦???這下子我們的記憶體池就發揮作用了!

下面我們來重點講一講如果自由連結串列對應的位置沒有所需的記憶體塊該怎麼辦,也就是Refill函式的實現。

static void* Allocate(size_t n)
{
//...
    if (ret == NULL)
    {
        void* r = Refill(RoundUP(n));//沒有可用free list 準備裝填
    }
//...
}

//freelist沒有可用區塊,將要填充,此時新的空間取自記憶體池
static void* Refill(size_t n)
{
    size_t nobjs = 20;
    char* chunk = (char*)ChunkAlloc(n, nobjs); //預設獲得20的新節點,但是也可能小於20,可能會改變nobjs
    if (nobjs == 1) //如果只有一塊直接返回呼叫者,此時freelist無結點
        return chunk;
    //有多塊,返回一塊給呼叫者,其他掛在自由連結串列中
    Obj* ret = (Obj*)chunk;
    Obj* cur = (Obj*)(chunk + n);
    Obj* next = cur;
    Obj* volatile *myFreeList = FreeList + FreeListIndex(n);
    *myFreeList = cur;
    for (size_t i = 1; i < nobjs; ++i)
    {       
        next = (Obj*)((char*)cur + n);
        cur->freeListLink = next;
        cur = next;
    }
    cur->freeListLink = NULL;
    return ret;

}

這裡面的重點函式為ChunkAlloc,它的邏輯相對複雜,程式碼如下:


static char* ChunkAlloc(size_t size, size_t& nobjs)
{
    size_t bytesLeft = endFree - startFree; //記憶體池剩餘空間
    size_t totalBytes = size * nobjs;
    char* ret = NULL;
    if (bytesLeft >= totalBytes) // 記憶體池大小足夠分配nobjs個物件大小
    {
        ret = startFree;
        startFree += totalBytes;
        return ret;
    }
    else if (bytesLeft >= size) // 記憶體池大小不夠分配nobjs,但是至少分配一個
    {
        size_t nobjs = bytesLeft / size;
        totalBytes = size * nobjs;
        ret = startFree;
        startFree += totalBytes;
        return ret;
    }
    else // 記憶體池一個都分配不了
    {
        //讓記憶體池剩餘的那麼點掛在freelist上
        if (bytesLeft > 0)
        {
            size_t index = FreeListIndex(bytesLeft);
            ((Obj*)startFree)->freeListLink = FreeList[index];
            FreeList[index] = (Obj*)startFree;
        }

        size_t bytesToGet = 2 * totalBytes + RoundUP(heapSize >> 4);
        startFree = (char*)malloc(bytesToGet);
        if (startFree == NULL)
        {
            //申請失敗,此時試著在自由連結串列中找
            for (size_t i = size; i <= __MAX_BYTES; i += __ALIGN)
            {
                size_t index = FreeListIndex(i);
                Obj* volatile* myFreeList = FreeList + index;
                Obj* p = *myFreeList;
                if (FreeList[index] != NULL)
                {
                    FreeList[index] = p->freeListLink;
                    startFree = (char*)p;
                    endFree = startFree + i;
                    return ChunkAlloc(size, nobjs);
                }
            }
            endFree = NULL;
            //試著呼叫一級空間配置器
            startFree = (char*)MallocAlloc::Allocate(bytesToGet);
        }

        heapSize += bytesToGet;
        endFree = startFree + bytesToGet;
        return ChunkAlloc(size, nobjs);
    }

}

觀擦上面的程式碼,我們知道當自由連結串列中沒有對應的記憶體塊,系統會執行以下策略:

如果使用者需要是一塊n位元組的區塊,且n <= 128(呼叫第二級配置器),此時Refill填充是這樣的:(需要注意的是:系統會自動將n位元組擴充套件到8的倍數也就是RoundUP(n),再將RoundUP(n)傳給Refill)。使用者需要n塊,且自由連結串列中沒有,因此係統會向記憶體池申請nobjs * n大小的記憶體塊,預設nobjs=20

    如果記憶體池大於 nobjs * n,那麼直接從記憶體池中取出
    如果記憶體池小於nobjs * n,但是比一塊大小n要大,那麼此時將記憶體最大可分配的塊數給自由連結串列,並且更新nobjs為最大分配塊數x (x < nobjs)
    如果記憶體池連一個區塊的大小n都無法提供,那麼首先先將記憶體池殘餘的零頭給掛在自由連結串列上,然後向系統heap申請空間,申請成功則返回,申請失敗則到自己的自由連結串列中看看還有沒有可用區塊返回,如果連自由連結串列都沒了最後會呼叫一級配置器

這就是ChunkAlloc所執行的操作,在執行完ChunkAlloc函式後會獲得記憶體(失敗就丟擲異常),此時也就是這段程式碼:

if (nobjs == 1) //如果只有一塊直接返回呼叫者,此時freelist無結點
        return chunk;
    //有多塊,返回一塊給呼叫者,其他掛在自由連結串列中
    Obj* ret = (Obj*)chunk;
    Obj* cur = (Obj*)(chunk + n);
    Obj* next = cur;
    Obj* volatile *myFreeList = FreeList + FreeListIndex(n);
    *myFreeList = cur;
    for (size_t i = 1; i < nobjs; ++i)
    {       
        next = (Obj*)((char*)cur + n);
        cur->freeListLink = next;
        cur = next;
    }
    cur->freeListLink = NULL;

如果只有一塊返回給呼叫者,有多塊,返回給呼叫者一塊,剩下的掛在對應的位置。

這樣一個空間配置的比較關鍵思路就有了,剩餘的可以參看stl原始碼剖析。

關於完整程式碼可見 我的github
最後

也就是STL可能存在的問題,通俗的講就是優缺點吧

我們知道,引入相對的複雜的空間配置器,主要源自兩點:

1. 頻繁使用malloc,free開闢釋放小塊記憶體帶來的效能效率的低下
2. 記憶體碎片問題,導致不連續記憶體不可用的浪費

引入兩層配置器幫我們解決以上的問題,但是也帶來一些問題:

    內碎片的問題,自由連結串列所掛區塊都是8的整數倍,因此當我們需要非8倍數的區塊,往往會導致浪費,比如我只要1位元組的大小,但是自由連結串列最低分配8塊,也就是浪費了7位元組,我以為這也就是通常的以空間換時間的做法,這一點在電腦科學中很常見。
    我們發現似乎沒有釋放自由連結串列所掛區塊的函式?確實是的,由於配置器的所有方法,成員都是靜態的,那麼他們就是存放在靜態區。釋放時機就是程式結束,這樣子會導致自由連結串列一直佔用記憶體,自己程序可以用,其他程序卻用不了。