1. 程式人生 > >堆區的動態內存分配

堆區的動態內存分配

請求 頭部 let 是否 相同 有效 存儲空間 未使用 技術分享

【前言】前面有一篇文章介紹了堆區棧區的區別。棧區的核心主要集中在操作一個棧結構,一般由操作系統維護。堆區,主要是我們程序員來維護,核心就是動態內存分配。

一、動態內存分配器

  雖然低級的mmap和munmap函數來創建和刪除虛擬內存區域,但是C程序運行時在需要額外的存儲空間時,一般會使用動態存儲器分配器,它維護著一個進程的虛擬存儲器區域,稱為堆。堆是一個請求二進制零的區域,內核為每個進程維護一個變量 brk ,指向堆的頂部。分配器將堆視為一組不同大小的塊,每個塊為虛擬存儲器的一個連續組塊,是已分配的或空閑的。

  有兩種分配器。顯式分配器要求應用顯式地釋放任何已分配的塊,如C的 malloc/free

和C++的 new/delete 。隱式分配器則由分配器檢測不再被使用的已分配塊,並釋放塊,也稱為垃圾收集器,Lisp、ML和Java等高級語言使用垃圾收集。

二、顯式分配器

C標準庫提供了 malloc 程序包作為顯式分配器,包括 malloccallocreallocfree 函數。malloc返回一個指針,會自動數據對齊。32系統分配的塊的地址總是8的倍數,64位系統是16的倍數。malloc不初始化他返回的內存,calloc將內存初始化為0,realloc改變一個以前分配的大小。

動態存儲分配器可以使用 mmapmunmap 函數顯式地分配和釋放堆,還有 sbrk

函數:

#include <unistd.h>

/** 將內核的brk指針增加increment來擴展和收縮堆,increment為0時返回brk當前值
 * @return      返回brk的舊值,出錯返回-1,並設errno為ENOMEM */
void *sbrk(intptr_t increment);

顯式分配器有一些約束條件:

  • 能夠處理任意(分配和釋放)請求的序列,釋放請求必須對應以前分配請求分配的塊。
  • 立即響應請求。
  • 對齊塊,使可以保存任何類型的數據對象,因此大多數系統中分配器返回的塊為8字節對齊的。
  • 不修改已分配的塊。

分配器力圖做到吞吐量最大化和存儲器利用率最大化,在兩者之間平衡。吞吐量指單位時間內完成的請求數,一般要求分配請求的最差運行時間和空閑塊的數量成線性關系,釋放請求的運行時間為常數。描述存儲器利用率常用峰值利用率,即請求序列的某個時刻時已分配的總有效載荷和堆的當前大小(為整個請求序列時間的最大值)的比值。

碎片會造成堆的利用率低,產生於未使用的存儲器不能滿足分配請求的情況。有內部碎片和外部碎片。內部碎片在已分配塊比有效載荷大時發生,比如由於對齊要求。外部碎片在沒有單獨的空閑塊足夠滿足請求時發生,盡管它們合起來足夠大。

分配器需要處理空閑塊的組織,放置、分隔和合並塊。實際的分配器會使用一些數據結構來區別塊邊界,已分配塊和空閑塊。

三、隱式空閑鏈表

下圖中示意了用隱式空閑鏈表來組織堆的方式。

技術分享圖片

簡單的堆塊的格式和隱式空閑鏈表的組織

1、放置塊時,分配器搜索空閑鏈表,常見有首次適配、下一次適配和最佳適配的放置策略。首次適配從頭開始搜索空閑鏈表,下一次適配從鏈表的上一次查詢結束的地方開始搜索,最佳適配檢查所有空閑塊,選擇最小滿足的。下一次適配運行最快,但利用率低得多;最佳適配最慢,利用率最高。

2、分配器找到匹配的空閑塊後,根據情況可能分割它。如果沒有合適的空閑塊,合並空閑塊來創建更大的空閑塊。如果還是不能滿足需要,分配器向內核請求額外的堆存儲器,轉成空閑塊加入到空閑鏈表中。

3、分配器可以選擇立即合並或推遲合並,一般為防止抖動,會采用某種形式的推遲合並。

4、合並需要在常數時間內完成,對於空閑鏈表來說,它是單鏈表,可以方便地查看後面的塊是否空閑塊,但前面的塊則不行,一個好辦法是在塊的腳部使用邊界標記,它是頭部的副本,這樣就可以在常數時間查看前後塊的類型了。為了避免邊界標記占用空間,可以只在空閑塊中加邊界標記。

四、顯式空閑鏈表

  對於通用的分配器,隱式空閑鏈表並不適合,因為它的塊分配和堆塊的總數呈線性關系。可以在空閑塊中增加一種顯式的數據結構。下面是雙向空閑鏈表的堆塊的格式。雙向鏈表使首次適配時間從塊總數的線性時間減少到了空閑塊數的線性時間。

技術分享圖片

雙向空閑鏈表的堆塊的格式

顯式鏈表的缺點是空閑塊必須足夠大來包含結構,這增大了最小塊的大小,也潛在提高了內部碎片的程度。

五、分離的空閑鏈表

分離的空閑鏈表利用分離存儲來減少分配時間。分配器維護一個空閑鏈表數組,每個空閑鏈表為一個大小類。大小類的定義方式有很多,如2的冪。有簡單分離存儲和分離適配方法。

簡單分離存儲的大小類的空閑鏈表包含大小相等的塊,塊大小為大小類中最大元素的大小。分配和釋放塊都是常數時間,不分割,不合並,已分配塊不需要頭部和腳部,空閑鏈表只需是單向的,因此最小塊為單字大小。缺點是很容易造成內部和外部碎片。

分離適配的分配器維護一個空閑鏈表的數組,每個鏈表和一個大小類相關聯,包含大小不同的塊。分配塊時,確定請求的大小類,對適當的空閑鏈表做首次適配。如果找到合適的塊,可以分割它,將剩余的部分插入適當的空閑鏈表中;如果沒找到合適的塊,查找更大的大小類的空閑鏈表。分離適配方法比較常見,如GNU malloc包。這種方法既快、利用率也高。

六、垃圾收集

垃圾收集器是一種動態存儲分配器,自動釋放程序不再需要的已分配塊(垃圾)。支持垃圾收集的系統中,應用顯式分配堆塊,但從不顯式釋放它們。

垃圾收集器將存儲器視為一個有向可達圖,節點分為根節點和堆節點,堆節點對應堆中的已分配塊,根節點對應包含指向堆中的指針但不在堆中的位置,如寄存器、棧裏的變量、虛擬存儲器中讀寫數據區域內的全局變量。當存在根節點到p的有向路徑時,稱p是可達的,不可達節點無法被應用再次使用,即為垃圾。

Java等語言對於創建和使用指針有嚴格的控制,能夠回收所有垃圾。C/C++語言的垃圾收集器通常不能維護可達圖的精確表示,稱為保守的垃圾收集器,它不能回收所有垃圾。

七、和存儲器有關的錯誤

在使用C語言和虛擬存儲器打交道時,很容易犯一些錯誤,而且它們常常是致命的。

  • 間接引用壞指針。間接引用指向空洞或只讀區域的指針,會造成段異常或保護異常而終止。
  • 讀未初始化的存儲器。.bss存儲器位置總是被加載器初始化為0,但堆存儲器不是這樣,假定它為0會造成不可預料的結果。
  • 允許棧緩沖區溢出。不檢查串的大小就寫入棧中的目標緩沖區可能會有緩沖區溢出錯誤。
  • 假設指針和指向的對象大小相同。這可能會導致分配器的合並代碼失敗,但沒有明顯的原因。
  • 造成錯位錯誤。如超出循環造成覆蓋錯誤。
  • 引用指針,而不是指向的對象。
  • 誤解指針運算。指針的算術操作是以指向的對象的大小為單位進行的,而不是字節。
  • 引用不存在的變量。比如棧中的局部變量,棧彈出後它就不再合法了。
  • 引用空閑堆塊中的數據。和上一個類似,這回發生在被釋放的堆中。
  • 引起存儲器泄漏。忘記釋放已分配塊,產生垃圾,對於不終止的程序(守護進程、服務器),存儲器泄漏的錯誤非常嚴重。

堆區的動態內存分配