對內存分配器透徹理解是編程高手的標誌之一。程序員
若是你不能理解malloc之類內存分配器實現原理的話,那你可能寫不出高性能程序,寫不出高性能程序就很難參與核心項目,參與不了核心項目那麼很難升職加薪,很難升級加薪就沒法走向人生巔峯,沒想到內存分配竟如此關鍵,爲了走上人生巔峯你也要勢必讀完本文。算法
如今咱們知道了,對內存分配器透徹的理解是寫出高性能程序的關鍵所在,那麼咱們該怎樣透徹理解內存分配器呢?編程
還有什麼能比你本身動手實現一個理解的更透徹嗎?數組
接下來,咱們就本身實現一個malloc內存分配器。讀完本文後內存分配對你將再也不是一個神祕的黑盒。
在講解實現原理以前,咱們須要回答一個基本問題,那就是咱們爲何要發明內存分配器這種東西。
程序員常用的內存申請方式被稱爲動態內存分配,Dynamic Memory Allocation。咱們爲何須要動態的去進行內存分配與釋放呢?
答案很簡單,由於咱們不能提早知道程序到底須要使用多少內存。那咱們何時才能知道呢?答案是隻有當程序真的運行起來後咱們才知道。
這就是爲何程序員須要動態的去申請內存的緣由,若是能提早知道咱們的程序到底須要多少內存,那麼直接知道告訴編譯器就行了,這樣也沒必要發明malloc等內存分配器了。
知道了爲何要發明內存分配器的緣由後,接下來咱們着手實現一個。
實際上,現代程序員是很幸福的,程序員不多去關心內存分配的問題。做爲程序員,能夠簡單的認爲咱們的程序獨佔內存,注意,是獨佔哦。
寫程序時你歷來沒有關心過若是咱們的程序佔用過多內存會不會影響到其它程序,咱們能夠簡單的認爲每一個程序(進程)獨佔4G內存(32位操做系統),即便咱們的物理內存512M。不信你能夠去試試,在即便只有512M大小的內存上你依然能夠申請到2G內存來使用,可這是爲何呢?關於這個問題咱們會在《深刻理解操做系統》系列中詳細闡述。
總之,程序員能夠放心的認爲咱們的程序運行起來後在內存中是這樣的:
做爲程序員咱們應該知道,內存動態申請和釋放都發生在堆區,heap。
咱們使用的malloc或者C++中的new申請內存時,就是從堆區這個區域中申請的。
堆區這個區域實際上很是簡單,真的是很是簡單,你能夠將其看作一大數組,就像這樣:
從內存分配器的角度看,內存分配器根本不關心你是整數、浮點數、鏈表、二叉樹等數據結構、仍是對象、結構體等這些花哨的概念,在內存分配器眼裏不過就是一個內存塊,這些內存塊中能夠裝入原生的字節序列,申請者拿到該內存塊後能夠塑形成整數、浮點數、鏈表、二叉樹等數據結構以及對象、結構體等,這是使用者的事情,和內存分配器無關。
這是內存分配器要解決的兩個最核心的問題,接下來咱們先去停車場看看能找到什麼啓示。
實際上你能夠把內存看作一條長長的停車場,咱們申請內存就是要找到一塊停車位,釋放內存就是把車開走讓出停車位。
只不過這個停車場比較特殊,咱們不止能夠停小汽車、也能夠停佔地面積很小的自行車以及佔地面積很大的卡車,重點就是申請的內存是大小不一的,在這樣的條件下你該怎樣實現如下兩個目標呢?
如今,咱們已經清楚的理解任務了,那麼該怎麼實現呢?
如今咱們已經明確要實現什麼以及衡量其好壞的標準,接下來咱們就要去設計實現細節了,讓咱們把任務拆分一下,怎麼拆分呢?
咱們能夠本身想一下從內存的申請到釋放須要哪些細節。
申請內存時,咱們須要在內存中找到一塊大小合適的空閒內存分配出去,那麼咱們怎麼知道有哪些內存塊是空閒的呢?
所以,第一個實現細節出現了,咱們須要把內存塊用某種方式組織起來,這樣咱們才能追蹤到每一塊內存的分配狀態。
如今空閒內存塊組織好了,那麼一次內存申請可能有不少空閒內存塊知足要求,那麼咱們該選擇哪個空閒內存塊分配給用戶呢?
所以,第二個實現細節出現了,咱們該選擇什麼樣的空閒內存塊給到用戶。
接下來咱們找到了一塊大小合適的內存塊,假設用戶須要16個字節,而咱們找到的這塊空閒內存塊大小爲32字節,那麼將16字節分配給用戶後還剩下16字節,這剩下的內存該怎麼處理呢?
所以,第三個實現細節出現了,分配出去內存後,空閒內存塊剩餘的空間該怎麼處理?
最後,分配給用戶的內存使用完畢,這是第四個細節出現了,咱們該怎麼處理用戶還給咱們的內存呢?
以上四個問題是任何一個內存分配器必需要回答的,接下來咱們就一一解決這些問題,解決完這些問題後一個嶄新的內存分配器就誕生啦。
空閒內存塊的本質是須要某種辦法來來區分哪些是空閒內存哪些是已經分配出去的內存。
有的同窗可能會說,這還不簡單嗎,用一個鏈表之類的結構記錄下每一個空閒內存塊的開始和結尾不就能夠了,這句話也對也不對。
說不對,是由於若是要申請內存來建立這個鏈表那麼這就是不對的,緣由很簡單,由於建立鏈表不可避免的要申請內存,申請內存就須要經過內存分配器,但是你要實現的就是一個內存分配器,你沒有辦法向一個尚未實現的內存分配器申請內存。
說對也對,咱們確實須要一個相似鏈表這樣的結構來維護空閒內存塊,但這個鏈表並非咱們常見的那種。
由於咱們沒法將空閒內存塊的信息保存在其它地方,那麼沒有辦法,咱們只能將維護內存塊的分配信息保存在內存塊自己中,這也是大多數內存分配器的實現方法。
那麼,爲了維護內存塊分配狀態,咱們須要知道哪些信息呢?很簡單:
爲了簡單起見,咱們的內存分配器不對內存對齊有要求,同時一次內存申請容許的最大內存塊爲2G,注意,這些假設是爲了方便講解內存分配器的實現而屏蔽一些細節,咱們經常使用的malloc等不會有這樣的限制。
由於咱們的內存塊大小上限爲2G,所以咱們可使用31個比特位來記錄塊大小,剩下的一個比特位用來標識該內存塊是空閒的仍是已經被分配出去了,下圖中的f/a是free/allocate,也就是標記是已經分配出去仍是空閒的。這32個比特位就是header,用來存儲塊信息。
剩下的灰色部分纔是真正能夠分配給用戶的內存,這一部分也被稱爲負載,payload,咱們調用malloc返回的內存起始地址正是這塊內存的起始地址。
如今你應該知道了吧,不是說堆上有10G內存,這裏面就能夠所有用來存儲數據的,這裏面必然有一部分要拿出來維護內存塊的一些信息,就像這裏的header同樣。
有了上圖,咱們就能夠將堆這塊內存區域組織起來並進行內存分配與釋放了,如圖所示:
在這裏咱們的堆區還很小,每一方框表明4字節,其中紅色區域表示已經分配出去的,灰色區域表示空閒內存,每一塊內存都有一個header,用帶斜線的方框表示,好比16/1,就表示該內存塊大小是16字節,1表示已經分配出去了;而32/0表示該內存塊大小是32字節,0表示該內存塊當前空閒。
細心的同窗可能會問,那最後一個方框0/1表示什麼呢?原來,咱們須要某種特殊標記來告訴咱們的內存分配器是否是已經到末尾了,這就是最後4字節的做用。
經過引入header咱們就能知道每個內存塊的大小,從而能夠很方便的遍歷整個堆區。遍歷方法很簡單,由於咱們知道每一塊的大小,那麼從當前的位置加上當前塊的大小就是下一個內存塊的起始位置,如圖所示:
經過每個header的最後一個bit位就能知道每一塊內存是空閒的仍是已經分配出去了,這樣咱們就能追蹤到每個內存塊的分配信息,所以上文提到的第一個問題解決了。
當應用程序調用咱們實現的malloc時,內存分配器須要遍歷整個空閒內存塊找到一塊能知足應用程序要求的內存塊返回,就像下圖這樣:
假設應用程序須要申請4字節內存,從圖中咱們能夠看到有兩個空閒內存塊知足要求,第一個大小爲8字節的內存塊和第三個大小爲32字節的內存塊,那麼咱們到底該選擇哪個返回呢?這就涉及到了分配策略的問題,實際上這裏有不少的策略可供選擇。
最簡單的就是每次從頭開始找起,找到第一個知足要求的就返回,這就是所謂的First fit方法,教科書中通常稱爲首次適應方法,固然咱們不須要記住這樣拗口的名字,只須要記住這是什麼意思就能夠了。
這種方法的優點在於簡單,但該策略老是從前面的空閒塊找起,所以很容易在堆區前半部分因分配出內存留下不少小的內存塊,所以下一次內存申請搜索的空閒塊數量將會愈來愈多。
該方法是大名鼎鼎的Donald Knuth首次提出來的,若是你不知道誰是Donald Knuth,那麼數據結構課上折磨的你痛不欲生的字符串匹配KMP算法你必定不會錯過,KMP其中的K就是指Donald Knuth,該算法全稱Knuth–Morris–Pratt string-searching algorithm,若是你也沒聽過KMP算法那麼你必定聽過下面這本書:
這就是更加大名鼎鼎的《計算機程序設計藝術》,這本書就是Donald Knuth寫的,若是你沒有聽過這本書請面壁思過一分鐘,比爾蓋茨曾經說過,若是你看懂了這本書就去給微軟投簡歷吧,這本書也是不少程序員買回來後歷來不會翻一眼只是拿來當作鎮宅之寶用的。
不止比爾蓋茨,有一次喬布斯見到Knuth老爺子後。。算了,扯遠了,有機會再和你們講這個故事,拉回來。
Next Fit說的是什麼呢?這個策略和First Fit很類似,是說咱們別老是從頭開始找了,而是從上一次找到合適的空閒內存塊的位置找起,老爺子觀察到上一次找到某個合適的內存塊的地方頗有可能剩下的內存塊能知足接下來的內存分配請求,因爲不須要從頭開始搜索,所以Next Fit將遠快於First Fit。
然而也有研究代表Next Fit方法內存使用率不及First Fit,也就是一樣的停車場面積,First Fit方法能停更多的車。
First Fit和Next Fit都是找到第一個知足要求的內存塊就返回,但Best Fit不是這樣。
Best Fit算法會找到全部的空閒內存塊,而後將全部知足要求的而且大小爲最小的那個空閒內存塊返回,這樣的空閒內存塊纔是最Best的,所以被稱爲Best Fit。就像下圖雖然有三個空閒內存塊知足要求,可是Best Fit會選擇大小爲8字節的空閒內存塊。
顯然,從直覺上咱們就能得出Best Fit會比前兩種方法能更合理利用內存的結論,各項研究也證明了這一點。
然而Best Fit最大的缺點就是分配內存時須要遍歷堆上全部的空閒內存塊,在速度上顯然不及前面兩種方法。
以上介紹的這三種策略在各類內存分配器中很是常見,固然分配策略遠不止這幾種,但這些算法不是該主題下關注的重點,所以就不在這裏詳細闡述了,假設在這裏咱們選擇First Fit算法。
重要的是,從上面的介紹中咱們可以看到,沒有一種完美的策略,每一種策略都有其優勢和缺點,咱們能作到的只有取捨和權衡。所以,要實現一個內存分配器,設計空間實際上是很是大的,要想設計出一個通用的內存分配器,就像咱們經常使用的malloc是很不容易的。
其實不止內存分配器,在設計其它軟件系統時咱們也沒有銀彈。
如今咱們找到合適的空閒內存塊了,接下來咱們又將面臨一個新的問題。
若是用戶須要12字節,而咱們的空閒內存塊也剛好是12字節,那麼很好,直接返回就能夠了。
可是,若是用戶申請12字節內存,而咱們找到的空閒內存塊大小爲32字節,那麼咱們是要將這32字節的整個空閒內存塊標記爲已分配嗎?就像這樣:
這樣雖然速度最快,但顯然會浪費內存,造成內部碎片,也就是說該內存塊剩下的空間將沒法被利用到。
一種顯而易見的方法就是將空閒內存塊進行劃分,前一部分設置爲已分配,返回給內存申請者使用,後一部分變爲一個新的空閒內存塊,只不過大小會更小而已,就像這樣:
咱們須要將空閒內存塊大小從32修改成16,其中消息頭header佔據4字節,剩下的12字節分配出去,並將標記爲置爲1,表示該內存塊已分配。
分配出16字節後,還剩下16字節,咱們須要拿出4字節做爲新的header並將其標記爲空閒內存塊。
到目前爲止,咱們的malloc已經可以處理內存分配請求了,還差最後的內存釋放。
內存釋放和咱們想象的不太同樣,該過程並不比前幾個環節簡單。咱們要考慮到的關鍵一點就在於,與被釋放的內存塊相鄰的內存塊可能也是空閒的。若是釋放一塊內存後咱們僅僅簡單的將其標誌位置爲空閒,那麼可能會出現下面的場景:
從圖中咱們能夠看到,被釋放內存的下一個內存塊也是空閒的,若是咱們僅僅將這16個字節的內存塊標記爲空閒的話,那麼當下一次申請20字節時圖中的這兩個內存塊都不能知足要求,儘管這兩個空閒內存塊的總數要超過20字節。
所以一種更好的方法是當應用程序向咱們的malloc釋放內存時,咱們查看一下相鄰的內存塊是不是空閒的,若是是空閒的話咱們須要合併空閒內存塊,就像這樣:
在這裏咱們又面臨一個新的決策,那就是釋放內存時咱們要當即去檢查可否夠合併相鄰空閒內存塊嗎?仍是說咱們能夠推遲一段時間,推遲到下一次分配內存找不到知足要的空閒內存塊時再合併相鄰空閒內存塊。
釋放內存時當即合併空閒內存塊相對簡單,但每次釋放內存時將引入合併內存塊的開銷,若是應用程序老是釋放12字節而後申請12字節,而後在釋放12字節等等這樣重複的模式:
free(ptr);obj* ptr = malloc(12);free(ptr);obj* ptr = malloc(12);...
那麼這種內存使用模式對當即合併空閒內存塊這種策略很是不友好,咱們的內存分配器會有不少的
無用功
。
但這種策略最爲簡單,在這裏咱們依然選擇使用這種簡單的策略。
實際上咱們須要意識到,實際使用的內存分配器都會有某種推遲合併空閒內存塊的策略。
合併空閒內存塊的故事到這裏就完了嗎?問題沒有那麼簡單。
使用的內存塊其前和其後都是空閒的,在當前的設計中咱們能夠很容易的知道後一個內存塊是空閒的,由於咱們只須要從當前位置向下移動16字節就是下一個內存塊,但咱們怎麼能知道上一個內存塊是否是空閒的呢?
咱們之因此能向後跳是由於當前內存塊的大小是知道的,那麼咱們該怎麼向前跳找到上一個內存塊呢?
仍是咱們上文提到的Donald Knuth,老爺子提出了一個很聰明的設計,咱們之因此不能往前跳是由於不知道前一個內存塊的信息,那麼咱們該怎麼快速知道前一個內存塊的信息呢?
Knuth老爺子的設計是這樣的,咱們不是有一個信息頭header嗎,那麼咱們就在該內存塊的末尾再加一個信息尾,footer,footer一詞用的很形象,header和footer的內容是同樣的。
由於上一內存塊的footer和下一個內存塊的header是相鄰的,所以咱們只須要在當前內存塊的位置向上移動4直接就能夠等到上一個內存塊的信息,這樣當咱們釋放內存時就能夠快速的進行相鄰空閒內存塊的合併了。
咱們的簡單內存分配器採用了First Fit分配算法;找到一個知足要求的內存塊後會進行切分,剩下的做爲新的內存塊;同時當釋放內存時會當即合併相鄰的空閒內存塊,同時爲加快合併速度,咱們引入了Donald Knuth的設計方法,爲每一個內存塊增長footer信息。
這樣,咱們本身實現的內存分配就能夠運行起來了,能夠真正的申請和釋放內存。
本文從0到1實現了一個簡單的內存分配器,但不但願這裏的闡述給你們留下內存分配器實現很簡單的印象,實際上本文實現的內存分配器還有大量的優化空間,同時咱們也沒有考慮線程安全問題,但這些都不是本文的目的。
本文的目的在於把內存分配器的本質告訴你們,對於想理解內存分配器實現原理的同窗來講這些已經足夠了,而對於要編寫高性能程序的同窗來講實現本身的內存池是必不可少的,內存池實現也離不開這裏的討論。
最後的最後,若是以爲文章對你有幫助的話,請多多分享、轉發、在看。