CSAPP閱讀筆記-虛擬內存-動態內存分配-來自第九章9.9-9.11的筆記-P587-P614

動態內存分配數組

  前幾節講過,加載程序時,bss區域是映射到匿名文件的,其大小包含在目標文件中,堆和棧也是映射到二進制0的,但初始長度爲0。相似於棧指針,也有堆指針,brk,指向堆頂,由內核維護,堆向上(高地址)增加,棧向下(低地址)增加。數據結構

  動態內存分配主要針對堆操做,會把堆看做許多個塊來維護,將空閒的塊分配給須要的應用程序,已分配的塊須要程序顯式執行釋放(如free),或利用垃圾收集機制隱式釋放。那爲何須要動態內存分配呢?由於有些時候須要在程序運行時才能決定要申請的內存空間的大小,好比根據輸入建立,程序加載並剛開始運行時,堆大小爲0,此時堆大小能夠隨運行時的動態內存分配而改變。函數

  介紹一下malloc函數:工具

  其參數爲須要申請的內存字節數,返回值爲指向分配的內存塊的指針。注意申請到內存塊大小的不必定就等於須要的字節數,因爲有對齊需求,可能會獲得更大一些的內存塊,申請失敗返回NULL並設置errno。malloc不初始化返回的內存,要初始化爲0能夠用相似的函數calloc,此外有個realloc函數能夠改變一個已分配塊的大小spa

  能夠看到,malloc實際就是返回一部分的塊的指針給申請的應用程序,同時修改堆的大小,所以底層的實現應該涉及到堆指針brk。那麼有什麼函數能操控堆指針呢?設計

  答案是 void *sbrk(intptr_t incr),此函數將堆指針增長incr,返回brk的舊值,失敗返回-1並設置errno,incr爲正則是擴展堆,爲負則是收縮堆。指針

  

碎片blog

  動態內存分配容易出現一種問題,就是明明仍有足夠的內存,卻沒法知足分配請求,這是由「碎片」致使的。碎片份內部碎片和外部碎片,前者引起緣由就是以前說的,當申請一塊指定大小的內存時,因爲一些緣由,好比對齊要求,會分配比申請大小更大的內存,這就是內部碎片。外部碎片則是當空閒內存合起來能知足分配要求,但任一單一的空閒內存塊都比需求的內存小時,引起的。內存

 

隱式空閒鏈表it

  一個好的動態內存分配器,必須能解決以前提到的種種問題,好比如何記錄空閒塊?如何選擇合適的空閒塊並進行分配?當空閒塊比申請空間大不少時,如何處理剩餘部分?當一個已分配塊被釋放時,如何將它與周圍的空閒塊合併?

  要解決這些問題,須要爲每一個塊設計合理的數據結構,好比最簡單的一種:隱式空閒鏈表,以下圖所示:

  

  此時,一個塊分三個部分,首先是一個8字節(雙字)的頭部,頭部會記錄整個塊(包括頭部,有效載荷和填充)的大小,之因此用了8字節是由於咱們這裏採用了雙字對齊要求,此外,因爲雙字對齊要求,因此整個塊的大小必定是8字節的倍數,所以最低3位必定是0,爲了更好地利用低3位,能夠用它來標記當前塊是已分配塊仍是空閒塊,這裏採用最低位做爲已分配位。

  舉個例子,有一個已分配的塊,其大小爲24字節,則頭部爲0x00000018 | 0x1 = 0x00000019

  有效載荷就是分配塊中用來給應用程序使用的空間,填充能夠用來對付外部碎片或知足對齊要求。

  當要響應申請內存的請求時,分配器會遍歷此鏈表,找到大小合適的空閒塊並進行分配,直到遍歷到一個含有特殊標記的結束塊,這裏對結束塊的定義是頭部記錄的塊大小爲0,且設置了已分配位的塊。之因此稱其爲隱式空閒鏈表,是由於它裏面沒放指針,咱們僅僅經過頭部就能夠知道這個塊的大小,從而找到下一個塊,這種數據結構的缺點是每次分配都要從新遍歷鏈表,花費的時間與塊的總數線性相關。

  看個案例: 

  

  這題的主要知識點是雙字對齊,以malloc(1)爲例,頭部佔4字節,申請1字節空間,爲了知足雙字對齊,會分配8字節空間,因此塊大小爲8字節,塊頭部爲0x8 | 0x1 = 0x9,後面的分析方法相似,看書後答案便可。

  

  具體設計動態分配器時要考慮如下幾點細節:

  那麼遍歷並找到了合適大小的空閒塊後該怎麼辦呢?

  有幾種適配策略:首次適配,下一次適配和最佳適配,這個比較好理解,看書便可。

  那麼肯定了適配的空閒塊後,如何分配內存?

  能夠整個空閒塊分配出去,也能夠選擇分割,一般會選擇後者,即把一個大的空閒塊分紅兩個小的,一個分配出去,剩下一個小的空閒塊。

  若是空閒塊不夠,怎麼辦?

  能夠合併相鄰的空閒塊建立更大的空閒塊,若仍沒法知足要求,能夠調用以前提到的sbrk函數,向內核請求額外的堆內存,申請的內存被放到空閒鏈表的末尾,隨後將其中的塊分配出去。

  什麼時候合併空閒塊?

  兩種方案,一是在每一個塊被釋放時,合併全部相鄰空閒塊,二是推遲合併,直到某個分配請求失敗,再掃描整個堆,合併空閒塊。當即合併可能會致使抖動現象,好比先申請某個大小的塊,致使了對某個空閒塊的分隔,隨後又釋放它,致使了合併,隨後又申請一樣大小的塊,又致使分割,立刻又釋放,致使合併。。。如此往復,會產生大量沒必要要的分割與合併。

  如何實現合併空閒塊?

  合併空閒塊面臨的最大問題是如何合併當前釋放塊前面的空閒塊,由於合併的時候須要將兩個塊的大小相加並賦給新的塊,假如傳遞一個指針進來,指向要釋放的塊,此時沒有辦法獲得前面那個塊的頭部信息。一種解決辦法是搜索整個鏈表,記住前面塊的位置,直到抵達當前塊,但這太蠢了。。。

  一種更好的辦法是「邊界標記」,即在塊的結尾處添加一個「腳部」,它是頭部的一個副本,以下圖:

  

  這樣,當指針指向被釋放塊的頭部時,咱們能夠經過減一個字(4字節)的距離,將指針指向上一個塊的腳部,此時若從腳部讀出此塊是空閒塊,則可根據從腳部獨處的大小移到此塊的頭部進行修改。

  

  合併分四種狀況:

四種狀況圖中已經標得很明白,須要注意的是合併的同時要修改新塊的頭部和腳部。

舉個例子:

這個例子和上面那個基本同樣的思路,只不過如今多了個腳部,好比分析一下第3個,雙字對齊要求下,已分配的塊頭部和腳部,須要4+4=8個字節,又由於是已分配的,因此申請的空間至少1字節,又由於雙字對齊要求,所以已分配塊的最小塊大小爲4+4+8=16個字節,空閒塊未分配字節,最小爲4+4=8字節,而一個塊在不一樣時候能夠被分配和釋放,所以最小塊大小要同時知足兩種形態下的最小塊要求,所以總最小塊爲16字節。其他的分析相似,再也不列舉。

 

 

簡單分配器的實現

  上面介紹了具體設計動態分配器時要考慮幾點細節,下面根據這些細節,看一下一個簡單分配器具體實現:

  先介紹memlib.c包提供的兩個工具函數: 

  

  其中,mem_init函數負責用malloc將全部可用的虛擬內存化爲大的字節數組,注意,這並非把它變成了堆的空間,malloc分配時是利用分配器向堆申請空間的。mem_sbrk函數與以前提到的sbrk系統函數功能基本一致,只是多了個incr的斷定,去掉了收縮堆的功能。

 

  隨後介紹一下隱式空閒鏈表組織形式:

  

  圖中每一個方框表明一個4字節的字,第一個字是不使用的填充字,隨後有一個序言塊,8/1表明塊總大小爲8字節,且是已分配的,很明顯,序言塊是有頭部與腳部的,且沒有申請任何字節,它在初始化時被建立,永不釋放。後面的普通塊,hdr表明頭部,ftr表明尾部,最後以一個塊大小爲0,已分配的塊做爲結尾塊,它只有一個頭部,初始時分配器會讓一個指針heap_listp指向序言塊(不是序言塊的頭部)。

  再介紹一下要用到的宏與參數: 

  

  WSIZE和DSIZE定義了字和雙字的大小,CHUNKSIZE定義了初始空閒塊的大小擴展堆時的默認大小,這裏是212=4KB

  重點是幾個工具宏:

  PACK:用於返回頭部信息,傳入塊大小和已分配位,返回的值就是用來存在頭部和腳部中的信息

  GET:傳入一個指針p,返回的是該指針指向的4個字節的unsigned int值,這裏用unsigned int*做轉換,明顯是爲了獲取塊的大小用的,當傳入的指針指向塊的頭部時,能夠用GET獲取頭部存儲的塊的大小和分配位信息。

  PUT:與GET相反,傳入一個指針p和一個值val,把val存放在p指向的4個字節中。

  GET_SIZE: 顯然它負責把GET取出的4字節的字的低3位置0,能夠用於從頭部/腳部取出塊的大小和分配位信息後進一步提取出其中的塊大小信息。

  GET_ALLOC: 相似GET_SIZE,不過這裏提取的是最後一位的分配位信息。

  HDRP: 它能夠根據傳入的指向塊的指針,找到相應的指向頭部的指針。(注意,這裏的指向塊是指向該塊頭部後的起始地的,就如指向序言塊的heap_listp指針那樣,顯然,塊指針和頭部指針就相差一個字的距離)

  FRTP: 它能夠根據傳入的指向塊的指針,找到相應的指向腳部的指針,實現的思路是:先利用GET_SIZE獲得該塊的大小,當前塊指針加上它就會指向下一個塊的塊指針,它和上一個塊的腳部差了一個頭部(4字節)和一個腳部(4字節),因此減1個雙字的大小便可指向當前塊的腳部。

  NEXT_BLKP:它能夠獲得下一個塊的塊指針,具體再也不分析。

  PREV_BLKP:它能夠獲得前一個塊的塊指針,具體再也不分析。

 

  下面實現分配器的基本功能,實現的時候要注意,完成以前細節討論中要注意的地方。

  首先是對分配器的初始化,具體任務是建立新的空閒鏈表。

  

  具體操做手法:先使堆擴大4個字的空間(注意一開始堆大小爲0),隨後如9-42的圖所示,先設置一個字的填充塊,隨後是序言塊的頭部和腳部,隨後是結束塊,設置完後將heap_listp指向圖中所示的序言塊的頭部後起始的地方(這裏由於序言塊申請字節爲0,因此至關於指向腳部)。最後要進一步擴大堆的空間,擴大的堆空間被標記爲空閒塊,這由extend_heap函數完成,其實現以下圖所示:

  

  extend_heap函數功能就是爲堆申請指定大小的新空間,它會將傳入的大小進行雙字對齊,並進行擴展,擴展後返回塊指針,指針指向的空間已經被擴展了,它是跟在結尾塊的後面的,所以結尾塊再也不是結尾塊,須要將它變成新空閒塊的頭部,同理要將擴展空間的最後一個字設置爲結尾塊,至關於作了一個挪移效果,這幾步在第12-14行被設置完成,頗有意思。最後的coalesce函數負責合併空閒塊,它的實現以下圖所示:

  

  它對應處理的是以前圖中展現的4種合併狀況,這裏再也不具體解釋。

  實際供外部調用的分配函數是mm_malloc,以下圖所示: 

  

  首先主程序裏調用以前說的mm_init函數,來將堆進行基本的擴展,並建立必要的結構塊,如序言塊,結尾塊,以及基本的空閒塊,當須要申請分配空間時,調用mm_malloc函數,它會先檢測請求的塊的大小,使之知足雙字對齊條件,隨後搜索堆中的空閒鏈表,找到合適的空閒塊進行放置並進行合理的分割,最後返回新分配塊的地址。若是沒找到合適的空閒塊,說明向堆申請空間時,堆的空間已經沒法知足要求了,此時須要用以前的擴展堆的函數,對堆進行擴展,將請求的塊放置在堆擴展後的塊中

  放置和尋找匹配塊的函數以下圖所示:

尋找匹配塊的策略比較簡單,只要該空閒塊的大小大於申請的空間大小便可,放置採用首次匹配的策略,須要注意的是,這裏要判斷放置後剩餘的塊大小是否大於最小塊大小(這裏由於有頭部和腳部,最小塊大小爲雙字,即8字節),不是則直接放置,是則須要進行分割,分割只要多造出一個頭部和腳部便可。

 

最後是釋放已分配的塊的功能的實現,以下圖所示:

只需把對應的塊的頭部和腳部標爲空閒,隨後進行合併便可。

 

後面的顯式空閒鏈表及垃圾收集機制講得太少,不記錄了,下次參考了其它資料專門做專題記錄吧。

最後是一些C程序裏的常見的和內存有關的錯誤:

  大部分已經知道了,沒什麼記錄意義,有兩個有點意思,記錄一下:

1.引用不存在的變量:

這段代碼返回了局部變量的地址,還記得第三章講述的嗎?由於在代碼中用到了val的地址,因此val不會被放在寄存器裏,而是會放在棧上,此時函數執行結束後,返回的地址雖然是合法的,但可能會有這樣一種狀況:程序調用了其它函數,並再次用了棧幀,這樣,這個地址指向的數據是屬於其它函數的數據,然後面當咱們對這個地址的內容進行修改時,會直接改掉其它函數中的數據,會帶來麻煩的後果。

 

2.引用空閒堆塊中的數據:

這裏向堆申請的塊已經被釋放了,然而咱們後面又引用了指向這個塊的指針,對其指向的內容修改,從以前的講述中咱們知道,被釋放的塊是空閒塊,會再次被分配給其它的向堆申請塊的程序,這時候修改顯然會影響到那個程序。

這個錯誤本質和上面那個是同樣的,只不過一個發生在棧中,一個發生在堆中,因此當初學C語言的時候,書裏常常會講,當咱們用free釋放某個塊時,還須要把對應的指向塊的指針設爲null,當時的理由是防止出現野指針,實質就是上面講述的問題。

相關文章
相關標籤/搜索