詳解Go語言的內存模型及堆的分配管理

前言

這篇文章主要介紹Go內存分配和Go內存管理,會輕微涉及內存申請和釋放,以及Go垃圾回收。從很是宏觀的角度看,Go的內存管理就是下圖這個樣子,咱們今天主要關注其中標紅的部分。數據庫

Go這門語言拋棄了C/C++中的開發者管理內存的方式,實現了主動申請與主動釋放管理,增長了逃逸分析和GC,將開發者從內存管理中釋放出來,讓開發者有更多的精力去關注軟件設計,而不是底層的內存問題。這是Go語言成爲高生產力語言的緣由之一。編程

咱們不須要精通內存的管理,由於它確實很複雜,但掌握內存的管理,可讓你寫出更高質量的代碼,另外,還能助你定位Bug。這篇文章採用層層遞進的方式,依次會介紹關於存儲的基本知識,Go內存管理的 「前輩」 TCMalloc,而後是Go的內存管理和分配,最後是總結。這麼作的目的是,但願各位能經過全局的認識和思考,擁有更好的編碼思惟和架構思惟。後端

正文

1. 存儲基礎知識回顧

這部分咱們簡單回顧一下計算機存儲體系、虛擬內存、棧和堆,以及堆內存的管理,這部份內容對理解和掌握Go內存管理比較重要。緩存

1.1. 存儲金字塔

這幅圖表達了計算機的存儲體系,從上至下的訪問速度愈來愈慢,訪問時間愈來愈長。從上至下依次是:性能優化

  • CPU寄存器
  • CPU Cache
  • 內存
  • 硬盤等輔助存儲設備
  • 鼠標等外接設備

你有沒有思考過下面2個簡單的問題,若是沒有不妨想一想:bash

  1. 若是CPU直接訪問硬盤,CPU能充分利用嗎?
  2. 若是CPU直接訪問內存,CPU能充分利用嗎?

CPU速度很快,但硬盤等持久存儲很慢,若是CPU直接訪問磁盤,磁盤能夠拉低CPU的速度,機器總體性能就會低下,爲了彌補這2個硬件之間的速率差別,因此在CPU和磁盤之間增長了比磁盤快不少的內存。數據結構

然而,CPU跟內存的速率也不是相同的,從上圖能夠看到,CPU的速率提升的很快(摩爾定律),然而內存速率增加的很慢,雖然CPU的速率如今增長的很慢了,可是內存的速率也沒增長多少,速率差距很大,從1980年開始CPU和內存速率差距在不斷拉大,爲了彌補這2個硬件之間的速率差別,因此在CPU跟內存之間增長了比內存更快的Cache,Cache是內存數據的緩存,能夠下降CPU訪問內存的時間。多線程

三級Cache分別是L一、L二、L3,它們的速率是三個不一樣的層級,L1速率最快,與CPU速率最接近,是RAM速率的100倍,L2速率就降到了RAM的25倍,L3的速率更靠近RAM的速率。架構

看到這了,你有沒有Get到整個存儲體系的分層設計?自頂向下,速率愈來愈低,訪問時間愈來愈長,從磁盤到CPU寄存器,上一層均可以看作是下一層的緩存。看了分層設計,下面開始正式介紹內存。併發

1.2. 虛擬內存

虛擬內存是當代操做系統必備的一項重要功能,對於進程而言虛擬內存屏蔽了底層了RAM和磁盤,並向進程提供了遠超物理內存大小的內存空間。咱們看一下虛擬內存的分層設計。

上圖展現了某進程訪問數據,當Cache沒有命中的時候,訪問虛擬內存獲取數據的過程。在訪問內存,實際訪問的是虛擬內存,虛擬內存經過頁表查看,當前要訪問的虛擬內存地址,是否已經加載到了物理內存。若是已經在物理內存,則取物理內存數據,若是沒有對應的物理內存,則從磁盤加載數據到物理內存,並把物理內存地址和虛擬內存地址更新到頁表。

物理內存就是磁盤存儲緩存層,在沒有虛擬內存的時代,物理內存對全部進程是共享的,多進程同時訪問同一個物理內存會存在併發問題。而引入虛擬內存後,每一個進程都有各自的虛擬內存,內存的併發訪問問題的粒度從多進程級別,能夠下降到多線程級別。

1.3. 棧和堆

咱們如今從虛擬內存,再進一層,看虛擬內存中的棧和堆,也就是進程對內存的管理。

上圖展現了一個進程的虛擬內存劃分,代碼中使用的內存地址都是虛擬內存地址,而不是實際的物理內存地址。棧和堆只是虛擬內存上2塊不一樣功能的內存區域:

  • 棧在高地址,從高地址向低地址增加

  • 堆在低地址,從低地址向高地址增加

棧和堆相比有這麼幾個好處:

  • 棧的內存管理簡單,分配比堆上快。
  • 棧的內存不須要回收,而堆須要進行回收,不管是主動free,仍是被動的垃圾回收,這都須要花費額外的CPU。
  • 棧上的內存有更好的局部性,堆上內存訪問就不那麼友好了,CPU訪問的2塊數據可能在不一樣的頁上,CPU訪問數據的時間可能就上去了。

1.4. 堆內存管理

咱們再進一層,當咱們說內存管理的時候,主要是指堆內存的管理,由於棧的內存管理不須要程序去操心,這小節看下堆內存管理到底完成了什麼。如上圖所示主要是3部分,分別是分配內存塊,回收內存塊和組織內存塊。

在一個最簡單的內存管理中,堆內存最初會是一個完整的大塊,即未分配任何內存。當發現內存申請的時候,堆內存就會從未分配內存分割出一個小內存塊(block),而後用鏈表把全部內存塊鏈接起來。須要一些信息描述每一個內存塊的基本信息,好比大小(size)、是否使用中(used)和下一個內存塊的地址(next),內存塊實際數據存儲在data中。

一個內存塊包含了3類信息,以下圖所示,元數據、用戶數據和對齊字段,內存對齊是爲了提升訪問效率。下圖申請5Byte內存的時候,就須要進行內存對齊。

釋放內存實質是把使用的內存塊從鏈表中取出來,而後標記爲未使用,當分配內存塊的時候,能夠從未使用內存塊中優先查找大小相近的內存塊,若是找不到,再從未分配的內存中分配內存。

上面這個簡單的設計中還沒考慮內存碎片的問題,由於隨着內存不斷的申請和釋放,內存上會存在大量的碎片,下降內存的使用率。爲了解決內存碎片,能夠將2個連續的未使用的內存塊合併,減小碎片。

以上就是內存管理的基本思路,關於基本的內存管理,想了解更多,能夠閱讀這篇文章《Writing a Memory Allocator》,本節的3張圖片也是來自這篇文章。

2. TCMalloc

TCMalloc是Thread Cache Malloc的簡稱,是Go內存管理的起源,Go的內存管理是借鑑了TCMalloc,隨着Go的迭代,Go的內存管理與TCMalloc不一致地方在不斷擴大,但其主要思想、原理和概念都是和TCMalloc一致的,若是跳過TCMalloc直接去看Go的內存管理,也許你會似懂非懂。

掌握TCMalloc的理念,無需去關注過多的源碼細節,就能夠爲掌握Go的內存管理打好基礎,基礎打好了,後面知識才紮實。

在Linux操做系統中,其實有很多的內存管理庫,好比glibc的ptmalloc,FreeBSD的jemalloc,Google的tcmalloc等等,爲什麼會出現這麼多的內存管理庫?本質都是在多線程編程下,追求更高內存管理效率:更快的分配是主要目的。

咱們前面提到引入虛擬內存後,讓內存的併發訪問問題的粒度從多進程級別,下降到多線程級別。然而同一進程下的全部線程共享相同的內存空間,它們申請內存時須要加鎖,若是不加鎖就存在同一塊內存被2個線程同時訪問的問題。

TCMalloc的作法是什麼呢?爲每一個線程預分配一塊緩存,線程申請小內存時,能夠從緩存分配內存,這樣有2個好處:

  1. 爲線程預分配緩存須要進行1次系統調用,後續線程申請小內存時直接從緩存分配,都是在用戶態執行的,沒有了系統調用,縮短了內存整體的分配和釋放時間,這是快速分配內存的第二個層次。

  2. 多個線程同時申請小內存時,從各自的緩存分配,訪問的是不一樣的地址空間,從而無需加鎖,把內存併發訪問的粒度進一步下降了,這是快速分配內存的第三個層次。

2.1. 基本原理

下面就簡單介紹下TCMalloc,細緻程度夠咱們理解Go的內存管理便可。

結合上圖,介紹TCMalloc的幾個重要概念:

  • Page

操做系統對內存管理以頁爲單位,TCMalloc也是這樣,只不過TCMalloc裏的Page大小與操做系統裏的大小並不必定相等,而是倍數關係。《TCMalloc解密》裏稱x64下Page大小是8KB。

  • Span

一組連續的Page被稱爲Span,好比能夠有2個頁大小的Span,也能夠有16頁大小的Span,Span比Page高一個層級,是爲了方便管理必定大小的內存區域,Span是TCMalloc中內存管理的基本單位。

  • ThreadCache

ThreadCache是每一個線程各自的Cache,一個Cache包含多個空閒內存塊鏈表,每一個鏈表鏈接的都是內存塊,同一個鏈表上內存塊的大小是相同的,也能夠說按內存塊大小,給內存塊分了個類,這樣能夠根據申請的內存大小,快速從合適的鏈表選擇空閒內存塊。因爲每一個線程有本身的ThreadCache,因此ThreadCache訪問是無鎖的。

  • CentralCache

CentralCache是全部線程共享的緩存,也是保存的空閒內存塊鏈表,鏈表的數量與ThreadCache中鏈表數量相同,當ThreadCache的內存塊不足時,能夠從CentralCache獲取內存塊;當ThreadCache內存塊過多時,能夠放回CentralCache。因爲CentralCache是共享的,因此它的訪問是要加鎖的。

  • PageHeap

PageHeap是對堆內存的抽象,PageHeap存的也是若干鏈表,鏈表保存的是Span。當CentralCache的內存不足時,會從PageHeap獲取空閒的內存Span,而後把1個Span拆成若干內存塊,添加到對應大小的鏈表中並分配內存;當CentralCache的內存過多時,會把空閒的內存塊放回PageHeap中。

以下圖所示,分別是1頁Page的Span鏈表,2頁Page的Span鏈表等,最後是large span set,這個是用來保存中大對象的。毫無疑問,PageHeap也是要加鎖的。

前文提到了小、中、大對象,Go內存管理中也有相似的概念,咱們看一眼TCMalloc的定義:

  • 小對象大小:0~256KB
  • 中對象大小:257~1MB
  • 大對象大小:>1MB

小對象的分配流程:ThreadCache -> CentralCache -> HeapPage,大部分時候,ThreadCache緩存都是足夠的,不須要去訪問CentralCache和HeapPage,無系統調用配合無鎖分配,分配效率是很是高的。

中對象分配流程:直接在PageHeap中選擇適當的大小便可,128 Page的Span所保存的最大內存就是1MB。

大對象分配流程:從large span set選擇合適數量的頁面組成span,用來存儲數據。

經過本節的介紹,你應當對TCMalloc主要思想有必定了解了,我建議再回顧一下上面的內容。

3. Go內存管理

前文提到Go內存管理源自TCMalloc,但它比TCMalloc還多了2件東西:逃逸分析和垃圾回收,這是2項提升生產力的絕佳武器。這一大章節,咱們先介紹Go內存管理和Go內存分配,最後涉及一點垃圾回收和內存釋放。

3.1. Go內存管理的基本概念

Go內存管理的許多概念在TCMalloc中已經有了,含義是相同的,只是名字有一些變化。先給你們上一幅宏觀的圖,藉助圖一塊兒來介紹。

  • Page

與TCMalloc中的Page相同,x64架構下1個Page的大小是8KB。上圖的最下方,1個淺藍色的長方形表明1個Page。

  • Span

Span與TCMalloc中的Span相同,Span是內存管理的基本單位,代碼中爲mspan,一組連續的Page組成1個Span,因此上圖一組連續的淺藍色長方形表明的是一組Page組成的1個Span,另外,1個淡紫色長方形爲1個Span。

  • mcache

mcache與TCMalloc中的ThreadCache相似,mcache保存的是各類大小的Span,並按Span class分類,小對象直接從mcache分配內存,它起到了緩存的做用,而且能夠無鎖訪問。可是mcache與ThreadCache也有不一樣點,TCMalloc中是每一個線程1個ThreadCache,Go中是每一個P擁有1個mcache。由於在Go程序中,當前最多有GOMAXPROCS個線程在運行,因此最多須要GOMAXPROCS個mcache就能夠保證各線程對mcache的無鎖訪問,線程的運行又是與P綁定的,把mcache交給P剛恰好。

  • mcentral

mcentral與TCMalloc中的CentralCache相似,是全部線程共享的緩存,須要加鎖訪問。它按Span級別對Span分類,而後串聯成鏈表,當mcache的某個級別Span的內存被分配光時,它會向mcentral申請1個當前級別的Span。

可是mcentral與CentralCache也有不一樣點,CentralCache是每一個級別的Span有1個鏈表,mcache是每一個級別的Span有2個鏈表,這和mcache申請內存有關,稍後咱們再解釋。

  • mheap

mheap與TCMalloc中的PageHeap相似,它是堆內存的抽象,把從OS申請出的內存頁組織成Span,並保存起來。當mcentral的Span不夠用時會向mheap申請內存,而mheap的Span不夠用時會向OS申請內存。mheap向OS的內存申請是按頁來的,而後把申請來的內存頁生成Span組織起來,一樣也是須要加鎖訪問的。

可是mheap與PageHeap也有不一樣點:mheap把Span組織成了樹結構,而不是鏈表,而且仍是2棵樹,而後把Span分配到heapArena進行管理,它包含地址映射和span是否包含指針等位圖,這樣作的主要緣由是爲了更高效的利用內存:分配、回收和再利用。

  1. object size:代碼裏簡稱size,指申請內存的對象大小。
  2. size class:代碼裏簡稱class,它是size的級別,至關於把size歸類到必定大小的區間段,好比size[1,8]屬於size class 1,size(8,16]屬於size class 2。
  3. span class:指span的級別,但span class的大小與span的大小並無正比關係。span class主要用來和size class作對應,1個size class對應2個span class,2個span class的span大小相同,只是功能不一樣,1個用來存放包含指針的對象,一個用來存放不包含指針的對象,不包含指針對象的Span就無需GC掃描了。
  4. num of page:代碼裏簡稱npage,表明Page的數量,其實就是Span包含的頁數,用來分配內存。

3.2. Go內存分配

Go中的內存分類並不像TCMalloc那樣分紅小、中、大對象,可是它的小對象裏又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間而且不包含指針的對象。小對象和大對象只用大小劃定,無其餘區分。

小對象是在mcache中分配的,而大對象是直接從mheap分配的,從小對象的內存分配看起。

3.1.1. 小對象的內存分配

大小轉換這一小節,咱們介紹了轉換表,size class從1到66共66個,代碼中_NumSizeClasses=67表明了實際使用的size class數量,即67個,從0到67,size class 0實際並未使用到。

上文提到1個size class對應2個span class:

numSpanClasses = _NumSizeClasses * 2
複製代碼

numSpanClasses爲span class的數量爲134個,因此span class的下標是從0到133,因此上圖中mcache標註了的span class是,span class 0到span class 133。每1個span class都指向1個span,也就是mcache最多有134個span。

  • 爲對象尋找span

尋找span的流程以下:

  1. 計算對象所需內存大小size
  2. 根據size到size class映射,計算出所需的size class
  3. 根據size class和對象是否包含指針計算出span class
  4. 獲取該span class指向的span

以分配一個不包含指針的,大小爲24Byte的對象爲例,根據映射表:

// class  bytes/obj  bytes/span  objects  tail waste  max waste
//     1          8        8192     1024           0     87.50%
//     2         16        8192      512           0     43.75%
//     3         32        8192      256           0     46.88%
//     4         48        8192      170          32     31.52%
複製代碼

對應的size class爲3,它的對象大小範圍是(16,32]Byte,24Byte恰好在此區間,因此此對象的size class爲3。

Size class到span class的計算以下:

// noscan爲true表明對象不包含指針
func makeSpanClass(sizeclass uint8, noscan bool) spanClass {
	return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))
}
複製代碼

因此對應的span class爲7,因此該對象須要的是span class 7指向的span。

span class = 3 << 1 | 1 = 7
複製代碼
  • 從span分配對象空間

Span能夠按對象大小切成不少份,這些均可以從映射表上計算出來,以size class 3對應的span爲例,span大小是8KB,每一個對象實際所佔空間爲32Byte,這個span就被分紅了256塊,能夠根據span的起始地址計算出每一個對象塊的內存地址。

隨着內存的分配,span中的對象內存塊,有些被佔用,有些未被佔用,好比上圖,總體表明1個span,藍色塊表明已被佔用內存,綠色塊表明未被佔用內存。當分配內存時,只要快速找到第一個可用的綠色塊,並計算出內存地址便可,若是須要還能夠對內存塊數據清零。

當span內的全部內存塊都被佔用時,沒有剩餘空間繼續分配對象,mcache會向mcentral申請1個span,mcache拿到span後繼續分配對象。

  • mcache向mcentral申請span

mcentral和mcache同樣,都是0~133這134個span class級別,但每一個級別都保存了2個span list,即2個span鏈表:

  1. nonempty:這個鏈表裏的span,全部span都至少有1個空閒的對象空間。這些span是mcache釋放span時加入到該鏈表的。
  2. empty:這個鏈表裏的span,全部的span都不肯定裏面是否有空閒的對象空間。當一個span交給mcache的時候,就會加入到empty鏈表。

這兩個東西名稱一直有點繞,建議直接把empty理解爲沒有對象空間就行了。

mcache向mcentral申請span時,mcentral會先從nonempty搜索知足條件的span,若是沒有找到再從emtpy搜索知足條件的span,而後把找到的span交給mcache。

  • mheap的span管理

mheap裏保存了兩棵二叉排序樹,按span的page數量進行排序:

  1. free:free中保存的span是空閒而且非垃圾回收的span。
  2. scav:scav中保存的是空閒而且已經垃圾回收的span。

若是是垃圾回收致使的span釋放,span會被加入到scav,不然加入到free,好比剛從OS申請的的內存也組成的Span。

mheap中還有arenas,由一組heapArena組成,每個heapArena都包含了連續的pagesPerArena個span,這個主要是爲mheap管理span和垃圾回收服務。mheap自己是一個全局變量,它裏面的數據,也都是從OS直接申請來的內存,並不在mheap所管理的那部份內存之內。

  • mcentral向mheap申請span

當mcentral向mcache提供span時,若是empty裏也沒有符合條件的span,mcentral會向mheap申請span。

此時,mcentral須要向mheap提供須要的內存頁數和span class級別,而後它優先從free中搜索可用的span。若是沒有找到,會從scav中搜索可用的span。若是尚未找到,它會向OS申請內存,再從新搜索2棵樹,必然能找到span。若是找到的span比須要的span大,則把span進行分割成2個span,其中1個恰好是需求大小,把剩下的span再加入到free中去,而後設置須要的span的基本信息,而後交給mcentral。

  • mheap向OS申請內存

當mheap沒有足夠的內存時,mheap會向OS申請內存,把申請的內存頁保存爲span,而後把span插入到free樹。在32位系統中,mheap還會預留一部分空間,當mheap沒有空間時,先從預留空間申請,若是預留空間內存也沒有了,才向OS申請。

3.1.2. 大對象的內存分配

大對象的分配比小對象省事多了,99%的流程與mcentral向mheap申請內存的相同,因此不重複介紹了。不一樣的一點在於mheap會記錄一點大對象的統計信息,詳情見mheap.alloc_m()。

3.2. Go垃圾回收和內存釋放

若是隻申請和分配內存,內存終將枯竭。Go使用垃圾回收收集再也不使用的span,調用mspan.scavenge()把span釋放還給OS(並不是真釋放,只是告訴OS這片內存的信息無用了,若是你須要的話,收回去好了),而後交給mheap,mheap對span進行span的合併,把合併後的span加入scav樹中,等待再分配內存時,由mheap進行內存再分配,Go垃圾回收也是一個很強的主題,計劃後面單獨寫一篇文章介紹。

如今咱們關注一下,Go程序是怎麼把內存釋放給操做系統的?釋放內存的函數是sysUnused,它會被mspan.scavenge()調用:

func sysUnused(v unsafe.Pointer, n uintptr) {
    // MADV_FREE_REUSABLE is like MADV_FREE except it also propagates
    // accounting information about the process to task_info.
    madvise(v, n, _MADV_FREE_REUSABLE)
}
複製代碼

註釋說 _MADV_FREE_REUSABLE 與 MADV_FREE 的功能相似,它的功能是給內核提供一個建議:這個內存地址區間的內存已經再也不使用,能夠進行回收。但內核是否回收,以及何時回收,這就是內核的事情了。若是內核真把這片內存回收了,當Go程序再使用這個地址時,內核會從新進行虛擬地址到物理地址的映射。因此在內存充足的狀況下,內核也沒有必要馬上回收內存。

4. Go的棧內存

最後提一下棧內存。從一個宏觀的角度看,內存管理不該當只有堆,也應當有棧。每一個goroutine都有本身的棧,棧的初始大小是2KB,100萬的goroutine會佔用2G,但goroutine的棧會在2KB不夠用時自動擴容,當擴容爲4KB的時候,百萬goroutine會佔用4GB。

總結

Go的內存分配原理就再也不回顧了,它主要強調兩個重要的思想:

  1. 使用緩存提升效率。在存儲的整個體系中處處可見緩存的思想,Go內存分配和管理也使用了緩存,利用緩存一是減小了系統調用的次數,二是下降了鎖的粒度、減小加鎖的次數,從這2點提升了內存管理效率。

  2. 以空間換時間,提升內存管理效率。空間換時間是一種經常使用的性能優化思想,這種思想其實很是廣泛,好比Hash、Map、二叉排序樹等數據結構的本質就是空間換時間,在數據庫中也很常見,好比數據庫索引、索引視圖和數據緩存等,再如Redis等緩存數據庫也是空間換時間的思想。

本賬號將持續分享後端技術乾貨,包括虛擬機基礎,多線程編程,高性能框架,異步、緩存和消息中間件,分佈式和微服務,架構學習和進階等學習資料和文章。

相關文章
相關標籤/搜索