1. 程式人生 > 其它 >【轉】記憶體管理內幕mallco及free函式實現--簡易記憶體分配器、記憶體池、GC技術

【轉】記憶體管理內幕mallco及free函式實現--簡易記憶體分配器、記憶體池、GC技術

原文:https://www.ibm.com/developerworks/cn/linux/l-memory/

為什麼必須管理記憶體

記憶體管理是計算機程式設計最為基本的領域之一。在很多指令碼語言中,您不必擔心記憶體是如何管理的,這並不能使得記憶體管理的重要性有一點點降低。對實際程式設計來說,理解您的記憶體管理器的能力與侷限性至關重要。在大部分系統語言中,比如 C 和 C++,您必須進行記憶體管理。本文將介紹手工的、半手工的以及自動的記憶體管理實踐的基本概念。

追溯到在 Apple II 上進行組合語言程式設計的時代,那時記憶體管理還不是個大問題。您實際上在執行整個系統。系統有多少記憶體,您就有多少記憶體。您甚至不必費心思去弄明白它有多少記憶體,因為每一臺機器的記憶體數量都相同。所以,如果記憶體需要非常固定,那麼您只需要選擇一個記憶體範圍並使用它即可。

不過,即使是在這樣一個簡單的計算機中,您也會有問題,尤其是當您不知道程式的每個部分將需要多少記憶體時。如果您的空間有限,而記憶體需求是變化的,那麼您需要一些方法來滿足這些需求:

  • 確定您是否有足夠的記憶體來處理資料。
  • 從可用的記憶體中獲取一部分記憶體。
  • 向可用記憶體池(pool)中返回部分記憶體,以使其可以由程式的其他部分或者其他程式使用。

實現這些需求的程式庫稱為分配程式(allocators),因為它們負責分配和回收記憶體。程式的動態性越強,記憶體管理就越重要,您的記憶體分配程式的選擇也就更重要。讓我們來了解可用於記憶體管理的不同方法,它們的好處與不足,以及它們最適用的情形。


C 風格的記憶體分配程式

C 程式語言提供了兩個函式來滿足我們的三個需求:

  • malloc:該函式分配給定的位元組數,並返回一個指向它們的指標。如果沒有足夠的可用記憶體,那麼它返回一個空指標。
  • free:該函式獲得指向由malloc分配的記憶體片段的指標,並將其釋放,以便以後的程式或作業系統使用(實際上,一些malloc實現只能將記憶體歸還給程式,而無法將記憶體歸還給作業系統)。

實體記憶體和虛擬記憶體

要理解記憶體在程式中是如何分配的,首先需要理解如何將記憶體從作業系統分配給程式。計算機上的每一個程序都認為自己可以訪問所有的實體記憶體。顯然,由於同時在執行多個程式,所以每個程序不可能擁有全部記憶體。實際上,這些程序使用的是虛擬記憶體

只是作為一個例子,讓我們假定您的程式正在訪問地址為 629 的記憶體。不過,虛擬記憶體系統不需要將其儲存在位置為 629 的 RAM 中。實際上,它甚至可以不在 RAM 中 —— 如果物理 RAM 已經滿了,它甚至可能已經被轉移到硬碟上!由於這類地址不必反映記憶體所在的物理位置,所以它們被稱為虛擬記憶體。作業系統維持著一個虛擬地址到實體地址的轉換的表,以便計算機硬體可以正確地響應地址請求。並且,如果地址在硬碟上而不是在 RAM 中,那麼作業系統將暫時停止您的程序,將其他記憶體轉存到硬碟中,從硬碟上載入被請求的記憶體,然後再重新啟動您的程序。這樣,每個程序都獲得了自己可以使用的地址空間,可以訪問比您物理上安裝的記憶體更多的記憶體。

在 32-位 x86 系統上,每一個程序可以訪問 4 GB 記憶體。現在,大部分人的系統上並沒有 4 GB 記憶體,即使您將 swap 也算上,每個程序所使用的記憶體也肯定少於 4 GB。因此,當載入一個程序時,它會得到一個取決於某個稱為系統中斷點(system break)的特定地址的初始記憶體分配。該地址之後是未被對映的記憶體 —— 用於在 RAM 或者硬碟中沒有分配相應物理位置的記憶體。因此,如果一個程序執行超出了它初始分配的記憶體,那麼它必須請求作業系統“對映進來(map in)”更多的記憶體。(對映是一個表示一一對應關係的數學術語 —— 當記憶體的虛擬地址有一個對應的實體地址來儲存記憶體內容時,該記憶體將被對映。)

基於 UNIX 的系統有兩個可對映到附加記憶體中的基本系統呼叫:

  • brk:brk()是一個非常簡單的系統呼叫。還記得系統中斷點嗎?該位置是程序對映的記憶體邊界。brk()只是簡單地將這個位置向前或者向後移動,就可以向程序新增記憶體或者從程序取走記憶體。
  • mmap:mmap(),或者說是“記憶體映像”,類似於brk(),但是更為靈活。首先,它可以對映任何位置的記憶體,而不單單隻侷限於程序。其次,它不僅可以將虛擬地址對映到物理的 RAM 或者 swap,它還可以將它們對映到檔案和檔案位置,這樣,讀寫記憶體將對檔案中的資料進行讀寫。不過,在這裡,我們只關心mmap向程序新增被對映的記憶體的能力。munmap()所做的事情與mmap()相反。

如您所見,brk()或者mmap()都可以用來向我們的程序新增額外的虛擬記憶體。在我們的例子中將使用brk(),因為它更簡單,更通用。

實現一個簡單的分配程式

如果您曾經編寫過很多 C 程式,那麼您可能曾多次使用過malloc()free()。不過,您可能沒有用一些時間去思考它們在您的作業系統中是如何實現的。本節將向您展示mallocfree的一個最簡化實現的程式碼,來幫助說明管理記憶體時都涉及到了哪些事情。

要試著執行這些示例,需要先複製本程式碼清單,並將其貼上到一個名為 malloc.c 的檔案中。接下來,我將一次一個部分地對該清單進行解釋。

在大部分作業系統中,記憶體分配由以下兩個簡單的函式來處理:

  • void *malloc(long numbytes):該函式負責分配numbytes大小的記憶體,並返回指向第一個位元組的指標。
  • void free(void *firstbyte):如果給定一個由先前的malloc返回的指標,那麼該函式會將分配的空間歸還給程序的“空閒空間”。

malloc_init將是初始化記憶體分配程式的函式。它要完成以下三件事:將分配程式標識為已經初始化,找到系統中最後一個有效記憶體地址,然後建立起指向我們管理的記憶體的指標。這三個變數都是全域性變數:


清單 1. 我們的簡單分配程式的全域性變數

        
int has_initialized = 0;
void *managed_memory_start;
void *last_valid_address;
      

如前所述,被對映的記憶體的邊界(最後一個有效地址)常被稱為系統中斷點或者當前中斷點。在很多 UNIX® 系統中,為了指出當前系統中斷點,必須使用sbrk(0)函式。sbrk根據引數中給出的位元組數移動當前系統中斷點,然後返回新的系統中斷點。使用引數0只是返回當前中斷點。這裡是我們的malloc初始化程式碼,它將找到當前中斷點並初始化我們的變數:


清單 2. 分配程式初始化函式

        
/* Include the sbrk function */
#include <unistd.h>
void malloc_init()
{
	/* grab the last valid address from the OS */
	last_valid_address = sbrk(0);
	/* we don't have any memory to manage yet, so
	 *just set the beginning to be last_valid_address
	 */
	managed_memory_start = last_valid_address;
	/* Okay, we're initialized and ready to go */
 	has_initialized = 1;
}
      

現在,為了完全地管理記憶體,我們需要能夠追蹤要分配和回收哪些記憶體。在對記憶體塊進行了free呼叫之後,我們需要做的是諸如將它們標記為未被使用的等事情,並且,在呼叫malloc時,我們要能夠定位未被使用的記憶體塊。因此,malloc返回的每塊記憶體的起始處首先要有這個結構:


清單 3. 記憶體控制塊結構定義

        
struct mem_control_block {
	int is_available;
	int size;
};
      

現在,您可能會認為當程式呼叫malloc時這會引發問題 —— 它們如何知道這個結構?答案是它們不必知道;在返回指標之前,我們會將其移動到這個結構之後,把它隱藏起來。這使得返回的指標指向沒有用於任何其他用途的記憶體。那樣,從呼叫程式的角度來看,它們所得到的全部是空閒的、開放的記憶體。然後,當通過free()將該指標傳遞回來時,我們只需要倒退幾個記憶體位元組就可以再次找到這個結構。

在討論分配記憶體之前,我們將先討論釋放,因為它更簡單。為了釋放記憶體,我們必須要做的惟一一件事情就是,獲得我們給出的指標,回退sizeof(struct mem_control_block)個位元組,並將其標記為可用的。這裡是對應的程式碼:


清單 4. 解除分配函式

        
void free(void *firstbyte) {
	struct mem_control_block *mcb;
	/* Backup from the given pointer to find the
	 * mem_control_block
	 */
	mcb = firstbyte - sizeof(struct mem_control_block);
	/* Mark the block as being available */
	mcb->is_available = 1;
	/* That's It!  We're done. */
	return;
}
      

如您所見,在這個分配程式中,記憶體的釋放使用了一個非常簡單的機制,在固定時間內完成記憶體釋放。分配記憶體稍微困難一些。以下是該演算法的略述:


清單 5. 主分配程式的虛擬碼

        
1. If our allocator has not been initialized, initialize it.
2. Add sizeof(struct mem_control_block) to the size requested.
3. start at managed_memory_start.
4. Are we at last_valid address?
5. If we are:
   A. We didn't find any existing space that was large enough
      -- ask the operating system for more and return that.
6. Otherwise:
   A. Is the current space available (check is_available from
      the mem_control_block)?
   B. If it is:
      i)   Is it large enough (check "size" from the
           mem_control_block)?
      ii)  If so:
           a. Mark it as unavailable
           b. Move past mem_control_block and return the
              pointer
      iii) Otherwise:
           a. Move forward "size" bytes
           b. Go back go step 4
   C. Otherwise:
      i)   Move forward "size" bytes
      ii)  Go back to step 4
      

我們主要使用連線的指標遍歷記憶體來尋找開放的記憶體塊。這裡是程式碼:


清單 6. 主分配程式

        
void *malloc(long numbytes) {
	/* Holds where we are looking in memory */
	void *current_location;
	/* This is the same as current_location, but cast to a
	 * memory_control_block
	 */
	struct mem_control_block *current_location_mcb;
	/* This is the memory location we will return.  It will
	 * be set to 0 until we find something suitable
	 */
	void *memory_location;
	/* Initialize if we haven't already done so */
	if(! has_initialized) 	{
		malloc_init();
	}
	/* The memory we search for has to include the memory
	 * control block, but the users of malloc don't need
	 * to know this, so we'll just add it in for them.
	 */
	numbytes = numbytes + sizeof(struct mem_control_block);
	/* Set memory_location to 0 until we find a suitable
	 * location
	 */
	memory_location = 0;
	/* Begin searching at the start of managed memory */
	current_location = managed_memory_start;
	/* Keep going until we have searched all allocated space */
	while(current_location != last_valid_address)
	{
		/* current_location and current_location_mcb point
		 * to the same address.  However, current_location_mcb
		 * is of the correct type, so we can use it as a struct.
		 * current_location is a void pointer so we can use it
		 * to calculate addresses.
		 */
		current_location_mcb =
			(struct mem_control_block *)current_location;
		if(current_location_mcb->is_available)
		{
			if(current_location_mcb->size >= numbytes)
			{
				/* Woohoo!  We've found an open,
				 * appropriately-size location.
				 */
				/* It is no longer available */
				current_location_mcb->is_available = 0;
				/* We own it */
				memory_location = current_location;
				/* Leave the loop */
				break;
			}
		}
		/* If we made it here, it's because the Current memory
		 * block not suitable; move to the next one
		 */
		current_location = current_location +
			current_location_mcb->size;
	}
	/* If we still don't have a valid location, we'll
	 * have to ask the operating system for more memory
	 */
	if(! memory_location)
	{
		/* Move the program break numbytes further */
		sbrk(numbytes);
		/* The new memory will be where the last valid
		 * address left off
		 */
		memory_location = last_valid_address;
		/* We'll move the last valid address forward
		 * numbytes
		 */
		last_valid_address = last_valid_address + numbytes;
		/* We need to initialize the mem_control_block */
		current_location_mcb = memory_location;
		current_location_mcb->is_available = 0;
		current_location_mcb->size = numbytes;
	}
	/* Now, no matter what (well, except for error conditions),
	 * memory_location has the address of the memory, including
	 * the mem_control_block
	 */
	/* Move the pointer past the mem_control_block */
	memory_location = memory_location + sizeof(struct mem_control_block);
	/* Return the pointer */
	return memory_location;
 }
      

這就是我們的記憶體管理器。現在,我們只需要構建它,並在程式中使用它即可。

執行下面的命令來構建malloc相容的分配程式(實際上,我們忽略了realloc()等一些函式,不過,malloc()free()才是最主要的函式):


清單 7. 編譯分配程式

        
gcc -shared -fpic malloc.c -o malloc.so
      

該程式將生成一個名為malloc.so的檔案,它是一個包含有我們的程式碼的共享庫。

在 UNIX 系統中,現在您可以用您的分配程式來取代系統的malloc(),做法如下:


清單 8. 替換您的標準的 malloc

        
LD_PRELOAD=/path/to/malloc.so
export LD_PRELOAD
      

LD_PRELOAD環境變數使動態連結器在載入任何可執行程式之前,先載入給定的共享庫的符號。它還為特定庫中的符號賦予優先權。因此,從現在起,該會話中的任何應用程式都將使用我們的malloc(),而不是隻有系統的應用程式能夠使用。有一些應用程式不使用malloc(),不過它們是例外。其他使用realloc()等其他記憶體管理函式的應用程式,或者錯誤地假定malloc()內部行為的那些應用程式,很可能會崩潰。ash shell 似乎可以使用我們的新malloc()很好地工作。

如果您想確保malloc()正在被使用,那麼您應該通過向函式的入口點新增write()呼叫來進行測試。

我們的記憶體管理器在很多方面都還存在欠缺,但它可以有效地展示記憶體管理需要做什麼事情。它的某些缺點包括:

  • 由於它對系統中斷點(一個全域性變數)進行操作,所以它不能與其他分配程式或者mmap一起使用。
  • 當分配記憶體時,在最壞的情形下,它將不得不遍歷全部程序記憶體;其中可能包括位於硬碟上的很多記憶體,這意味著作業系統將不得不花時間去向硬碟移入資料和從硬碟中移出資料。
  • 沒有很好的記憶體不足處理方案(malloc只假定記憶體分配是成功的)。
  • 它沒有實現很多其他的記憶體函式,比如realloc()
  • 由於sbrk()可能會交回比我們請求的更多的記憶體,所以在堆(heap)的末端會遺漏一些記憶體。
  • 雖然is_available標記只包含一位資訊,但它要使用完整的 4-位元組 的字。
  • 分配程式不是執行緒安全的。
  • 分配程式不能將空閒空間拼合為更大的記憶體塊。
  • 分配程式的過於簡單的匹配演算法會導致產生很多潛在的記憶體碎片。
  • 我確信還有很多其他問題。這就是為什麼它只是一個例子!

其他 malloc 實現

malloc()的實現有很多,這些實現各有優點與缺點。在設計一個分配程式時,要面臨許多需要折衷的選擇,其中包括:

  • 分配的速度。
  • 回收的速度。
  • 有執行緒的環境的行為。
  • 記憶體將要被用光時的行為。
  • 區域性快取。
  • 簿記(Bookkeeping)記憶體開銷。
  • 虛擬記憶體環境中的行為。
  • 小的或者大的物件。
  • 實時保證。

每一個實現都有其自身的優缺點集合。在我們的簡單的分配程式中,分配非常慢,而回收非常快。另外,由於它在使用虛擬記憶體系統方面較差,所以它最適於處理大的物件。

還有其他許多分配程式可以使用。其中包括:

  • Doug Lea Malloc:Doug Lea Malloc 實際上是完整的一組分配程式,其中包括 Doug Lea 的原始分配程式,GNU libc 分配程式和ptmalloc。 Doug Lea 的分配程式有著與我們的版本非常類似的基本結構,但是它加入了索引,這使得搜尋速度更快,並且可以將多個沒有被使用的塊組合為一個大的塊。它還支援快取,以便更快地再次使用最近釋放的記憶體。ptmalloc是 Doug Lea Malloc 的一個擴充套件版本,支援多執行緒。在本文後面的參考資料部分中,有一篇描述 Doug Lea 的 Malloc 實現的文章。
  • BSD Malloc:BSD Malloc 是隨 4.2 BSD 發行的實現,包含在 FreeBSD 之中,這個分配程式可以從預先確實大小的物件構成的池中分配物件。它有一些用於物件大小的 size 類,這些物件的大小為 2 的若干次冪減去某一常數。所以,如果您請求給定大小的一個物件,它就簡單地分配一個與之匹配的 size 類。這樣就提供了一個快速的實現,但是可能會浪費記憶體。在參考資料部分中,有一篇描述該實現的文章。
  • Hoard:編寫Hoard 的目標是使記憶體分配在多執行緒環境中進行得非常快。因此,它的構造以鎖的使用為中心,從而使所有程序不必等待分配記憶體。它可以顯著地加快那些進行很多分配和回收的多執行緒程序的速度。在參考資料部分中,有一篇描述該實現的文章。

眾多可用的分配程式中最有名的就是上述這些分配程式。如果您的程式有特別的分配需求,那麼您可能更願意編寫一個定製的能匹配您的程式記憶體分配方式的分配程式。不過,如果不熟悉分配程式的設計,那麼定製分配程式通常會帶來比它們解決的問題更多的問題。要獲得關於該主題的適當的介紹,請參閱 Donald Knuth 撰寫的The Art of Computer Programming Volume 1: Fundamental Algorithms中的第 2.5 節“Dynamic Storage Allocation”(請參閱參考資料中的連結)。它有點過時,因為它沒有考慮虛擬記憶體環境,不過大部分演算法都是基於前面給出的函式。

在 C++ 中,通過過載operator new(),您可以以每個類或者每個模板為單位實現自己的分配程式。在 Andrei Alexandrescu 撰寫的Modern C++ Design的第 4 章(“Small Object Allocation”)中,描述了一個小物件分配程式(請參閱參考資料中的連結)。

基於 malloc() 的記憶體管理的缺點

不只是我們的記憶體管理器有缺點,基於malloc()的記憶體管理器仍然也有很多缺點,不管您使用的是哪個分配程式。對於那些需要保持長期儲存的程式使用malloc()來管理記憶體可能會非常令人失望。如果您有大量的不固定的記憶體引用,經常難以知道它們何時被釋放。生存期侷限於當前函式的記憶體非常容易管理,但是對於生存期超出該範圍的記憶體來說,管理記憶體則困難得多。而且,關於記憶體管理是由進行呼叫的程式還是由被呼叫的函式來負責這一問題,很多 API 都不是很明確。

因為管理記憶體的問題,很多程式傾向於使用它們自己的記憶體管理規則。C++ 的異常處理使得這項任務更成問題。有時好像致力於管理記憶體分配和清理的程式碼比實際完成計算任務的程式碼還要多!因此,我們將研究記憶體管理的其他選擇。


半自動記憶體管理策略

引用計數

引用計數是一種半自動(semi-automated)的記憶體管理技術,這表示它需要一些程式設計支援,但是它不需要您確切知道某一物件何時不再被使用。引用計數機制為您完成記憶體管理任務。

在引用計數中,所有共享的資料結構都有一個域來包含當前活動“引用”結構的次數。當向一個程式傳遞一個指向某個資料結構指標時,該程式會將引用計數增加 1。實質上,您是在告訴資料結構,它正在被儲存在多少個位置上。然後,當您的程序完成對它的使用後,該程式就會將引用計數減少 1。結束這個動作之後,它還會檢查計數是否已經減到零。如果是,那麼它將釋放記憶體。

這樣做的好處是,您不必追蹤程式中某個給定的資料結構可能會遵循的每一條路徑。每次對其區域性的引用,都將導致計數的適當增加或減少。這樣可以防止在使用資料結構時釋放該結構。不過,當您使用某個採用引用計數的資料結構時,您必須記得執行引用計數函式。另外,內建函式和第三方的庫不會知道或者可以使用您的引用計數機制。引用計數也難以處理髮生迴圈引用的資料結構。

要實現引用計數,您只需要兩個函式 —— 一個增加引用計數,一個減少引用計數並當計數減少到零時釋放記憶體。

一個示例引用計數函式集可能看起來如下所示:


清單 9. 基本的引用計數函式

        
/* Structure Definitions*/
/* Base structure that holds a refcount */
struct refcountedstruct
{
	int refcount;
}
/* All refcounted structures must mirror struct
 * refcountedstruct for their first variables
 */
/* Refcount maintenance functions */
/* Increase reference count */
void REF(void *data)
{
	struct refcountedstruct *rstruct;
	rstruct = (struct refcountedstruct *) data;
	rstruct->refcount++;
}
/* Decrease reference count */
void UNREF(void *data)
{
	struct refcountedstruct *rstruct;
	rstruct = (struct refcountedstruct *) data;
	rstruct->refcount--;
	/* Free the structure if there are no more users */
	if(rstruct->refcount == 0)
	{
		free(rstruct);
	}
}
      

REFUNREF可能會更復雜,這取決於您想要做的事情。例如,您可能想要為多執行緒程式增加鎖,那麼您可能想擴充套件refcountedstruct,使它同樣包含一個指向某個在釋放記憶體之前要呼叫的函式的指標(類似於面嚮物件語言中的解構函式 —— 如果您的結構中包含這些指標,那麼這是必需的)。

當使用REFUNREF時,您需要遵守這些指標的分配規則:

  • UNREF分配前左端指標(left-hand-side pointer)指向的值。
  • REF分配後左端指標(left-hand-side pointer)指向的值。

在傳遞使用引用計數的結構的函式中,函式需要遵循以下這些規則:

  • 在函式的起始處 REF 每一個指標。
  • 在函式的結束處 UNREF 第一個指標。

以下是一個使用引用計數的生動的程式碼示例:


清單 10. 使用引用計數的示例

        
/* EXAMPLES OF USAGE */
/* Data type to be refcounted */
struct mydata
{
	int refcount; /* same as refcountedstruct */
	int datafield1; /* Fields specific to this struct */
	int datafield2;
	/* other declarations would go here as appropriate */
};
/* Use the functions in code */
void dosomething(struct mydata *data)
{
	REF(data);
	/* Process data */
	/* when we are through */
	UNREF(data);
}
struct mydata *globalvar1;
/* Note that in this one, we don't decrease the
 * refcount since we are maintaining the reference
 * past the end of the function call through the
 * global variable
 */
void storesomething(struct mydata *data)
{
	REF(data); /* passed as a parameter */
	globalvar1 = data;
	REF(data); /* ref because of Assignment */
	UNREF(data); /* Function finished */
}
      

由於引用計數是如此簡單,大部分程式設計師都自已去實現它,而不是使用庫。不過,它們依賴於mallocfree等低層的分配程式來實際地分配和釋放它們的記憶體。

在 Perl 等高階語言中,進行記憶體管理時使用引用計數非常廣泛。在這些語言中,引用計數由語言自動地處理,所以您根本不必擔心它,除非要編寫擴充套件模組。由於所有內容都必須進行引用計數,所以這會對速度產生一些影響,但它極大地提高了程式設計的安全性和方便性。以下是引用計數的益處:

  • 實現簡單。
  • 易於使用。
  • 由於引用是資料結構的一部分,所以它有一個好的快取位置。

不過,它也有其不足之處:

  • 要求您永遠不要忘記呼叫引用計數函式。
  • 無法釋放作為迴圈資料結構的一部分的結構。
  • 減緩幾乎每一個指標的分配。
  • 儘管所使用的物件採用了引用計數,但是當使用異常處理(比如trysetjmp()/longjmp())時,您必須採取其他方法。
  • 需要額外的記憶體來處理引用。
  • 引用計數佔用了結構中的第一個位置,在大部分機器中最快可以訪問到的就是這個位置。
  • 在多執行緒環境中更慢也更難以使用。

C++ 可以通過使用智慧指標(smart pointers)來容忍程式設計師所犯的一些錯誤,智慧指標可以為您處理引用計數等指標處理細節。不過,如果不得不使用任何先前的不能處理智慧指標的程式碼(比如對 C 庫的聯接),實際上,使用它們的後果通實比不使用它們更為困難和複雜。因此,它通常只是有益於純 C++ 專案。如果您想使用智慧指標,那麼您實在應該去閱讀 Alexandrescu 撰寫的Modern C++ Design一書中的“Smart Pointers”那一章。

記憶體池

記憶體池是另一種半自動記憶體管理方法。記憶體池幫助某些程式進行自動記憶體管理,這些程式會經歷一些特定的階段,而且每個階段中都有分配給程序的特定階段的記憶體。例如,很多網路伺服器程序都會分配很多針對每個連線的記憶體 —— 記憶體的最大生存期限為當前連線的存在期。Apache 使用了池式記憶體(pooled memory),將其連線拆分為各個階段,每個階段都有自己的記憶體池。在結束每個階段時,會一次釋放所有記憶體。

在池式記憶體管理中,每次記憶體分配都會指定記憶體池,從中分配記憶體。每個記憶體池都有不同的生存期限。在 Apache 中,有一個持續時間為伺服器存在期的記憶體池,還有一個持續時間為連線的存在期的記憶體池,以及一個持續時間為請求的存在期的池,另外還有其他一些記憶體池。因此,如果我的一系列函式不會生成比連線持續時間更長的資料,那麼我就可以完全從連線池中分配記憶體,並知道在連線結束時,這些記憶體會被自動釋放。另外,有一些實現允許註冊清除函式(cleanup functions),在清除記憶體池之前,恰好可以呼叫它,來完成在記憶體被清理前需要完成的其他所有任務(類似於面向物件中的解構函式)。

要在自己的程式中使用池,您既可以使用 GNU libc 的 obstack 實現,也可以使用 Apache 的 Apache Portable Runtime。GNU obstack 的好處在於,基於 GNU 的 Linux 發行版本中預設會包括它們。Apache Portable Runtime 的好處在於它有很多其他工具,可以處理編寫多平臺伺服器軟體所有方面的事情。要深入瞭解 GNU obstack 和 Apache 的池式記憶體實現,請參閱參考資料部分中指向這些實現的文件的連結。

下面的假想程式碼列表展示瞭如何使用 obstack:


清單 11. obstack 的示例程式碼

        
#include <obstack.h>
#include <stdlib.h>
/* Example code listing for using obstacks */
/* Used for obstack macros (xmalloc is
   a malloc function that exits if memory
   is exhausted */
#define obstack_chunk_alloc xmalloc
#define obstack_chunk_free free
/* Pools */
/* Only permanent allocations should go in this pool */
struct obstack *global_pool;
/* This pool is for per-connection data */
struct obstack *connection_pool;
/* This pool is for per-request data */
struct obstack *request_pool;
void allocation_failed()
{
	exit(1);
}
int main()
{
	/* Initialize Pools */
	global_pool = (struct obstack *)
		xmalloc (sizeof (struct obstack));
	obstack_init(global_pool);
	connection_pool = (struct obstack *)
		xmalloc (sizeof (struct obstack));
	obstack_init(connection_pool);
	request_pool = (struct obstack *)
		xmalloc (sizeof (struct obstack));
	obstack_init(request_pool);
	/* Set the error handling function */
	obstack_alloc_failed_handler = &allocation_failed;
	/* Server main loop */
	while(1)
	{
		wait_for_connection();
		/* We are in a connection */
		while(more_requests_available())
		{
			/* Handle request */
			handle_request();
			/* Free all of the memory allocated
			 * in the request pool
			 */
			obstack_free(request_pool, NULL);
		}
		/* We're finished with the connection, time
		 * to free that pool
		 */
		obstack_free(connection_pool, NULL);
	}
}
int handle_request()
{
	/* Be sure that all object allocations are allocated
	 * from the request pool
	 */
	int bytes_i_need = 400;
	void *data1 = obstack_alloc(request_pool, bytes_i_need);
	/* Do stuff to process the request */
	/* return */
	return 0;
}
      

基本上,在操作的每一個主要階段結束之後,這個階段的 obstack 會被釋放。不過,要注意的是,如果一個過程需要分配持續時間比當前階段更長的記憶體,那麼它也可以使用更長期限的 obstack,比如連線或者全域性記憶體。傳遞給obstack_free()NULL指出它應該釋放 obstack 的全部內容。可以用其他的值,但是它們通常不怎麼實用。

使用池式記憶體分配的益處如下所示:

  • 應用程式可以簡單地管理記憶體。
  • 記憶體分配和回收更快,因為每次都是在一個池中完成的。分配可以在 O(1) 時間內完成,釋放記憶體池所需時間也差不多(實際上是 O(n) 時間,不過在大部分情況下會除以一個大的因數,使其變成 O(1))。
  • 可以預先分配錯誤處理池(Error-handling pools),以便程式在常規記憶體被耗盡時仍可以恢復。
  • 有非常易於使用的標準實現。

池式記憶體的缺點是:

  • 記憶體池只適用於操作可以分階段的程式。
  • 記憶體池通常不能與第三方庫很好地合作。
  • 如果程式的結構發生變化,則不得不修改記憶體池,這可能會導致記憶體管理系統的重新設計。
  • 您必須記住需要從哪個池進行分配。另外,如果在這裡出錯,就很難捕獲該記憶體池。

垃圾收集

垃圾收集(Garbage collection)是全自動地檢測並移除不再使用的資料物件。垃圾收集器通常會在當可用記憶體減少到少於一個具體的閾值時執行。通常,它們以程式所知的可用的一組“基本”資料 —— 棧資料、全域性變數、暫存器 —— 作為出發點。然後它們嘗試去追蹤通過這些資料連線到每一塊資料。收集器找到的都是有用的資料;它沒有找到的就是垃圾,可以被銷燬並重新使用這些無用的資料。為了有效地管理記憶體,很多型別的垃圾收集器都需要知道資料結構內部指標的規劃,所以,為了正確執行垃圾收集器,它們必須是語言本身的一部分。

收集器的型別

  • 複製(copying):這些收集器將記憶體儲存器分為兩部分,只允許資料駐留在其中一部分上。它們定時地從“基本”的元素開始將資料從一部分複製到另一部分。記憶體新近被佔用的部分現在成為活動的,另一部分上的所有內容都認為是垃圾。另外,當進行這項複製操作時,所有指標都必須被更新為指向每個記憶體條目的新位置。因此,為使用這種垃圾收集方法,垃圾收集器必須與程式語言整合在一起。
  • 標記並清理(Mark and sweep):每一塊資料都被加上一個標籤。不定期的,所有標籤都被設定為 0,收集器從“基本”的元素開始遍歷資料。當它遇到記憶體時,就將標籤標記為 1。最後沒有被標記為 1 的所有內容都認為是垃圾,以後分配記憶體時會重新使用它們。
  • 增量的(Incremental):增量垃圾收集器不需要遍歷全部資料物件。因為在收集期間的突然等待,也因為與訪問所有當前資料相關的快取問題(所有內容都不得不被頁入(page-in)),遍歷所有記憶體會引發問題。增量收集器避免了這些問題。
  • 保守的(Conservative):保守的垃圾收集器在管理記憶體時不需要知道與資料結構相關的任何資訊。它們只檢視所有資料型別,並假定它們可以全部都是指標。所以,如果一個位元組序列可以是一個指向一塊被分配的記憶體的指標,那麼收集器就將其標記為正在被引用。有時沒有被引用的記憶體會被收集,這樣會引發問題,例如,如果一個整數域中包含一個值,該值是已分配記憶體的地址。不過,這種情況極少發生,而且它只會浪費少量記憶體。保守的收集器的優勢是,它們可以與任何程式語言相整合。

Hans Boehm 的保守垃圾收集器是可用的最流行的垃圾收集器之一,因為它是免費的,而且既是保守的又是增量的,可以使用--enable-redirect-malloc選項來構建它,並且可以將它用作系統分配程式的簡易替代者(drop-in replacement)(用malloc/free代替它自己的 API)。實際上,如果這樣做,您就可以使用與我們在示例分配程式中所使用的相同的LD_PRELOAD技巧,在系統上的幾乎任何程式中啟用垃圾收集。如果您懷疑某個程式正在洩漏記憶體,那麼您可以使用這個垃圾收集器來控制程序。在早期,當 Mozilla 嚴重地洩漏記憶體時,很多人在其中使用了這項技術。這種垃圾收集器既可以在 Windows® 下執行,也可以在 UNIX 下執行。

垃圾收集的一些優點:

  • 您永遠不必擔心記憶體的雙重釋放或者物件的生命週期。
  • 使用某些收集器,您可以使用與常規分配相同的 API。

其缺點包括:

  • 使用大部分收集器時,您都無法干涉何時釋放記憶體。
  • 在多數情況下,垃圾收集比其他形式的記憶體管理更慢。
  • 垃圾收集錯誤引發的缺陷難於除錯。
  • 如果您忘記將不再使用的指標設定為 null,那麼仍然會有記憶體洩漏。

結束語

一切都需要折衷:效能、易用、易於實現、支援執行緒的能力等,這裡只列出了其中的一些。為了滿足專案的要求,有很多記憶體管理模式可以供您使用。每種模式都有大量的實現,各有其優缺點。對很多專案來說,使用程式設計環境預設的技術就足夠了,不過,當您的專案有特殊的需要時,瞭解可用的選擇將會有幫助。下表對比了本文中涉及的記憶體管理策略。

表 1. 記憶體分配策略的對比

策略 分配速度 回收速度 區域性快取 易用性 通用性 實時可用 SMP 執行緒友好
定製分配程式 取決於實現 取決於實現 取決於實現 很難 取決於實現 取決於實現
簡單分配程式 記憶體使用少時較快 很快 容易
GNUmalloc 容易
Hoard 容易
引用計數 N/A N/A 非常好 是(取決於malloc實現) 取決於實現
非常快 極好 是(取決於malloc實現) 取決於實現
垃圾收集 中(進行收集時慢) 幾乎不
增量垃圾收集 幾乎不
增量保守垃圾收集 容易 幾乎不

參考資料

  • 您可以參閱本文在 developerWorks 全球站點上的英文原文

Web 上的文件

基本的分配程式

池式分配程式

智慧指標和定製分配程式

  • Loki C++ Library有很多為 C++ 實現的通用模式,包括智慧指標和一個定製的小物件分配程式。

垃圾收集器

關於現代作業系統中的虛擬記憶體的文章

關於 malloc 的文章

關於定製分配程式的文章

關於垃圾收集的文章

Web 上的通用參考資料

書籍

  • Michael Daconta 撰寫的C++ Pointers and Dynamic Memory Management介紹了關於記憶體管理的很多技術。

  • Frantisek Franek 撰寫的Memory as a Programming Concept in C and C++討論了有效使用記憶體的技術與工具,並給出了在計算機程式設計中應當引起注意的記憶體相關錯誤的角色。

  • Richard Jones 和 Rafael Lins 合著的Garbage Collection: Algorithms for Automatic Dynamic Memory Management描述了當前使用的最常見的垃圾收集演算法。

  • 在 Donald Knuth 撰寫的The Art of Computer Programming第 1 卷Fundamental Algorithms的第 2.5 節“Dynamic Storage Allocation”中,描述了實現基本的分配程式的一些技術。

  • 在 Donald Knuth 撰寫的The Art of Computer Programming第 1 卷Fundamental Algorithms的第 2.3.5 節“Lists and Garbage Collection”中,討論了用於列表的垃圾收集演算法。

  • Andrei Alexandrescu 撰寫的Modern C++ Design第 4 章“Small Object Allocation”描述了一個比 C++ 標準分配程式效率高得多的一個高速小物件分配程式。

  • Andrei Alexandrescu 撰寫的Modern C++ Design第 7 章“Smart Pointers”描述了在 C++ 中智慧指標的實現。

  • Jonathan 撰寫的Programming from the Ground Up第 8 章“Intermediate Memory Topics”中有本文使用的簡單分配程式的一個組合語言版本。

來自 developerWorks

  • 自我管理資料緩衝區記憶體(developerWorks,2004 年 1 月)略述了一個用於管理記憶體的自管理的抽象資料快取器的偽 C (pseudo-C)實現。

  • A framework for the user defined malloc replacement feature(developerWorks,2002 年 2 月)展示瞭如何利用 AIX 中的一個工具,使用自己設計的記憶體子系統取代原有的記憶體子系統。

  • 掌握 Linux 除錯技術(developerWorks,2002 年 8 月)描述了可以使用除錯方法的 4 種不同情形:段錯誤、記憶體溢位、記憶體洩漏和掛起。

  • 處理 Java 程式中的記憶體漏洞(developerWorks,2001 年 2 月)中,瞭解導致 Java 記憶體洩漏的原因,以及何時需要考慮它們。

  • developerWorks Linux 專區中,可以找到更多為 Linux 開發人員準備的參考資料。

  • 從 developerWorks 的Speed-start your Linux app專區中,可以下載運行於 Linux 之上的 IBM 中介軟體產品的免費測試版本,其中包括 WebSphere® Studio Application Developer、WebSphere Application Server、DB2® Universal Database、Tivoli® Access Manager 和 Tivoli Directory Server,查詢 how-to 文章和技術支援。

  • 通過參與developerWorks blogs加入到 developerWorks 社群。

  • 可以在 Developer Bookstore Linux 專欄中定購打折出售的 Linux 書籍