記憶體動態分配函式malloc的基本實現原理
malloc是C語言最常用的標準庫函式之一,用於在程式執行中動態地申請記憶體空間。我們都會使用它,其函式原型為:
extern void *malloc(unsigned int num_bytes);
那麼它是怎麼實現的呢?不同的編譯環境中對它的實現可能不同。比如glibc(The GNU C Library)就有自己對malloc庫函式的實現方法,並且是開源的。如果讓我們自己實現malloc功能的函式,該怎麼寫?下面學習一下malloc實現的原理。
首先,我們需要知道,作業系統核心是怎麼把一段記憶體分配給程序的?這當然需要系統呼叫了。使用者態申請分配內容的系統呼叫是sbrk(n),引數n是期望得到的記憶體位元組數。但是,頻繁的呼叫sbrk進行分配會使得真實記憶體出現越來越多的零碎空間。Linux作業系統的基本分配方式是夥伴分配方式
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結構等, 他會在靠近棧的地方分配虛存, 這樣返回的地址大.