本章節,就來學習一下go語言的內存模型,看一下內存的分配,存儲都是如何實現的,與此同時,在正式開始今天的主題之前,首先先來學習操作系統基于這一方面的內容,來看看是如何管理內存的吧
本章及節內容參考小徐先生和劉丹冰老師的內容,加上一些個人注解,go語言版本是1.24.1,由于文章的內容是在語雀,這里就附上我的語雀鏈接,方便大家更好的查看https://www.yuque.com/chenxiangyang-n12yg/pli7v5/eadkhxswioy3w746?singleDoc# 《內存管理》
一.內存管理機制
1.1 操作系統的存儲模型
在學習go語言的內存模型之前,先來熟悉一下操作系統金典的多級存儲模型,如上圖所示,差不多大家都清楚這一些東西。
1.2 虛擬內存和物理內存
虛擬內存:是一種內存管理技術,它為每一個進程提供了一個非常大的,一致的和獨立且連續的地址空間。虛擬內存通過地址翻譯硬件和頁表,提供了內存保護和簡化內存管理的能力,將物理內存和磁盤空間結合管理
它的作用如下:
- 在用戶與硬件間添加中間代理層(沒有什么是加一個中間層解決不了的)
- 優化用戶體驗(進程感知到獲得的內存空間是“連續”的)
- “放大”可用內存(虛擬內存可以由物理內存+磁盤補足,并根據冷熱動態置換,用戶無感知)
物理內存:是指計算機硬件實際的內存,即RAM(隨機存取存儲器)直接和CPU交互,用于臨時存儲運行中的程序和數據。
特點:
- 有限性:容量受硬件限制(如8GB、16GB等)
- 高速訪問:讀寫速度遠超磁盤,但斷電后數據丟失。
- 直接尋址:CPU通過物理地址直接訪問內存單元。
1.3 分頁管理
操作系統中通常會將虛擬內存和物理內存切割成固定的尺寸,于虛擬內存而言叫作“頁”,于物理內存而言叫作“幀”,原因及要點如下:
? 提高內存空間利用(以頁為粒度后,消滅了不穩定的外部碎片,取而代之的是相對可控的內部碎片)
? 提高內外存交換效率(更細的粒度帶來了更高的靈活度)
? 與虛擬內存機制呼應,便于建立虛擬地址->物理地址的映射關系(聚合映射關系的數據結構,稱為頁表)
? linux 頁/幀的大小固定,為 4KB(這實際是由實踐推動的經驗值,太粗會增加碎片率,太細會增加分配頻率影響效率)
二.Golang的內存管理機制
前面的一小節對操作系統的內存模型,做了一個簡單的介紹,如果想要了解更多這一方面的知識可以上網查詢一下,下面將邁入正題,來看看golang世界中內存模型的設計
2.1 golang的內存管理的模型圖
首先先來看一下go語言的內存模型長什么樣,然后我們在進一步去了解
看完它的大致樣子之后,我們來介紹介紹這些東西都是什么:
- mheap:全局的內存起源,訪問要加全局鎖
- mcentral:每種對象大小規格(全局共劃分為 68 種)對應的緩存,鎖的粒度也僅限于同一種規格以內
- mcache:每個 P(正是 GMP 中的 P)持有一份的內存緩存,訪問時無鎖
這些內容,我們會在后續詳細展開說明
Golang內存模型設計的幾個核心要點
- 以時間換空間,一次緩存,多次使用
首先我們要做到,每次向操作系統申請空間的操作都是很重的,那不妨一次性咱多要一點,以備后續使用。
Golang中的mheap正是基于這種思想,產生的數據結構。接下來我們從兩個不同的角度去看這個堆:
- 對于操作系統而言:這個堆就是用戶進程中緩存的內存。
- 對于Go進程內部:堆則是所有對象的起源。
- 多級緩存,以實現無/細鎖化
為什么要設置多級緩存?
實際上,堆是Go運行時中最大的臨界內存資源,這意味著每次存取都要加鎖,在性能層面上看是一件非常可怕的事情.
正是為了解決這個問題,Golang在堆mheap上,做了更加細微的處理,建立了 mcentral、mcache。
通過設置不同規格的mcentral,從而實現對不同大小的對象進行管理,分開加鎖,避免了直接對mheap直接加鎖導致的性能問題。
- 多級規格,提高利用率
首先先來了解一下page和mspan這兩個概念:
- page:最小的存儲單元
Golang 借鑒操作系統分頁管理的思想,每個最小的存儲單元也稱之為頁 page,但大小為 8 KB
- mspan:最小的管理單元
mspan 大小為 page 的整數倍,且從 8B 到 80 KB 被劃分為 67 種不同的規格,分配對象時,會根據大小映射到不同規格的 mspan,從中獲取空間,實際上有一個更大的規格,所以說是68種,后續會在涉及
為什么要將mspan劃分為多個規格呢?
- 有了規格化,便產生了等級制度,有了等級,才支持mcentral實現細瑣化
- 消除了外部碎片,但是不能避免內部碎片,宏觀上提高了整體空間的利用率
可以看一下整體的架構圖,我們后續會不斷對細節進行一個補充的。
2.2 內存單元 mspan
mspan,其實就是類似雙向鏈表中的一個結點,我們來看下mspan有那些特點:
- mspan 是 Golang 內存管理的最小單元
- mspan 大小是 page 的整數倍(Go 中的 page 大小為 8KB),且內部的頁是連續的(至少在虛擬內存的視角中是這樣)
- 每個 mspan 根據空間大小以及面向分配對象的大小,會被劃分為不同的等級(2.2小節展開)
- 同等級的 mspan 會從屬同一個 mcentral,最終會被組織成鏈表,因此帶有前后指針(prev、next)
- 由于同等級的 mspan 內聚于同一個 mcentral,所以會基于同一把互斥鎖管理
- mspan 會基于 bitMap 輔助快速找到空閑內存塊(塊大小為對應等級下的 object 大小),此時需要使用到 Ctz64 算法.
type mspan struct {// 標識前后節點的指針 next *mspan prev *mspan // ...// 起始地址startAddr uintptr // 包含幾頁,頁是連續的npages uintptr // 標識此前的位置都已被占用 freeindex uintptr// 最多可以存放多少個 objectnelems uintptr // number of object in the span.// bitmap 每個 bit 對應一個 object 塊,標識該塊是否已被占用allocCache uint64// ...// 標識 mspan 等級,包含 class 和 noscan 兩部分信息spanclass spanClass // ...
}
2.3 內存單元等級 spanClass
mspan根據空間大小和面向分配對象的大小,被劃分為67種不同的等級(1-67,實際上還有一種隱藏的0級,用于處理更大的對象,上不封頂)
實際上對于不同規格的等級,都會產生對應的浪費,簡單說一下這個浪費是怎么造成的,比如:
此時我們創建了一個對象,它的大小是1b,但是最小的mspan是8b,他不會分配1b大小的mspan,所以就會導致7b被浪費掉,比如這樣:
除了上面談及的根據大小確定的 mspan 等級外,每個 object 還有一個重要的屬性叫做 noscan,標識了 object 是否包含指針,在 gc 時是否需要展開標記.
(這里做一個簡單的拓展:Go的垃圾回收器GC需要找到存活的數據,為此GC會從根對象比如棧,全局變量,出發,遍歷所有可以到達的對象,過程是標記所有存活對象,檢查每個對象的內部字段,遞歸標記其引用的對象,如果對象不包含指針,掃描它就是多余,反而浪費CPU時間,所有noscan作用就是告訴GC,這家伙沒有指針,跳過掃描。)
在 Golang 中,會將 span class + noscan 兩部分信息組裝成一個 uint8,形成完整的 spanClass 標識. 8 個 bit 中,高 7 位表示了上表的 span 等級(總共 67 + 1 個等級,8 個 bit 足夠用了),最低位表示 nocan 信息.
2.4 線程緩存 mcache
要點:
(1)mcache 是每個 P 獨有的緩存,因此交互無鎖
(2)mcache 將每種 spanClass 等級的 mspan 各緩存了一個,總數為 2(nocan 維度) * 68(大小維度)= 136
(3)mcache 中還有一個為對象分配器 tiny allocator,用于處理小于 16B 對象的內存分配
const numSpanClasses = 136
type mcache struct {// 微對象分配器相關tiny uintptrtinyoffset uintptrtinyAllocs uintptr// mcache 中緩存的 mspan,每種 spanClass 各一個alloc [numSpanClasses]*mspan // ...
}
這里可能大家大家可能會疑惑為什么是136個大小,而不是68個,從圖中不難看出它分為了scan和noscan兩種,他表示是否含有指針,在spanclass里說過,這個指針就是告訴GC要不要掃描,如果我直接在他的上層直接將其分開,也就是有scan在一塊,沒有的在另外一塊,這樣就可以提高GC的效率,不用每次都去掃描mspan,判斷有無指針了。
mcache具體的申請形式
通過alloc區分出了136個指針,指向對應的mspan,用完時,會重新向mcentral申請,并更新alloc中的指針哦。
2.5 中心緩存 mcentral
要點:
(1)每個 mcentral 對應一種 spanClass
(2)每個 mcentral 下聚合了該 spanClass 下的 mspan
(3)mcentral 下的 mspan 分為兩個鏈表,分別為有空間 mspan 鏈表 partial 和滿空間 mspan 鏈表 full
(4)每個 mcentral 都有一把屬于自己的鎖
type mcentral struct {_ sys.NotInHeap// 對應的 spanClassspanclass spanClass// 有空位的 mspan 集合,數組長度為 2 是用于抗一輪 GCpartial [2]spanSet // 無空位的 mspan 集合full [2]spanSet
}
簡單的說一下這兩個鏈表的作用:
內存分配的時候,mcache從partical鏈表獲取mspan,當其mcache需要新的mspan時(例如mspan已滿),會向mcentral請求。mcentral會優先從partial鏈表中取出一個mspan,返回給mcache
對于這個已滿的mspan會歸還到mcache的full鏈表。
當 GC 掃描并釋放某些對象時,原本在 full
鏈表中的 mspan
可能重新獲得空閑對象。此時,這些 mspan
會從 full
鏈表移回 partial
鏈表。
2.6 全局堆緩存 mheap
- 對于 Golang 上層應用而言,堆是操作系統虛擬內存的抽象
- 以頁(8KB)為單位,作為最小內存存儲單元
- 負責將連續頁組裝成 mspan
- 全局內存基于 bitMap 標識頁使用情況,每個 bit 對應一頁,為 0 則自由,為 1 則已被 mspan 組裝(也就是說全部的頁是否使用,都會在一個巨大的bitmap里面標記)
- 通過 heapArena 聚合頁,記錄了頁到 mspan 的映射信息(后續2.8小節展開)
- 建立空閑頁基數樹索引 radix tree index,輔助快速尋找空閑頁(后續2.7小節展開)
- 是 mcentral 的持有者,持有所有 spanClass 下的 mcentral,作為自身的緩存
- 內存不夠時,向操作系統申請,申請單位為 heapArena(64M)
內存的包含形式
這里做一個簡簡單單的說明哈,可能之前的圖會誤導大家,就是mcache和mcentral都是mheap的一部分哦,可不是分開的。
type mheap struct {// 堆的全局鎖lock mutex// 空閑頁分配器,底層是多棵基數樹組成的索引,每棵樹對應 16 GB 內存空間pages pageAlloc // 記錄了所有的 mspan. 需要知道,所有 mspan 都是經由 mheap,使用連續空閑頁組裝生成的allspans []*mspan// heapAreana 數組,64 位系統下,二維數組容量為 [1][2^22]// 每個 heapArena 大小 64M,因此理論上,Golang 堆上限為 2^22*64M = 256T// 通過這個來間接管理bitmaparenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena// ...// 多個 mcentral,總個數為 spanClass 的個數central [numSpanClasses]struct {mcentral mcentral// 用于內存地址對齊pad [cpu.CacheLinePadSize - unsafe.Sizeof(mcentral{})%cpu.CacheLinePadSize]byte}// ...
}
2.7 空閑頁索引 pageAlloc
這一小節的內容主要講解空閑頁尋址分配的計數樹索引有關的內容,可能比較苦澀難懂,就做一個簡單的介紹吧,gogogo出發咯。
pageAlloc的底層是基數樹組成的一個索引,它是Go語言高效管理大內存空間的核心機制。尤其是在空閑頁的快速尋址和分配中。這一設計在 Google 的提案文檔(Scaling the Page Allocator)中有詳細描述。
接下來我會做一個簡單的介紹
為什么要使用基數樹(radix tree),而不采用傳統的bitmap呢?
- 主要就是現代程序的內存都是TB級別,傳統的bitmap掃描算法時間復雜度為O(N),無法滿足需求
- 通過基數樹可以降低時間復雜度為O(1)或O(logN)
該數據結構的一些必備知識點
- 一顆基數樹的大小為16GB,聚合了內存中各頁的使用情況,幫助mheap快速找到指定長度的連續的空閑頁所在的位置
- mheap內存的上限為256TB,所以它不是單單由一顆基數樹組成,而是森林,一共有2的14次方棵。
- mheap會對被使用過的頁進行標記,1表示已使用,0表示空閑
基數樹的節點設置
基數樹中,每個節點稱之為 PallocSum,是一個 uint64 類型,體現了索引的聚合信息,包含以下四部分:
- start:最右側 21 個 bit,標識了當前節點映射的 bitMap 范圍中首端有多少個連續的 0 bit(空閑頁),稱之為 start;
- max:中間 21 個 bit,標識了當前節點映射的 bitMap 范圍中最多有多少個連續的 0 bit(空閑頁),稱之為 max;
- end:左側 21 個 bit,標識了當前節點映射的 bitMap 范圍中最末端有多少個連續的 0 bit(空閑頁),稱之為 end.
- 最左側一個 bit,棄置不用
(這樣做的目的就是為了避免內存碎片化,這里的start和end只關心首端和尾端是不是0,如果不是0,那么他們的結果就是0,只有當首端和尾端是0的時候才會開始計算)
start和end主要用于為父節點提供合并之后的max是否發生改變,每個PallocSum下都有8個,相鄰的兩個節點的strat和end可能會合成比原本更大的一個max,所以需要記錄,后續會講到。
父子節點的關系
- 每個父 pallocSum 有 8 個子 pallocSum
- 根 pallocSum 總覽全局,映射的 bitMap 范圍為全局的 16 GB 空間(其 max 最大值為 2^21,因此總空間大小為 2^21*8KB=16GB);
- 從首層向下是一個依次八等分的過程,每一個 pallocSum 映射其父節點 bitMap 范圍的八分之一,因此第二層 pallocSum 的 bitMap 范圍為 16GB/8 = 2GB,以此類推,第五層節點的范圍為 16GB / (8^4) = 4 MB,已經很小
- 聚合信息時,自底向上. 每個父 pallocSum 聚合 8 個子 pallocSum 的 start、max、end 信息,形成自己的信息,直到根 pallocSum,坐擁全局 16 GB 的 start、max、end 信息
- mheap 尋頁時,自頂向下. 對于遍歷到的每個 pallocSum,先看起 start 是否符合,是則尋頁成功;再看 max 是否符合,是則進入其下層孩子 pallocSum 中進一步尋訪;最后看 end 和下一個同輩 pallocSum 的 start 聚合后是否滿足,是則尋頁成功.
2.8 heapArena
- 每個 heapArena 包含 8192 個頁,大小為 8192 * 8KB = 64 MB
- heapArena 記錄了頁到 mspan 的映射. 因為 GC 時,通過地址偏移找到頁很方便,但找到其所屬的 mspan 不容易. 因此需要通過這個映射信息進行輔助.
- heapArena 是 mheap 向操作系統申請內存的單位(64MB)
三.分配流程流程
下面來串聯 Golang 中分配對象的流程,不論是以下哪種方式,最終都會殊途同歸步入 mallocgc 方法中,并且根據 3.1 小節中的策略執行分配流程:
- new(T)
- &T{}
- make(xxxx)
3.1 分配對象
golang中,會依據object的大小,分為三類對象,從而對其進行內存的分配,接下來就來看看吧:
不同類型的對象,會有不同的分配策略,這些內容在mallocgc方法中都有體現:
核心流程類似于讀多級緩存的過程,自上而下,每一步只要成功就立即返回,若失敗,則由下層方法兜底。
對于微對象的分配流程:
(1)從 P 專屬 mcache 的 tiny 分配器取內存(無鎖)
(2)根據所屬的 spanClass,從 P 專屬 mcache 緩存的 mspan 中取內存(無鎖)
(3)根據所屬的 spanClass 從對應的 mcentral 中取 mspan 填充到 mcache,然后從 mspan 中取內存(spanClass 粒度鎖)
(4)根據所屬的 spanClass,從 mheap 的頁分配器 pageAlloc 取得足夠數量空閑頁組裝成 mspan 填充到 mcache,然后從 mspan 中取內存(全局鎖)
(5)mheap 向操作系統申請內存,更新頁分配器的索引信息,然后重復(4)
對于小對象的分配流程是跳過(1)步,執行上述流程的(2)-(5)步;
對于大對象的分配流程是跳過(1)-(3)步,執行上述流程的(4)-(5)步.
3.2 分配流程
(1)微小對象分配(Tiny Allocator)
- 條件:對象大小 < 16 B 且 不包含指針(避免影響垃圾回收掃描)。
- 流程:
-
- 嘗試合并:若當前
mcache.tiny
塊剩余空間足夠,直接在當前tiny
塊分配(無鎖)。 - 申請新塊:若空間不足,向
mheap
申請一個新的 16 B 內存塊(可能觸發垃圾回收)。
- 嘗試合并:若當前
- 優化:通過合并多個微小對象減少內存碎片。
(2)小對象分配(mcache -> mcentral)
- 步驟:
-
- 確定 Size Class:根據對象大小匹配預定義的 Size Class(Go 有 67 個預定義規格,如 8 B、16 B、32 B ... 32 KB)。
- 從 mcache 獲取 Span:
-
-
mcache
為每個 P(處理器)維護本地緩存 Span,包含對應 Size Class 的空閑對象鏈表。- 若當前 Span 有空閑對象,直接分配(無鎖)。
-
-
- mcache 向 mcentral 申請新 Span:
-
-
- 若
mcache
的 Span 耗盡,向對應的mcentral
請求新的 Span。 mcentral
維護全局 Span 列表(nonempty
和empty
鏈表),通過鎖保證并發安全。
- 若
-
-
- mcentral 向 mheap 擴容:
-
-
- 若
mcentral
無可用 Span,向mheap
申請新的 Span(通常為 8 KB 的倍數)。 mheap
通過基數樹查找連續空閑頁,切割為所需大小的 Span 返回給mcentral
。
- 若
-
(3)大對象分配(直接通過 mheap)
- 觸發條件:對象大小 > 32 KB。
- 流程:
-
- 計算所需頁數:將對象大小轉換為頁數(1 頁 = 8 KB)。
- 基數樹查找空閑頁:
-
-
mheap
使用基數樹快速定位滿足需求的連續空閑頁。- 基數樹的
max
字段幫助跳過無法滿足需求的子樹,start
/end
處理跨子樹合并。
-
-
- 分配并更新元數據:
-
-
- 標記位圖中對應頁為已占用。
- 更新基數樹節點的
start
/max
/end
摘要信息。
-
-
- 直接返回內存地址:無需切割 Span,直接將連續頁映射給大對象。