共計 6476 個字符,預計需要花費 17 分鐘才能閱讀完成。
這期內容當中丸趣 TV 小編將會給大家帶來有關如何分析 Go 語言內存分配,文章內容豐富且以專業的角度為大家分析和敘述,閱讀完這篇文章希望大家可以有所收獲。
Go 語言內置運行時(就是 runtime),拋棄了傳統的內存分配方式,改為自主管理。這樣可以自主地實現更好的內存使用模式,比如內存池、預分配等等。這樣,不會每次內存分配都需要進行系統調用。
Golang 運行時的內存分配算法主要源自 Google 為 C 語言開發的 TCMalloc 算法,全稱 Thread-Caching Malloc。核心思想就是把內存分為多級管理,從而降低鎖的粒度。它將可用的堆內存采用二級分配的方式進行管理:每個線程都會自行維護一個獨立的內存池,進行內存分配時優先從該內存池中分配,當內存池不足時才會向全局內存池申請,以避免不同線程對全局內存池的頻繁競爭。
基礎概念
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 arena
從上圖其實還可以看到 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 按照它自身的屬性 Size Class 的大小分割成若干個 object,每個 object 可存儲一個對象。并且會使用一個位圖來標記其尚未使用的 object。屬性 Size Class 決定 object 大小,而 mspan 只會分配給和 object 尺寸大小接近的對象,當然,對象的大小要小于 object 大小。還有一個概念:Span Class,它和 Size Class 的含義差不多,
Size_Class = Span_Class / 2
這是因為其實每個 Size Class 有兩個 mspan,也就是有兩個 Span Class。其中一個分配給含有指針的對象,另一個分配給不含有指針的對象。這會給垃圾回收機制帶來利好,之后的文章再談。
如下圖,mspan 由一組連續的頁組成,按照一定大小劃分成 object。
page mspan
Go1.9.2 里 mspan 的 Size Class 共有 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 的 Size Class 可以得到它劃分的 object 大小。比如 Size Class 等于 3,object 大小就是 32B。32B 大小的 object 可以存儲對象大小范圍在 17B~32B 的對象。而對于微小對象(小于 16B),分配器會將其進行合并,將幾個對象分配到同一個 object 中。
數組里最大的數是 32768,也就是 32KB,超過此大小就是大對象了,它會被特別對待,這個稍后會再介紹。順便提一句,類型 Size Class 為 0 表示大對象,它實際上直接由堆內存分配,而小對象都要通過 mspan 來分配。
對于 mspan 來說,它的 Size Class 會決定它所能分到的頁數,這也是寫死在代碼里的:
// 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 的時候,在 class_to_size 里對應的索引是 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 放到更大的視角來看:
mspan 更大視角
上圖可以看到有兩個 S 指向了同一個 mspan,因為這兩個 S 指向的 P 是同屬一個 mspan 的。所以,通過 arena 上的地址可以快速找到指向它的 S,通過 S 就能找到 mspan,回憶一下前面我們說的 mspan 區域的每個指針對應一頁。
假設最左邊第一個 mspan 的 Size Class 等于 10,根據前面的 class_to_size 數組,得出這個 msapn 分割的 object 大小是 144B,算出可分配的對象個數是 8KB/144B=56.89 個,取整 56 個,所以會有一些內存浪費掉了,Go 的源碼里有所有 Size Class 的 mspan 浪費的內存的大??;再根據 class_to_allocnpages 數組,得到這個 mspan 只由 1 個 page 組成;假設這個 mspan 是分配給無指針對象的,那么 spanClass 等于 20。
startAddr 直接指向 arena 區域的某個位置,表示這個 mspan 的起始地址,allocBits 指向一個位圖,每位代表一個塊是否被分配了對象;allocCount 則表示總共已分配的對象個數。
這樣,左起第一個 mspan 的各個字段參數就如下圖所示:
左起第一個 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 用 Span Classes 作為索引管理多個用于分配的 mspan,它包含所有規格的 mspan。它是_NumSizeClasses 的 2 倍,也就是 67*2=134,為什么有一個兩倍的關系,前面我們提到過:為了加速之后內存回收的速度,數組里一半的 mspan 中分配的對象不包含指針,另一半則包含指針。
對于無指針對象的 mspan 在進行垃圾回收的時候無需進一步掃描它是否引用了其他活躍的對象。后面的垃圾回收文章會再講到,這次先到這里。
mcache
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
}
mcentral
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
}
}
mheap
上圖我們看到,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 分配內存。
go 適合做什么
go 是 golang 的簡稱,而 golang 可以做服務器端開發,且 golang 很適合做日志處理、數據打包、虛擬機處理、數據庫代理等工作。在網絡編程方面,它還廣泛應用于 web 應用、API 應用等領域。
上述就是丸趣 TV 小編為大家分享的如何分析 Go 語言內存分配了,如果剛好有類似的疑惑,不妨參照上述分析進行理解。如果想知道更多相關知識,歡迎關注丸趣 TV 行業資訊頻道。