1. 程式人生 > >記憶體動態分配函式malloc的基本實現原理

記憶體動態分配函式malloc的基本實現原理

    malloc是C語言最常用的標準庫函式之一,用於在程式執行中動態地申請記憶體空間。我們都會使用它,其函式原型為:

extern void *malloc(unsigned int num_bytes);

    那麼它是怎麼實現的呢?不同的編譯環境中對它的實現可能不同。比如glibc(The GNU C Library)就有自己對malloc庫函式的實現方法,並且是開源的。如果讓我們自己實現malloc功能的函式,該怎麼寫?下面學習一下malloc實現的原理。

    首先,我們需要知道,作業系統核心是怎麼把一段記憶體分配給程序的?這當然需要系統呼叫了。使用者態申請分配內容的系統呼叫是sbrk(n),引數n是期望得到的記憶體位元組數。但是,頻繁的呼叫sbrk進行分配會使得真實記憶體出現越來越多的零碎空間。Linux作業系統的基本分配方式是夥伴分配方式

,所以即使申請的位元組數不是2的冪數,系統也會分配出2的冪數的空間來。因此malloc的實現不會是每次都動用核心的。

    malloc採用的總體策略是

    先系統呼叫sbrk一次,會得到一段較大的並且是連續的空間。程序把系統核心分配給自己的這段空間留著慢慢用。之後呼叫malloc時就從這段空間中分配,free回收時就再還回來(而不是還給系統核心)。只有當這段空間全部被分配掉時還不夠用時,才再次系統呼叫sbrk。當然,這一次呼叫sbrk後核心分配給程序的空間和剛才的那塊空間一般不會是相鄰的。

    malloc如何使用得到動態記憶體空間?一次sbrk之後,malloc就會保留著一段大的連續空間(稱作堆空間)。之後對於堆空間malloc不斷地分配,free不斷地收回,這段空間裡的格局必定是“亂七八糟”,有的已分配,有的未分配。

實現動態記憶體分配往往要考慮以下四個問題:

(1)空閒塊組織:我們如何記錄空閒塊?
(2)選擇:我們如何選擇一個合適的空閒塊來作為一個新分配的塊?
(3)分割:在我們選了一個空閒塊分配出我們需要的大小之後,我們如何處理這個空閒塊中的剩餘部分?
(4)合併:我們如何處理一個剛剛被釋放的塊?

    malloc的空閒連結串列機制:這是對問題(1)的解答。有兩種方法:顯式空閒連結串列、隱式空閒連結串列。

    (1)顯式空閒連結串列:用一個連結串列將可用的記憶體塊連線為一個長長的列表,稱為空閒連結串列。將堆組織成雙向空閒連結串列,每一個空閒塊結點都包含一個祖先指標和一個後繼指標。連結串列中的每個結點記錄一個連續的、未分配的記憶體小塊。結點的結構很簡單,只需要記錄可用記憶體小塊的首地址和大小即可。

    (2)隱式空閒連結串列:隱式空閒連結串列由N個塊組成,一個塊由頭部、有效載荷(只包括已分配的塊),以及可能的一些額外的填充(為了保證記憶體位元組對齊而需要的填充)和尾部組成。頭部大小為4個位元組,在強加雙字對齊的約束之後,塊大小總是8的倍數,所以用高29位來表示儲存塊大小,剩餘3位中利用最低位來表示塊是否已分配(1表示已分配,0表示空閒);尾部和頭部的內容一樣,加入尾部是為了分配器可以判斷出一個塊的起始位置和狀態。整個堆空間就是一個隱式空閒連結串列,從低地址向高地址生長,第一個和最後一個8位元組標記為已分配,以確定堆的大小。

    空閒連結串列如何從中選擇分配記憶體塊?這是對問題(2)的解答。有下面四種選擇方法。

    (1)首次適應法(First Fit):連結串列按塊地址排序。選擇第一個滿足要求的空閒塊。特點:低地址碎片多,高地址碎片少。
    (2)最佳適應法(Best Fit):連結串列按空閒塊大小排序。選擇滿足要求的,且大小最小的空閒塊。特點:費時間,並且會出現很小的碎片。
    (3)最壞適應法(Worst Fit):連結串列按空閒塊大小排序。選擇最大的空閒塊。特點:碎片少,容易缺乏大塊。
    (4)迴圈首次適應法(Next Fit):連結串列按塊地址排序。從上次分配位置開始找到第一個滿足要求的空閒塊。特點:碎片分佈的又多又均勻。

    如何處理被選空閒塊中的剩餘部分?這是對問題(3)的解答。一般來講,是要把剩餘的部分再插入回到空閒連結串列中去的。要注意一個空閒塊分割成兩個塊時,需要騰出若干位元組作為塊的頭部尾部等部分。

    如何合併被釋放的塊?這是對問題(4)的解答。有兩種方法:立即合併推遲合併。對於隱式空閒連結串列,合併的具體過程是,

    (1)前後塊都已分配:直接釋放當前塊即可;

    (2)前塊分配、後塊空閒:和後塊合併;

    (3)前塊空閒、後塊分配:和前塊合併;

    (4)前後塊都已空閒:和前後塊合併;

glibc對malloc的實現

    目前最新版本為2.18,glibc原始碼目錄/glibc-2.18/malloc中可以看到。在glibc的malloc的實現中, 分配虛存有兩種系統呼叫可用: brk()和mmap(), 如果要分配大塊記憶體, glibc會使用mmap()去分配記憶體,這種記憶體靠近棧。

基於UNIX 的系統有兩個可對映到附加記憶體中的基本系統呼叫:
brk: brk() 是一個非常簡單的系統呼叫。還記得系統中斷點嗎?該位置是程序對映的記憶體邊界。 brk()只是簡單地將這個位置向前或者向後移動,就可以向程序新增記憶體或者從程序取走記憶體。sbrk()是以增量的方式增加擴大記憶體。

mmap: mmap(),或者說是“記憶體映像”,類似於 brk(),但是更為靈活。首先,它可以對映任何位置的記憶體,而不單單隻侷限於程序。其次,它不僅可以將虛擬地址對映到物理的 RAM 或者 swap,它還可以將它們對映到檔案和檔案位置,這樣,讀寫記憶體將對檔案中的資料進行讀寫。不過在這裡,我們只關心 mmap向程序新增被對映的記憶體的能力。 

在glibc的malloc的實現有一個優化:
1. 當malloc()一塊很小的記憶體是, glibc呼叫brk(), 只需要在heap中移動一下指標, 即可獲得可用虛存, 這樣分配得到的地址較小.
2. 當malloc()一塊較大記憶體時, glibc呼叫mmap(), 需要在核心中重新分配vma結構等, 他會在靠近棧的地方分配虛存, 這樣返回的地址大.