malloc 函數詳解

不少學過C的人對malloc都不是很瞭解,知道使用malloc要加頭文件,知道malloc是分配一塊連續的內存,知道和free函數是一塊兒用的。可是可是:linux

一部分人仍是將:malloc看成系統所提供的或者是C的關鍵字,事實上:malloc只是C標準庫中提供的一個普通函數git

並且不少不少人都對malloc的具體實現機制不是很瞭解。
程序員



1,關於malloc以及相關的幾個函數算法

      #include <stdlib.h>(Linux下)

       void *malloc(size_t size);
       void free(void *ptr);
       void *calloc(size_t nmemb, size_t size);
       void *realloc(void *ptr, size_t size);

      也能夠這樣認爲(window下)原型:extern void *malloc(unsigned int num_bytes);
編程

                                                      頭文件:#include <malloc.h>或者#include <alloc.h>二者的內容是徹底同樣的。數組

       若是分配成功:則返回指向被分配內存空間的指針緩存

       否則,返回空指針NULL。數據結構

       同時,當內存再也不使用的時候,應使用free()函數將內存塊釋放掉。app


       關於:void *,表示未肯定類型的指針。C,C++規定,void *類型能夠強轉爲任何其餘類型的的指針。ide


malloc returns a void pointer to the allocated space, or NULL if there is insufficient memory available. To return a pointer to a type other than void, use a type cast on the return value. The storage space pointed to by the return value is guaranteed to be suitably aligned for storage of any type of object. If size is 0, malloc allocates a zero-length item in the heap and returns a valid pointer to that item. Always check the return from malloc, even if the amount of memory requested is small.


         關於void *的其餘說法:

         void * p1;

         int *p2;

         p1 = p2;                 就是說其餘任意類型均可以直接賦值給它,無需進行強轉,可是反過來不能夠。


malloc:

malloc分配的內存大小至少爲size參數所指定的字節數

malloc的返回值是一個指針,指向一段可用內存的起始地址

屢次調用malloc所分配的地址不能有重疊部分,除非某次malloc所分配的地址被釋放掉

malloc應該儘快完成內存分配並返回(不能使用NP-hard的內存分配算法)

實現malloc時應同時實現內存大小調整和內存釋放函數(realloc和free)




malloc和free函數是配對的,若是申請後不釋放就是內存泄露;若是無端釋放那就是什麼都沒有作,釋放只能釋放一次,若是釋放兩次及兩次以上會出現錯誤(可是釋放空指針例外,釋放空指針其實也等於什麼都沒有作,因此,釋放多少次都是能夠的)






2,malloc和new

     

     new返回指定類型的指針,而且能夠自動計算所須要的大小。

    

      int *p;

      p = new int;   //返回類型爲int *類型,分配的大小爲sizeof(int)

      p = new int[100];    //返回類型爲int *類型,分配的大小爲sizeof(int) * 100

    

      而malloc則必須由咱們計算字節數,而且在返回的時候強轉成實際指定類型的指針。

  

      int *p;

      p = (int *)malloc(sizeof(int));


          1,malloc的返回是void *,若是咱們寫成了: p = malloc(sizeof(int));間接的說明了(將void *轉化給了int *,這不合理)

      2,malloc的實參是sizeof(int),用於指明一個整形數據須要的大小,若是咱們寫成:

            p =  (int *)malloc(1),          那麼能夠看出:只是申請了一個字節的空間,若是向裏面存放了一個整數的話,

            將會佔用額外的3個字節,可能會改變原有內存空間中的數據

     3,malloc只管分配內存,並不能對其進行初始化,因此獲得的一片新內存中,其值將是隨機的。通常意義上:我

           們習慣性的將其初始化爲NULL。            固然,也能夠用memset函數的。



簡單的說:


malloc 函數其實就是在內存中:找一片指定大小的空間,而後將這個空間的首地址給一個指針變量,這裏的指針變量能夠是一個單獨的指針,也能夠是一個數組的首地址, 這要看malloc函數中參數size的具體內容。咱們這裏malloc分配的內存空間在邏輯上是連續的,而在物理上能夠不連續。咱們做爲程序員,關注的 是邏輯上的連續,其它的,操做系統會幫着咱們處理的。

 


下面咱們聊聊malloc的具體實現機制:

Linux內存管理

 

虛擬內存地址與物理內存地址

  爲了簡單,現代操做系統在處理內存地址時,廣泛採用虛擬內存地址技術。即在彙編程序(或機器語言)層面,當涉及內存地址時, 都是使用虛擬內存地址。採用這種技術時,每一個進程彷彿本身獨享一片2N字節的內存,其中N是機器位數。例如在64位CPU和64位操做系統下,每一個進程的 虛擬地址空間爲264Byte。

  這種虛擬地址空間的做用主要是簡化程序的編寫及方便操做系統對進程間內存的隔離管理,真實中的進程不太可能(也用不到)如此大的內存空間,實際能用到的內存取決於物理內存大小。

  因爲在機器語言層面都是採用虛擬地址,當實際的機器碼程序涉及到內存操做時,須要根據當前進程運行的實際上下文將虛擬地址轉換爲物理內存地址,才能實現對真實內存數據的操做。這個轉換通常由一個叫MMU(Memory Management Unit)的硬件完成。

 


頁與地址構成

  在現代操做系統中,不管是虛擬內存仍是物理內存,都不是以字節爲單位進行管理的,而是以頁(Page)爲單位。一個內存頁是一段固定大小的連續內存地址的總稱,具體到Linux中,典型的內存頁大小爲4096Byte(4K)。

  因此內存地址能夠分爲頁號和頁內偏移量。下面以64位機器,4G物理內存,4K頁大小爲例,虛擬內存地址和物理內存地址的組成以下:

內存地址構成

  上面是虛擬內存地址,下面是物理內存地址。因爲頁大小都是4K,因此頁內便宜都是用低12位表示,而剩下的高地址表示頁號。

  MMU映射單位並非字節,而是頁,這個映射經過查一個常駐內存的數據結構頁表來實現。如今計算機具體的內存地址映射比較複雜,爲了加快速度會引入一系列緩存和優化,例如TLB等機制。下面給出一個通過簡化的內存地址翻譯示意圖,雖然通過了簡化,可是基本原理與現代計算機真實的狀況的一致的。

內存地址翻譯


內存頁與磁盤頁

  咱們知道通常將內存看作磁盤的的緩存,有時MMU在工做時,會發現頁表代表某個內存頁不在物理內存中,此時會觸發一個缺頁異 常(Page Fault),此時系統會到磁盤中相應的地方將磁盤頁載入到內存中,而後從新執行因爲缺頁而失敗的機器指令。關於這部分,由於能夠看作對malloc實現 是透明的,因此再也不詳細講述,有興趣的能夠參考《深刻理解計算機系統》相關章節。

  最後附上一張在維基百科找到的更加符合真實地址翻譯的流程供你們參考,這張圖加入了TLB和缺頁異常的流程(圖片來源頁)。

較爲完整的地址翻譯流程


Linux進程級內存管理

  2.2.1 內存排布

  明白了虛擬內存和物理內存的關係及相關的映射機制,下面看一下具體在一個進程內是如何排布內存的。

  以Linux 64位系統爲例。理論上,64bit內存地址可用空間爲0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF,這是個至關龐大的空間,Linux實際上只用了其中一小部分(256T)。

  根據Linux內核相關文檔描述,Linux64位操做系統僅使用低47位,高17位作擴展(只能是全0或全1)。因此,實際用到的地址爲空間爲0x0000000000000000 ~ 0x00007FFFFFFFFFFF和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF,其中前面爲用戶空間(User Space),後者爲內核空間(Kernel Space)。圖示以下:

Linux進程地址排布

  對用戶來講,主要關注的空間是User Space。將User Space放大後,能夠看到裏面主要分爲以下幾段:

  • Code:這是整個用戶空間的最低地址部分,存放的是指令(也就是程序所編譯成的可執行機器碼)
  • Data:這裏存放的是初始化過的全局變量
  • BSS:這裏存放的是未初始化的全局變量
  • Heap:堆,這是咱們本文重點關注的地方,堆自低地址向高地址增加,後面要講到的brk相關的系統調用就是從這裏分配內存
  • Mapping Area:這裏是與mmap系統調用相關的區域。大多數實際的malloc實現會考慮經過mmap分配較大塊的內存區域,本文不討論這種狀況。這個區域自高地址向低地址增加
  • Stack:這是棧區域,自高地址向低地址增加

  下面咱們主要關注Heap區域的操做。對整個Linux內存排布有興趣的同窗能夠參考其它資料。

 

Heap內存模型

  通常來講,malloc所申請的內存主要從Heap區域分配(本文不考慮經過mmap申請大塊內存的狀況)。

  由上文知道,進程所面對的虛擬內存地址空間,只有按頁映射到物理內存地址,才能真正使用。受物理存儲容量限制,整個堆虛擬內存空間不可能所有映射到實際的物理內存。Linux對堆的管理示意以下:

Linux進程堆管理

  Linux維護一個break指針,這個指針指向堆空間的某個地址。從堆起始地址到break之間的地址空間爲映射好的,能夠供進程訪問;而從break往上,是未映射的地址空間,若是訪問這段空間則程序會報錯。


brk與sbrk

  由上文知道,要增長一個進程實際的可用堆大小,就須要將break指針向高地址移動。Linux經過brk和sbrk系統調用操做break指針。兩個系統調用的原型以下:

int brk(void *addr); void *sbrk(intptr_t increment);

  brk將break指針直接設置爲某個地址,而sbrk將break從當前位置移動increment所指定的增量。brk 在執行成功時返回0,不然返回-1並設置errno爲ENOMEM;sbrk成功時返回break移動以前所指向的地址,不然返回(void *)-1。

  一個小技巧是,若是將increment設置爲0,則能夠得到當前break的地址。

  另外須要注意的是,因爲Linux是按頁進行內存映射的,因此若是break被設置爲沒有按頁大小對齊,則系統實際上會在最 後映射一個完整的頁,從而實際已映射的內存空間比break指向的地方要大一些。可是使用break以後的地址是很危險的(儘管也許break以後確實有 一小塊可用內存地址)。



資源限制與rlimit

  系統對每個進程所分配的資源不是無限的,包括可映射的內存空間,所以每一個進程有一個rlimit表示當前進程可用的資源上限。這個限制能夠經過getrlimit系統調用獲得,下面代碼獲取當前進程虛擬內存空間的rlimit:

int main() { struct rlimit *limit = (struct rlimit *)malloc(sizeof(struct rlimit)); getrlimit(RLIMIT_AS, limit); printf("soft limit: %ld, hard limit: %ld\n", limit->rlim_cur, limit->rlim_max); }

  其中rlimit是一個結構體:

struct rlimit { rlim_t rlim_cur; /* Soft limit */ rlim_t rlim_max; /* Hard limit (ceiling for rlim_cur) */ };

  每種資源有軟限制和硬限制,而且能夠經過setrlimit對rlimit進行有條件設置。其中硬限制做爲軟限制的上限,非特權進程只能設置軟限制,且不能超過硬限制。



實現malloc

  3.1 玩具實現

  在正式開始討論malloc的實現前,咱們能夠利用上述知識實現一個簡單但幾乎無法用於真實的玩具malloc,權當對上面知識的複習:

/* 一個玩具malloc */ #include <sys/types.h> #include <unistd.h> void *malloc(size_t size) { void *p; p = sbrk(0); if (sbrk(size) == (void *)-1) return NULL; return p; }

  這個malloc每次都在當前break的基礎上增長size所指定的字節數,並將以前break的地址返回。這個malloc因爲對所分配的內存缺少記錄,不便於內存釋放,因此沒法用於真實場景。



3.2 正式實現

  下面嚴肅點討論malloc的實現方案。

  3.2.1 數據結構

  首先咱們要肯定所採用的數據結構。一個簡單可行方案是將堆內存空間以塊(Block)的形式組織起來,每一個塊由meta區和 數據區組成,meta區記錄數據塊的元信息(數據區大小、空閒標誌位、指針等等),數據區是真實分配的內存區域,而且數據區的第一個字節地址即爲 malloc返回的地址。

  能夠用以下結構體定義一個block:

typedef struct s_block *t_block; struct s_block { size_t size; /* 數據區大小 */ t_block next; /* 指向下個塊的指針 */ int free; /* 是不是空閒塊 */ int padding; /* 填充4字節,保證meta塊長度爲8的倍數 */ char data[1] /* 這是一個虛擬字段,表示數據塊的第一個字節,長度不該計入meta */ };

  因爲咱們只考慮64位機器,爲了方便,咱們在結構體最後填充一個int,使得結構體自己的長度爲8的倍數,以便內存對齊。示意圖以下:

Block結構


關於長度爲1的數組注意:http://bbs.csdn.net/topics/300077699


3.2.2 尋找合適的block

  如今考慮如何在block鏈中查找合適的block。通常來講有兩種查找算法:

  • First fit:從頭開始,使用第一個數據區大小大於要求size的塊所謂這次分配的塊
  • Best fit:從頭開始,遍歷全部塊,使用數據區大小大於size且差值最小的塊做爲這次分配的塊

  兩種方法各有千秋,best fit具備較高的內存使用率(payload較高),而first fit具備更好的運行效率。這裏咱們採用first fit算法。

/* First fit */ t_block find_block(t_block *last, size_t size) { t_block b = first_block; while(b && !(b->free && b->size >= size)) { *last = b; b = b->next; } return b; }

  find_block從frist_block開始,查找第一個符合要求的block並返回block起始地址,若是找不到 這返回NULL。這裏在遍歷時會更新一個叫last的指針,這個指針始終指向當前遍歷的block。這是爲了若是找不到合適的block而開闢新 block使用的,具體會在接下來的一節用到。



3.2.3 開闢新的block

  若是現有block都不能知足size的要求,則須要在鏈表最後開闢一個新的block。這裏關鍵是如何只使用sbrk建立一個struct:

#define BLOCK_SIZE 24 /* 因爲存在虛擬的data字段,sizeof不能正確計算meta長度,這裏手工設置 */ t_block extend_heap(t_block last, size_t s) { t_block b; b = sbrk(0); if(sbrk(BLOCK_SIZE + s) == (void *)-1) return NULL; b->size = s; b->next = NULL; if(last) last->next = b; b->free = 0; return b; }

  3.2.4 分裂block

  First fit有一個比較致命的缺點,就是可能會讓很小的size佔據很大的一塊block,此時,爲了提升payload,應該在剩餘數據區足夠大的狀況下,將其分裂爲一個新的block,示意以下:

分裂block

  實現代碼:

void split_block(t_block b, size_t s) { t_block new; new = b->data + s; new->size = b->size - s - BLOCK_SIZE ; new->next = b->next; new->free = 1; b->size = s; b->next = new; }



 

3.2.5 malloc的實現

  有了上面的代碼,咱們能夠利用它們整合成一個簡單但初步可用的malloc。注意首先咱們要定義個block鏈表的頭first_block,初始化爲NULL;另外,咱們須要剩餘空間至少有BLOCK_SIZE + 8才執行分裂操做。

  因爲咱們但願malloc分配的數據區是按8字節對齊,因此在size不爲8的倍數時,咱們須要將size調整爲大於size的最小的8的倍數:

size_t align8(size_t s) {
if(s & 0x7 == 0) return s; return ((s >> 3) + 1) << 3; }
#define BLOCK_SIZE 24
void *first_block=NULL; /* other functions... */ void *malloc(size_t size) { t_block b, last; size_t s; /* 對齊地址 */ s = align8(size); if(first_block) { /* 查找合適的block */ last = first_block; b = find_block(&last, s); if(b) { /* 若是能夠,則分裂 */ if ((b->size - s) >= ( BLOCK_SIZE + 8)) split_block(b, s); b->free = 0; } else { /* 沒有合適的block,開闢一個新的 */ b = extend_heap(last, s); if(!b) return NULL; } } else { b = extend_heap(NULL, s); if(!b) return NULL; first_block = b; } return b->data; }

 


 

3.2.6 calloc的實現

  有了malloc,實現calloc只要兩步:

  1. malloc一段內存
  2. 將數據區內容置爲0

  因爲咱們的數據區是按8字節對齊的,因此爲了提升效率,咱們能夠每8字節一組置0,而不是一個一個字節設置。咱們能夠經過新建一個size_t指針,將內存區域強制看作size_t類型來實現。

void *calloc(size_t number, size_t size) { size_t *new; size_t s8, i; new = malloc(number * size); if(new) { s8 = align8(number * size) >> 3; for(i = 0; i < s8; i++) new[i] = 0; } return new; }

 


 

3.2.7 free的實現

  free的實現並不像看上去那麼簡單,這裏咱們要解決兩個關鍵問題:

  1. 如何驗證所傳入的地址是有效地址,即確實是經過malloc方式分配的數據區首地址
  2. 如何解決碎片問題

  首先咱們要保證傳入free的地址是有效的,這個有效包括兩方面:

  • 地址應該在以前malloc所分配的區域內,即在first_block和當前break指針範圍內
  • 這個地址確實是以前經過咱們本身的malloc分配的

  第一個問題比較好解決,只要進行地址比較就能夠了,關鍵是第二個問題。這裏有兩種解決方案:一是在結構體內埋一個magic number字段,free以前經過相對偏移檢查特定位置的值是否爲咱們設置的magic number,另外一種方法是在結構體內增長一個magic pointer,這個指針指向數據區的第一個字節(也就是在合法時free時傳入的地址),咱們在free前檢查magic pointer是否指向參數所指地址。這裏咱們採用第二種方案:

  首先咱們在結構體中增長magic pointer(同時要修改BLOCK_SIZE):

typedef struct s_block *t_block; struct s_block { size_t size; /* 數據區大小 */ t_block next; /* 指向下個塊的指針 */ int free; /* 是不是空閒塊 */ int padding; /* 填充4字節,保證meta塊長度爲8的倍數 */ void *ptr; /* Magic pointer,指向data */ char data[1] /* 這是一個虛擬字段,表示數據塊的第一個字節,長度不該計入meta */ };

  而後咱們定義檢查地址合法性的函數:

t_block get_block(void *p) { char *tmp; tmp = p; return (p = tmp -= BLOCK_SIZE); } int valid_addr(void *p) { if(first_block) { if(p > first_block && p < sbrk(0)) { return p == (get_block(p))->ptr; } } return 0; }

  當屢次malloc和free後,整個內存池可能會產生不少碎片block,這些block很小,常常沒法使用,甚至出現許多碎片連在一塊兒,雖然整體能知足某此malloc要求,可是因爲分割成了多個小block而沒法fit,這就是碎片問題。

  一個簡單的解決方式時當free某個block時,若是發現它相鄰的block也是free的,則將block和相鄰block合併。爲了知足這個實現,須要將s_block改成雙向鏈表。修改後的block結構以下:

typedef struct s_block *t_block; struct s_block { size_t size; /* 數據區大小 */ t_block prev; /* 指向上個塊的指針 */ t_block next; /* 指向下個塊的指針 */ int free; /* 是不是空閒塊 */ int padding; /* 填充4字節,保證meta塊長度爲8的倍數 */ void *ptr; /* Magic pointer,指向data */ char data[1] /* 這是一個虛擬字段,表示數據塊的第一個字節,長度不該計入meta */ };

  合併方法以下:

t_block fusion(t_block b) {
if (b->next && b->next->free) { b->size += BLOCK_SIZE + b->next->size; b->next = b->next->next; if(b->next) b->next->prev = b; } return b; }

  有了上述方法,free的實現思路就比較清晰了:首先檢查參數地址的合法性,若是不合法則不作任何事;不然,將此block 的free標爲1,而且在能夠的狀況下與後面的block進行合併。若是當前是最後一個block,則回退break指針釋放進程內存,若是當前 block是最後一個block,則回退break指針並設置first_block爲NULL。實現以下:

void free(void *p) { t_block b; if(valid_addr(p)) { b = get_block(p); b->free = 1; if(b->prev && b->prev->free) b = fusion(b->prev); if(b->next) fusion(b); else { if(b->prev) b->prev->prev = NULL; else first_block = NULL; brk(b); } } }





 3.2.8 realloc的實現

  爲了實現realloc,咱們首先要實現一個內存複製方法。如同calloc同樣,爲了效率,咱們以8字節爲單位進行復制:

void copy_block(t_block src, t_block dst) { size_t *sdata, *ddata; size_t i; sdata = src->ptr; ddata = dst->ptr; for(i = 0; (i * 8) < src->size && (i * 8) < dst->size; i++) ddata[i] = sdata[i]; }

  而後咱們開始實現realloc。一個簡單(可是低效)的方法是malloc一段內存,而後將數據複製過去。可是咱們能夠作的更高效,具體能夠考慮如下幾個方面:

  • 若是當前block的數據區大於等於realloc所要求的size,則不作任何操做
  • 若是新的size變小了,考慮split
  • 若是當前block的數據區不能知足size,可是其後繼block是free的,而且合併後能夠知足,則考慮作合併

  下面是realloc的實現:

void *realloc(void *p, size_t size) { size_t s; t_block b, new; void *newp; if (!p) /* 根據標準庫文檔,當p傳入NULL時,至關於調用malloc */ return malloc(size); if(valid_addr(p)) { s = align8(size); b = get_block(p); if(b->size >= s) { if(b->size - s >= (BLOCK_SIZE + 8)) split_block(b,s); } else { /* 看是否可進行合併 */ if(b->next && b->next->free && (b->size + BLOCK_SIZE + b->next->size) >= s) { fusion(b); if(b->size - s >= (BLOCK_SIZE + 8)) split_block(b, s); } else { /* 新malloc */ newp = malloc (s); if (!newp) return NULL; new = get_block(newp); copy_block(b, new); free(p); return(newp); } } return (p); } return NULL; }

  3.3 遺留問題和優化

  以上是一個較爲簡陋,可是初步可用的malloc實現。還有不少遺留的可能優化點,例如:

  • 同時兼容32位和64位系統
  • 在分配較大快內存時,考慮使用mmap而非sbrk,這一般更高效
  • 能夠考慮維護多個鏈表而非單個,每一個鏈表中的block大小均爲一個範圍內,例如8字節鏈表、16字節鏈表、24-32字節鏈表等等。此時能夠根據size到對應鏈表中作分配,能夠有效減小碎片,並提升查詢block的速度
  • 能夠考慮鏈表中只存放free的block,而不存放已分配的block,能夠減小查找block的次數,提升效率

  還有不少可能的優化,這裏不一一贅述。下面附上一些參考文獻,有興趣的同窗能夠更深刻研究。

  4 其它參考

  1. 這篇文章大量參考了A malloc Tutorial,其中一些圖片和代碼直接引用了文中的內容,這裏特別指出
  2. Computer Systems: A Programmer's Perspective, 2/E一書有許多值得參考的地方
  3. 關於Linux的虛擬內存模型,Anatomy of a Program in Memory是很好的參考資料,另外做者還有一篇How the Kernel Manages Your Memory對於Linux內核中虛擬內存管理的部分有很好的講解
  4. 對於真實世界的malloc實現,能夠參考glibc的實現
  5. 本文寫做過程當中大量參考了維基百科,再次感謝這個偉大的網站,而且呼籲你們在手頭容許的狀況下能夠適當捐助維基百科,幫助這個造福人類的系統運行下去
相關文章
相關標籤/搜索