CSI-S4:動態存儲器分配-malloc與GC

1.用戶級存儲器映射

         之前我們介紹過關於程序加載的詳細內容,我們知道在其加載執行之前要對程序進行存儲器映射,Unix進程可以使用mmap函數來創建新的虛擬存儲器區域,並將對象映射到這些區域。


Mmap函數要求內核創建一個新的虛擬存儲器區域,最好是從start開始的地址,並將文件描述fd標識對象的一個連續的片映射到這個新的區域。連續的對象片大小爲length,從距文件開始處偏移量爲offset的地方開始。Prot指定了新創建的虛擬頁面的訪問位權限(之前提到過的虛擬頁面的讀寫、執行權限)。最後,falgs字段描述的是被映射對象的類型,可以用來標記匿名對象,私有、寫時拷貝對象和共享對象。調用mmap函數成功後就會返回對應新區域的地址。

和mmap相對應,munmap函數用來刪除虛擬存儲器的區域:


Munmap函數刪除從虛擬地址start開始的,由接下來length字節組成的區域,對已刪除區域的引用會引起段錯誤。

2.動態存儲器分配

          我們可以通過mmap和munmap來創建和刪除虛擬存儲器區域,但對開發人員來說使用起來並不方便,況且沒有很好的移植性,所以提出了使用動態存儲分配器來管理進程空間中的堆區域。

          動態存儲分配器維護着一個進程的虛擬存儲器區域,稱爲堆。堆是從低位地址向高位向上增長的,對於每個進程,內核維護着一個brk,它指向堆的頂部。

分配器將堆視爲一組不同大小的塊的集合來維護。每個塊就是一個連續的虛擬存儲器片,要麼是已分配的,要麼是空閒的。已分配的顯示地保留爲供應應用程序使用。空閒塊可用來分配。一個已分配的塊保持已分配狀態,直到它被釋放,這種釋放要麼是應用程序顯示執行的,要麼是存儲分配器隱式執行的,它們的都是顯式的來分配存儲塊的,不同之處在於由哪個實體來負責釋放已分配的塊。

          a)  顯式分配器,要求顯式的釋放已分配的塊。如C標準庫中的malloc和free,C++中的new和delete操作符。

          b)  隱式分配器,要求分配器檢測一個已分配的塊何時不再被程序使用,那麼就釋放這個塊。隱式分配器也叫做垃圾收集器(Grabage collector)。如java語言就依賴於類似分配器。

下面我們看下malloc和free的實現是如何管理一個C程序的16字的小堆的。每個方框代表一個4字節的字。粗線標出的矩形對應於已分配塊(有陰影)和空閒塊(無陰影),初始時,堆是由一個大小爲16個字的、雙字對齊的、空閒塊組成的。

          a)  程序請求一個4字的塊,malloc的響應是:從空閒塊的前部切出一個4字的塊,並返回一個指向這個塊的第一個字的指針p1

          b)  程序請求一個5字的塊,malloc的響應是:從空閒塊的前部分配一個6字的塊,返回指針p2,填充的一個額外字是爲了保持空閒塊是雙字邊界對齊的。

          c)  程序請求一個6字的塊,而malloc就從空閒塊的前部切出一個6字的塊。返回指針p3

          d)  程序釋放在b中分配的那個6字的塊。需要注意的是,在調用free返回之後,指針p2仍然指向被釋放的塊,在它被一個新的malloc調用重新初始化之前不能在程序中再使用p2.

          e)  程序請求一個2字的塊。在這種情況下,malloc分配在前一步中釋放了的塊的一部分,並返回指向新塊的指針p4.

                           

3.分配器的要求和目標

顯式分配器必須在一些相當嚴格的約束條件下工作:

(1)    處理任意請求序列。一個應用可以有任意的分配請求和釋放請求序列,只要滿足約束條件:每個釋放請求必須對應於一個當前已分配的塊,這個塊是由一個以前的分配請求獲得的。因此,分配器不可以假設分配和釋放請求的順序。

(2)    立即響應請求。分配器必須立即響應分配請求。因此,不允許分配器爲了提高性能重新排列或者緩衝請求。

(3)    只使用堆。分配器使用的任何非標量數據結構必須保存在堆裏。

(4)    對齊塊。分配器必須對齊塊,使得它們可以保存任何類型的數據對象。在大多數系統中,分配器返回的塊是8字節(雙字)邊界對齊的。

(5)    不修改已分配的塊。分配器只能操作或者改變空閒塊。特別地,一旦塊被分配了,就不能修改或者移動它了。

在這些限制條件下,分配器的編寫者視圖實現吞吐率最大化和存儲器使用率最大化,而這性能目標通常是相互衝突的。

1>    最大化吞吐率。假定n個分配和釋放請求的某種序列:

R0,R1,……Rk,…..Rn-1 ,吞吐率的定義爲每個單位時間裏完成的請求數。例如,如果一個分配器在1秒鐘內完成500個分配請求和500個釋放請求,那麼它的吞吐率就是每秒1000次操作。一般而言,我們可以通過使滿足分配和釋放請求的平均時間最小化來使吞吐率最大化。

2> 最大化存儲器利用率。描述一個分配器使用堆的效率的標準是峯值利用率,如上我們給定的分配和釋放請求順序R0,R1,…Rk,……Rn-1。如果一個應用程序請求一個P字節的塊,那麼得到的已分配的塊的有效載荷是p字節。在請求Rk完成之後,聚集有效載荷表示爲Pk,爲當前一分配塊的有效載荷之和,而Hk表示對的當前的大小。那麼,前k個請求的峯值利用率表示爲Uk,可以通過下式得到:

     Uk=(maxi<kPi)/Hk

可以看出,在最大化吞吐率和最大化利用率是相互牽制的。分配器設計中的挑戰之處就是在兩個目標之間找到一個合適的平衡。

4.碎片

            造成堆利用率很低的主要原因是一種稱爲碎片的現象,當雖然有未使用的存儲器但不能用來滿足分配請求時,就會發生這種現象。有兩種形式的碎片:內部碎片和外部碎片。

           內部碎片是在一個已分配的塊比有效載荷大時發生的。例如,在9-34b中,分配器爲了滿足對其約束添加額外的1字的存儲空間,這個1字的空間就是內部碎片。

           外部碎片是當空閒存儲器合計起來足夠滿足一個分配請求,但是沒有一個單獨的空閒塊足夠大可以來處理這個請求時發生的。例如,在圖9-34e中請求6個字,那麼如果不向內核請求額外的虛擬存儲器就無法滿足這個請求,即使在堆中仍然有6個空閒的字。但是這6個字是分在兩個空閒塊中的。

5.隱式空閒鏈表

任何實際的分配器都需要一些數據結構,允許它來區別塊邊界,以及區別已分配塊和空閒塊。大多數分配器將這些信息嵌入在塊本身。如下:


上圖的結構中,一個塊是由一個字的頭部、有效載荷,以及一些可能的填充組成。

頭部編碼了這個塊的大小,以及這個塊是已分配還是空閒的。如果我們強加一個雙字的對齊約束條件,那麼塊大小就總是8的倍數,且塊大小的最低3位總是零。因此,我們只需要存儲塊大小的29個高位,釋放剩餘的3位來編碼其他信息。例如,假設我們有一個已分配額塊,大小爲24(0x18)字節。那麼它的頭部將是

0x00000018 |0x1=0x00000019

那麼,根據上面的塊格式,我們可以將堆組織爲一個連續的已分配塊的空閒塊序列。

 

我們稱這種結構爲隱式空閒鏈表,是因爲空閒塊是通過頭部中的大小字段隱含連接着的。

分配器可以通過遍歷堆中所有的塊,從而間接地遍歷整個空閒塊集合。這裏,我們設置了已分配位而大小爲0的終止頭部來代表鏈表中的結束塊。

隱式空閒鏈表的優點是簡單。顯著的缺點是任何操作的開銷,例如放置分配的塊,要求空閒鏈表的搜索與堆中已分配塊和空閒塊的總數呈線性關係。

放置已分配的塊

        當一個應用請求一個k字節的塊時,分配器搜索空閒鏈表,查找一個足夠大可以放置所有請求塊的空閒塊。分配器執行這種搜索的方式是由放置策略確定的。一些常見的策略是首次適配、下一次適配和最佳適配。

        首次適配是從頭開始搜索空閒鏈表,選擇第一個合適的空閒塊。下一次適配和首次適配相似,只不過不是從鏈表的起始處開始每次搜索,而是從上一次查詢結束的地方開始。最佳適配檢查每個空閒塊,選擇適合所需請求大小的最小空閒塊。

分割空閒塊

        一旦分配器找到一個匹配的空閒塊,它就必須做另一個策略決定,那就是分配這個空閒塊中多少空間。一個選擇是用整個空閒塊。雖然這種方式較簡單而快捷,但是缺點是它會造成內部碎片。如果放置策略趨向於產生好的匹配,那麼額外的內部碎片也是可以接受的。

然而,如果匹配不太好,那麼分配器通常會選擇將這個空閒塊分割爲兩部分。第一部分變成分配塊,而剩下的變成一個新的空閒塊。


獲取額外的堆存儲器

        如果分配器不能爲請求塊找到合適的空閒塊會發生什麼呢?一個選擇是通過合併那些在存儲器中物理上相鄰的空閒塊來創建一些更大的空閒塊。然而,如果這樣還是不能生成一個足夠大的塊,或者如果空閒塊已經最大程度地合併了,那麼分配器就會向內核請求額外的存儲器。分配器將額外的存儲器轉化成一個大的空閒塊,將這個塊插入到空閒鏈表中,然後將被請求的塊放置在這個新的空閒塊中。

合併空閒塊

           當分配器釋放一個已分配塊時,可能有其他空閒塊於這個新釋放的空閒塊相鄰。這些鄰接的空閒塊可能引起一種現象,叫做假碎片,就是有許多可用的空閒塊被切割成小的、無法使用的空閒塊。如下圖,我們釋放掉9-37中分配的塊,結果是兩個相鄰的空閒塊,每個有效負載都爲3個字。因此,接下來一個對4個字的有效載荷的請求就會失敗,即使兩個空閒塊的合計大小足夠大,可以滿足這個請求。

 

           爲了解決假碎片的問題,任何實際的分配器都必須合併相鄰的空閒塊,這個過程稱爲合併。分配器一般可以選擇立即合併,也就是在每次一個塊被釋放時,就合併所有的相鄰塊。或者它也可以選擇推遲合併,也就是等到某個稍晚的時候再合併空閒塊。需要特別注意的是,立即合併簡單明瞭,可以在常數時間內執行完成,但是對於某些請求模式,這種方式會產生一種的抖動,塊會反覆地合併,然後馬上分割。

帶邊界標記的合併

         分配器如何實現合併呢?假設我們稱想要釋放的塊爲當前塊。那麼合併下一個空閒塊很簡單而且高效。當前塊的頭部指向下一個塊的頭部,可以檢查這個指針以判斷下一個塊是否是空閒的。如果是,就將它的大小簡單地加到當前塊頭部的大小上,這兩個塊在常數時間內被合併。

         但我們該如何合併前面的塊呢?給定一個帶頭的隱式空閒鏈表,唯一的選擇將是搜索整個鏈表。記住前面塊的位置,直到我們打到當前塊。使用隱式空閒鏈表,這意味着每次調用free需要的時間都於堆的大小呈線性關係。即使使用更復雜精細的空閒鏈表組織,搜索時間也不會是常數。

         Knuth提出一種聰明而通用的技術,叫做邊界標記,允許在常數時間內進行對前面塊的合併,這種思想,是在每個塊的結尾處添加一個腳部,其中腳部就是頭部的一個副本。如下所示:


如果每個塊包括這樣一個腳部,那麼分配器就可以通過檢查它的腳部,判斷前面一個塊的起始位置和狀態,這個腳部總是在距當期塊開始位置一個字的距離。那麼,分配器釋放當前塊時存在四種可能情況:

(1)    前面的塊和後面的塊都是已分配的

(2)    前面的塊是已分配的,後面的塊是空閒的

(3)    前面的塊是空閒的,而後面的塊是已分配的

(4)    前面的和後面的塊都是空閒的。

下圖,展示了這四種情況合併的過程:

 

 邊界標記幫我們解決了空閒塊合併的問題,對於不同類型的分配器和空閒鏈表組織都是通用的,然而,他還存在一個潛在的缺陷。它要求每個塊都保持一個頭部和腳部,在應用程序中操作許多小塊時,會產生顯著的存儲器開銷。例如,在一個圖形應用中反覆的調用malloc和free來動態創建和銷燬圖形節點,並且每個圖形節點都只要求兩個存儲器字,那麼頭部和腳部將佔用每個已分配塊的一半空間。

6.顯式空閒鏈表

          隱私空閒鏈表爲我們提供了一種簡單的介紹一些基本分配器概念的方法。然而,因爲塊分配與堆塊的總數呈線性關係,所以對於通用的分配器,隱式空閒鏈表是不合適的。而一種更好的方式是將空閒塊組織爲某種形式的顯式數據結構。因爲根據定義,程序不需要一個空閒塊的主體,所以實現這個數據結構的指針可以存放在這些空閒塊的主體裏面。例如,堆可以組織成一個雙向空閒鏈表,在每個空閒塊中,都包含一個pred(前驅)和succ(後繼)指針。


        使用雙向鏈表而不是隱式空閒鏈表,使首次適配的分配時間從塊總數的線性時間減少到了空閒塊數量的線性時間。不過,釋放一個塊的時間可以是線性的,也可能是常數,這取決於我們所選擇的空閒鏈表中塊的排序策略。

        一種方法是用後進先出(LIFO)的順序維護鏈表,將新釋放的塊位置放在鏈表的開始出。使用LIFO的順序和首次適配的放置策略,分配器會最先檢查最近使用過的塊。在這種情況下,釋放一個塊可以在常數時間內完成。如果使用了邊界標記,那麼合併也可以在常數時間內完成。

         另一種方法是按照地址順序來維護鏈表,其中鏈表中每個塊的地址都小於它後繼的地址。在這種情況下,釋放一個塊需要線性時間的搜索來定位合適的前驅。平衡點在於,按照地址排序的首次適配比LIFO排序的首次適配有更高的存儲器利用率,接近最佳適配的利用率。

一般而言,顯示鏈表的缺點是空閒塊必須足夠大,以包含所有需要的指針,以及頭部和可能的腳部。這就導致了更大的最小塊大小。也潛在地提高了內部碎片的程度。

分離的空閒鏈表

一個使用單向空閒塊鏈表的分配器需要與空閒塊數量呈線性關係的時間來分配塊。一種流行的減少分配時間的方法,通常稱爲分離存儲,就是維護多個空閒鏈表,其中每個鏈表中的塊有大致相等的大小。一般思路是將所有可能的塊大小分成一些等價類,也叫做大小類。

有很多方式來定義大小類,例如,我們可以可以根據2的冪來劃分塊大小:

{1},{2},{3,4},{5~8},…..{1025~2048},{2049~4096},{4096~∞}。

分配器維護着一個空閒鏈表數組,每個大小類一個空閒鏈表,按照大小的升序排列。當分配器需要一個大小爲n的塊時,它就搜索相應的空閒鏈表。如果它不能找到合適的塊與之匹配,他就搜索下一個鏈表,以此類推。

簡單分離存儲

        對於簡單分離存儲,每個大小類的空心啊鏈表包含大小相等的塊,每個塊的大小就是這個大小類中最大元素的大小。例如,如果某個大小類定義爲{17~32},那麼這個類的空閒鏈表全由大小爲32的塊組成。

        爲了分配一個給定大小的塊,我們需要檢查相應的空閒鏈表。如果鏈表非空,我們簡單地分配其中第一塊的全部。空閒塊是不會分割以滿足分配請求的。如果鏈表爲空,分配器就向操作系統請求一個固定大小的額外存儲器片,然後將這個片分成大小相等的塊,並將這些塊鏈接起來形成新的空閒鏈表,要釋放一個塊,分配器只要簡單地將這個塊插入到相應的空閒鏈表的前部。

分離適配

        使用分離適配,分配器維護着一個空閒鏈表的數組。每個空閒鏈表是和一個大小類相關聯的,並且被組織成某種類型的顯式或隱式鏈表。每個鏈表包含潛在的大小不同的塊,這些塊的大小是大小類的成員。

        爲了分配一個塊,我們必須確定請求的大小類,並且對適當的空閒鏈表做首次適配,查找一個合適的塊。如果我們找到了一個,那麼我們可以分割它,並將剩餘的部分插入到適當的空閒鏈表中。如果我們找不到合適的塊,那麼就搜索下一個更大的大小類的空閒鏈表。如此重複,直到找到一個合適的塊。如果空閒鏈表沒有合適的塊,那麼就向操作系統請求額外的堆存儲器,從這個新的堆存儲器中分配出一個塊,將剩餘部分放置在適當的大小類中。要釋放一個塊,我們執行合併,並將結果放置到相應的空閒鏈表中。

夥伴系統

         作爲分離適配的一種特例,夥伴系統中每個大小類都是2的冪。基本的思路是假設一個堆的大小爲2m個字,我們爲每個塊大小2k維護一個分離空閒鏈表。其中0<=k<=m。請求塊大小向上舍入到最接近的冪。最開始時,只有一個大小爲2m個字的空閒塊。

爲了分配一個大小爲2k的塊,我們找到第一個可用的、大小爲2j的塊,其中k<=j<=m。

如果j=k,那麼我們就完成了。否則,我們遞歸地而分割這個塊,直到j=k。當我們進行這樣的分割時,每個剩下的半塊(夥伴)被放置在相應的空閒鏈表中。要釋放一個大小爲2k塊,我們繼續合併空閒的夥伴。當我們遇到一個已分配的夥伴時,我們就停止合併。

關於夥伴系統的一個關鍵事實是,給定地址和塊的大小,很容易計算出它的夥伴的地址。例如,地址

xxxx…x00000

他的夥伴的地址爲

xxxx…x10000

換句話說,一個塊的地址和它的夥伴的地址只有一位不相同。

7.垃圾收集

          垃圾收集器(gargagecollector)是一種動態存儲器分配器,它自動釋放程序不再需要的已分配塊。這些塊稱爲垃圾。自動回收堆存儲的過程叫做垃圾收集。在java 虛擬機中就使用了類似的機制,應用顯式分配堆塊,但是從不顯式地釋放他們。垃圾收集器定期識別垃圾塊,並相應地調用free,將這些塊放回到空閒鏈表中。

         垃圾收集器將存儲器視爲一張有向可達圖,該圖的節點被分成一組根節點和一組堆節點。每個堆節點對應於堆中的一個已分配塊。有向邊p->q意味着塊p中的某個位置指向塊q中的某個位置。根節點對應於這樣一種不在堆中的位置,他們中包含指向堆中指針。這些位置可以是寄存器、棧裏的變量,或者是虛擬存儲器中讀寫數據區域內的全局變量。


          當存在一條從任意根節點出發併到達p的有向路徑時,我們說節點p是可達的。在任何時刻,不可達節點對應於垃圾,是不能被應用再次使用的。垃圾收集器的角色就是維護可達圖的某種表示,並通過釋放不可達節點將它們返回給空閒鏈表,來定期地回收它們。

          像C/C++程序的收集器是保守的,其根本原因是C/C++不會用類型信息來標識存儲器位置,因此,像int或者float這樣的標量可以僞裝成指針。類似這樣語言的收集器通常不能維持可達圖的精確表示。不過我們可以考慮將一個C程序的保守的收集器加入到已存在的malloc包中,如下圖示:


無論何時需要堆空間,應用都會用通常的方式調用Malloc,如果Malloc找不到一個合適的空閒塊,那麼它就調用垃圾收集器,希望能夠回收一些垃圾到空閒鏈表。收集器識別出垃圾塊,並通過free函數將它們返回給堆,關鍵的思想是收集器替代應用去調用free。當對收集器的調用返回時,malloc重試,視圖發現一個合適的空閒塊。如果還是失敗了,那麼它就會向操作系統要求額外的存儲器。最後,malloc返回一個執行請求塊的指針或者返回一個空指針。


Ok,關於動態存儲器分配的內容就介紹這麼多,可以看到我並沒詳細介紹其中用到的某些算法,只是描述大概的原理結構,至於像malloc標準庫函數或者 GC的詳細實現,如果感興趣可以自行研究,後面我會簡單的寫出一個模型來對本篇所講的內容進行一個補充和完善。

更新

基於隱式鏈表的malloc簡單模擬實現參考 http://download.csdn.net/detail/u012960981/7190851

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章