Go語言內建執行時(就是runtime),拋棄了傳統的記憶體分配方式,改為自主管理。這樣可以自主地實現更好的記憶體使用樣式,比如記憶體池、預分配等等。這樣,不會每次記憶體分配都需要進行系統呼叫。
Golang執行時的記憶體分配演演算法主要源自 Google 為 C 語言開發的 TCMalloc演演算法
,全稱 Thread-CachingMalloc
。核心思想就是把記憶體分為多級管理,從而降低鎖的粒度。它將可用的堆記憶體採用二級分配的方式進行管理:每個執行緒都會自行維護一個獨立的記憶體池,進行記憶體分配時優先從該記憶體池中分配,當記憶體池不足時才會向全域性記憶體池申請,以避免不同執行緒對全域性記憶體池的頻繁競爭。
為了更好的閱讀體驗,手動貼上文章目錄:
基礎概念
Go在程式啟動的時候,會先向作業系統申請一塊記憶體(註意這時還只是一段虛擬的地址空間,並不會真正地分配記憶體),切成小塊後自己進行管理。
申請到的記憶體塊被分配了三個區域,在X64上分別是512MB,16GB,512GB大小。
arena區域
就是我們所謂的堆區,Go動態分配的記憶體都是在這個區域,它把記憶體分割成 8KB
大小的頁,一些頁組合起來稱為 mspan
。
bitmap區域
標識 arena
區域哪些地址儲存了物件,並用 4bit
標誌位表示物件是否包含指標、 GC
標記資訊。 bitmap
中一個 byte
大小的記憶體對應 arena
區域中4個指標大小(指標大小為 8B )的記憶體,所以 bitmap
區域的大小是 512GB/(4*8B)=16GB
。如下圖:
從上圖其實還可以看到bitmap的高地址部分指向arena區域的低地址部分,也就是說bitmap的地址是由高地址向低地址增長的。
spans區域
存放 mspan
(也就是一些 arena
分割的頁組合起來的記憶體管理基本單元,後文會再講)的指標,每個指標對應一頁,所以 spans
區域的大小就是 512GB/8KB*8B=512MB
。除以8KB是計算 arena
區域的頁數,而最後乘以8是計算 spans
區域所有指標的大小。建立 mspan
的時候,按頁填充對應的 spans
區域,在回收 object
時,根據地址很容易就能找到它所屬的 mspan
。
記憶體管理單元
mspan
:Go中記憶體管理的基本單元,是由一片連續的 8KB
的頁組成的大塊記憶體。註意,這裡的頁和作業系統本身的頁並不是一回事,它一般是作業系統頁大小的幾倍。一句話概括: mspan
是一個包含起始地址、 mspan
規格、頁的數量等內容的雙端連結串列。
每個 mspan
按照它自身的屬性 SizeClass
的大小分割成若干個 object
,每個 object
可儲存一個物件。並且會使用一個點陣圖來標記其尚未使用的 object
。屬性 SizeClass
決定 object
大小,而 mspan
只會分配給和 object
尺寸大小接近的物件,當然,物件的大小要小於 object
大小。還有一個概念: SpanClass
,它和 SizeClass
的含義差不多,
-
Size_Class = Span_Class / 2
這是因為其實每個 SizeClass
有兩個 mspan
,也就是有兩個 SpanClass
。其中一個分配給含有指標的物件,另一個分配給不含有指標的物件。這會給垃圾回收機制帶來利好,之後的文章再談。
如下圖, mspan
由一組連續的頁組成,按照一定大小劃分成 object
。
Go1.9.2裡 mspan
的 SizeClass
共有67種,每種 mspan
分割的object大小是8*2n的倍數,這個是寫死在程式碼裡的:
-
// path: /usr/local/go/src/runtime/sizeclasses.go
-
const _NumSizeClasses = 67
-
var class_to_size = [_NumSizeClasses]uint16{0, 8, 16, 32, 48, 64, 80, 96, 112, 128, 144, 160, 176, 192, 208, 224, 240, 256, 288, 320, 352, 384, 416, 448, 480, 512, 576, 640, 704, 768, 896, 1024, 1152, 1280, 1408, 1536,1792, 2048, 2304, 2688, 3072, 3200, 3456, 4096, 4864, 5376, 6144, 6528, 6784, 6912, 8192, 9472, 9728, 10240, 10880, 12288, 13568, 14336, 16384, 18432, 19072, 20480, 21760, 24576, 27264, 28672, 32768}
根據 mspan
的 SizeClass
可以得到它劃分的 object
大小。 比如 SizeClass
等於3, object
大小就是32B。 32B大小的object可以儲存物件大小範圍在17B~32B的物件。而對於微小物件(小於16B),分配器會將其進行合併,將幾個物件分配到同一個 object
中。
陣列裡最大的數是32768,也就是32KB,超過此大小就是大物件了,它會被特別對待,這個稍後會再介紹。順便提一句,型別 SizeClass
為0表示大物件,它實際上直接由堆記憶體分配,而小物件都要透過 mspan
來分配。
對於mspan來說,它的 SizeClass
會決定它所能分到的頁數,這也是寫死在程式碼裡的:
-
// path: /usr/local/go/src/runtime/sizeclasses.go
-
const _NumSizeClasses = 67
-
var class_to_allocnpages = [_NumSizeClasses]uint8{0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 2, 1, 2, 1, 3, 2, 3, 1, 3, 2, 3, 4, 5, 6, 1, 7, 6, 5, 4, 3, 5, 7, 2, 9, 7, 5, 8, 3, 10, 7, 4}
比如當我們要申請一個 object
大小為 32B
的 mspan
的時候,在classtosize裡對應的索引是3,而索引3在 class_to_allocnpages
陣列裡對應的頁數就是1。
mspan
結構體定義:
-
// path: /usr/local/go/src/runtime/mheap.go
-
type mspan struct {
-
//連結串列前向指標,用於將span連結起來
-
next *mspan
-
//連結串列前向指標,用於將span連結起來
-
prev *mspan
-
// 起始地址,也即所管理頁的地址
-
startAddr uintptr
-
// 管理的頁數
-
npages uintptr
-
// 塊個數,表示有多少個塊可供分配
-
nelems uintptr
-
//分配點陣圖,每一位代表一個塊是否已分配
-
allocBits *gcBits
-
// 已分配塊的個數
-
allocCount uint16
-
// class表中的class ID,和Size Classs相關
-
spanclass spanClass
-
// class表中的物件大小,也即塊大小
-
elemsize uintptr
-
}
我們將 mspan
放到更大的視角來看:
上圖可以看到有兩個 S
指向了同一個 mspan
,因為這兩個 S
指向的 P
是同屬一個 mspan
的。所以,透過 arena
上的地址可以快速找到指向它的 S
,透過 S
就能找到 mspan
,回憶一下前面我們說的 mspan
區域的每個指標對應一頁。
假設最左邊第一個 mspan
的 SizeClass
等於10,根據前面的 class_to_size
陣列,得出這個 msapn
分割的 object
大小是144B,算出可分配的物件個數是 8KB/144B=56.89
個,取整56個,所以會有一些記憶體浪費掉了,Go的原始碼裡有所有 SizeClass
的 mspan
浪費的記憶體的大小;再根據 class_to_allocnpages
陣列,得到這個 mspan
只由1個 page
組成;假設這個 mspan
是分配給無指標物件的,那麼 spanClass
等於20。
startAddr
直接指向 arena
區域的某個位置,表示這個 mspan
的起始地址, allocBits
指向一個點陣圖,每位代表一個塊是否被分配了物件; allocCount
則表示總共已分配的物件個數。
這樣,左起第一個 mspan
的各個欄位引數就如下圖所示:
記憶體管理元件
記憶體分配由記憶體分配器完成。分配器由3種元件構成: mcache
, mcentral
, mheap
。
mcache
mcache
:每個工作執行緒都會系結一個mcache,本地快取可用的 mspan
資源,這樣就可以直接給Goroutine分配,因為不存在多個Goroutine競爭的情況,所以不會消耗鎖資源。
mcache
的結構體定義:
-
//path: /usr/local/go/src/runtime/mcache.go
-
type mcache struct {
-
alloc [numSpanClasses]*mspan
-
}
-
numSpanClasses = _NumSizeClasses << 1
mcache
用 SpanClasses
作為索引管理多個用於分配的 mspan
,它包含所有規格的 mspan
。它是 _NumSizeClasses
的2倍,也就是 67*2=134
,為什麼有一個兩倍的關係,前面我們提到過:為了加速之後記憶體回收的速度,陣列裡一半的 mspan
中分配的物件不包含指標,另一半則包含指標。
對於無指標物件的 mspan
在進行垃圾回收的時候無需進一步掃描它是否取用了其他活躍的物件。 後面的垃圾回收文章會再講到,這次先到這裡。
mcache
在初始化的時候是沒有任何 mspan
資源的,在使用過程中會動態地從 mcentral
申請,之後會快取下來。當物件小於等於32KB大小時,使用 mcache
的相應規格的 mspan
進行分配。
mcentral
mcentral
:為所有 mcache
提供切分好的 mspan
資源。每個 central
儲存一種特定大小的全域性 mspan
串列,包括已分配出去的和未分配出去的。 每個 mcentral
對應一種 mspan
,而 mspan
的種類導致它分割的 object
大小不同。當工作執行緒的 mcache
中沒有合適(也就是特定大小的)的 mspan
時就會從 mcentral
獲取。
mcentral
被所有的工作執行緒共同享有,存在多個Goroutine競爭的情況,因此會消耗鎖資源。結構體定義:
-
//path: /usr/local/go/src/runtime/mcentral.go
-
type mcentral struct {
-
// 互斥鎖
-
lock mutex
-
// 規格
-
sizeclass int32
-
// 尚有空閑object的mspan連結串列
-
nonempty mSpanList
-
// 沒有空閑object的mspan連結串列,或者是已被mcache取走的msapn連結串列
-
empty mSpanList
-
// 已累計分配的物件個數
-
nmalloc uint64
-
}
用圖來表示:
empty
表示這條連結串列裡的 mspan
都被分配了 object
,或者是已經被 cache
取走了的 mspan
,這個 mspan
就被那個工作執行緒獨佔了。而 nonempty
則表示有空閑物件的 mspan
串列。每個 central
結構體都在 mheap
中維護。
簡單說下 mcache
從 mcentral
獲取和歸還 mspan
的流程:
-
獲取 加鎖;從
nonempty
連結串列找到一個可用的mspan
;並將其從nonempty
連結串列刪除;將取出的mspan
加入到empty
連結串列;將mspan
傳回給工作執行緒;解鎖。 -
歸還 加鎖;將
mspan
從empty
連結串列刪除;將mspan
加入到nonempty
連結串列;解鎖。
mheap
mheap
:代表Go程式持有的所有堆空間,Go程式使用一個 mheap
的全域性物件 _mheap
來管理堆記憶體。
當 mcentral
沒有空閑的 mspan
時,會向 mheap
申請。而 mheap
沒有資源時,會向作業系統申請新記憶體。 mheap
主要用於大物件的記憶體分配,以及管理未切割的 mspan
,用於給 mcentral
切割成小物件。
同時我們也看到, mheap
中含有所有規格的 mcentral
,所以,當一個 mcache
從 mcentral
申請 mspan
時,只需要在獨立的 mcentral
中使用鎖,並不會影響申請其他規格的 mspan
。
mheap
結構體定義:
-
//path: /usr/local/go/src/runtime/mheap.go
-
type mheap struct {
-
lock mutex
-
// spans: 指向mspans區域,用於對映mspan和page的關係
-
spans []*mspan
-
// 指向bitmap首地址,bitmap是從高地址向低地址增長的
-
bitmap uintptr
-
// 指示arena區首地址
-
arena_start uintptr
-
// 指示arena區已使用地址位置
-
arena_used uintptr
-
// 指示arena區末地址
-
arena_end uintptr
-
central [67*2]struct {
-
mcentral mcentral
-
pad [sys.CacheLineSize - unsafe.Sizeof(mcentral{})%sys.CacheLineSize]byte
-
}
-
}
用圖來表示:
上圖我們看到,bitmap和arena_start指向了同一個地址,這是因為bitmap的地址是從高到低增長的,所以他們指向的記憶體位置相同。
記憶體分配流程
上一篇文章《Golang之變數去哪兒》中我們提到了,變數是在棧上分配還是在堆上分配,是由逃逸分析的結果決定的。通常情況下,編譯器是傾向於將變數分配到棧上的,因為它的開銷小,最極端的就是”zero garbage”,所有的變數都會在棧上分配,這樣就不會存在記憶體碎片,垃圾回收之類的東西。
Go的記憶體分配器在分配物件時,根據物件的大小,分成三類:小物件(小於等於16B)、一般物件(大於16B,小於等於32KB)、大物件(大於32KB)。
大體上的分配流程:
-
>32KB 的物件,直接從mheap上分配;
-
<=16B 的物件使用mcache的tiny分配器分配;
-
(16B,32KB] 的物件,首先計算物件的規格大小,然後使用mcache中相應規格大小的mspan分配;
-
如果mcache沒有相應規格大小的mspan,則向mcentral申請
-
如果mcentral沒有相應規格大小的mspan,則向mheap申請
-
如果mheap中也沒有合適大小的mspan,則向作業系統申請
-
總結
Go語言的記憶體分配非常複雜,它的一個原則就是能復用的一定要復用。原始碼很難追,後面可能會再來一篇關於記憶體分配的原始碼閱讀相關的文章。簡單總結一下本文吧。
文章從一個比較粗的角度來看Go的記憶體分配,並沒有深入細節。一般而言,瞭解它的原理,到這個程度也可以了。
-
Go在程式啟動時,會向作業系統申請一大塊記憶體,之後自行管理。
-
Go記憶體管理的基本單元是mspan,它由若干個頁組成,每種mspan可以分配特定大小的object。
-
mcache, mcentral, mheap是Go記憶體管理的三大元件,層層遞進。mcache管理執行緒在本地快取的mspan;mcentral管理全域性的mspan供所有執行緒使用;mheap管理Go的所有動態分配記憶體。
-
極小物件會分配在一個object中,以節省資源,使用tiny分配器分配記憶體;一般小物件透過mspan分配記憶體;大物件則直接由mheap分配記憶體。
更好的閱讀體驗,電腦端開啟原文閱讀。
參考資料
【簡單易懂,非常清晰】https://yq.aliyun.com/articles/652551
【記憶體分配器的初始化過程,分配流程圖很詳細】https://www.jianshu.com/p/47691d870756
【全域性的圖】https://swanspouse.github.io/2018/08/22/golang-memory-model/
【雨痕 Go1.5原始碼閱讀】https://github.com/qyuhen/book
【圖不錯】https://www.jianshu.com/p/47691d870756
【整體感】https://juejin.im/post/59f2e19f5188253d6816d504
【原始碼解讀】http://legendtkl.com/2017/04/02/golang-alloc/
【重點推薦 深入到晶體管了 圖很好】https://www.linuxzen.com/go-memory-allocator-visual-guide.html
【總體描述物件分配流程】http://gocode.cc/project/4/article/103
【實際Linux命令】https://mikespook.com/2014/12/%E7%90%86%E8%A7%A3-go-%E8%AF%AD%E8%A8%80%E7%9A%84%E5%86%85%E5%AD%98%E4%BD%BF%E7%94%A8/
【整體流程圖 物件分配函式呼叫鏈路】http://blog.newbmiao.com/2018/08/20/go-source-analysis-of-memory-alloc.html
【原始碼講解 非常細緻】https://www.cnblogs.com/zkweb/p/7880099.html
【原始碼閱讀】https://zhuanlan.zhihu.com/p/34930748