本文目錄
- 1. 操作系統中的虛擬內存
- 分頁與進程管理
- 虛擬內存與內存隔離
- 2. Golang中的內存模型
- 內存分配流程
- 內存單元mspan
- 線程緩存mcache
- 中心緩存mcentral
- 全局堆緩存mheap
- heapArena
- 空閑頁索引pageAlloc
- 3. Go對象分配
- mallocgc函數
- tiny對象分配內存
- 4.結合GMP模型來看內存模型
- tiny對象分配
- 5.總結
- 設計思想
- 一些問題?
- 為什么mcache與P綁定?
- span的等級到底是66級還是67級或者68級?
- 0級到底是什么?是更大對象嗎?
- 6. 參考文章
1. 操作系統中的虛擬內存
虛擬內存是操作系統中一種重要的內存管理技術。它允許計算機系統使用硬盤空間來模擬額外的內存空間,從而擴展可用內存的范圍。
從用戶程序的角度來看,虛擬內存提供了一個比實際物理內存大得多的地址空間。操作系統通過將程序和數據分段存儲在硬盤上的虛擬內存區域,并在需要時動態地將部分數據加載到物理內存中來實現這一功能。
這種方式使得大型程序能夠在有限的物理內存環境中運行,同時也提高了內存的利用率。例如,當物理內存不足時,操作系統會將暫時不用的數據或程序代碼移動到虛擬內存中,而當這些數據被訪問時,再將其調回到物理內存。
虛擬內存的管理涉及到頁面置換算法、段頁式管理等多種技術,這些技術共同確保了虛擬內存的有效運行,并為用戶提供了一個高效且透明的內存使用環境。
比如下面這個圖,是一個簡單的示意。虛擬內存可以通過頁表來定位到真實數據到底位于哪里,從而進行訪問數據。
虛擬內存是以“頁”進行單位進行管理,物理內存是“幀”。
操作系統中虛擬內存和物理內存被切割成固定尺寸的“頁”和“幀”有其特定的意義和好處。首先,這樣做可以提高內存空間的利用效率。當內存以頁為粒度進行管理時,可以消除不穩定的外部碎片,取而代之的是相對可控的內部碎片。這意味著內存的使用更加高效,減少了浪費。(內部的碎片是指頁內的碎片地址,比如說4k,只用了3k,所以多的1k是多余的。)
其次,將內存分割成頁和幀可以提高內存與外部存儲之間的交換效率。更細的粒度意味著更高的靈活性,操作系統可以更靈活地管理內存,從而提高內外存交換的效率。
此外,這種分割方式與虛擬內存機制相呼應,便于建立虛擬地址到物理地址的映射關系。這種映射關系是通過一種稱為頁表的數據結構來實現的,它聚合了映射關系,使得虛擬內存的管理和訪問更加高效。
在Linux系統中,頁或幀的大小是固定的,通常為4KB。這個大小實際上是由實踐經驗決定的。如果頁或幀太大,會增加內存碎片率,導致內存利用不充分;如果太小,則會增加分配頻率,影響系統效率。因此,4KB是一個平衡點,既能保證內存的有效利用,又能保持較高的系統效率。
所以總的來說,分頁不僅是為了防止外部的內存碎片導致的內存浪費,也還是因為多進程時代內存可能溢出的問題,主要還是為了做進程管理。
另外就是虛擬內存不是只讓用戶看著空間更大,主要是為了解決內存隔離的問題。
這里簡單提一下進程管理和內存隔離,不是本文重點。
分頁與進程管理
進程管理是操作系統的一項基本功能,它涉及到進程的創建、調度、執行和終止。進程是操作系統進行資源分配和調度的基本單位。每個進程都有自己的地址空間,操作系統通過進程管理確保每個進程都能安全、有效地使用系統資源,并且與其他進程隔離開來。
內存泄露(Memory Leak)是指程序在申請內存后,未能在不需要時正確釋放,導致隨著時間的推移,大量內存無法被回收利用,最終可能導致程序或系統性能下降,甚至崩潰。內存泄露是編程中常見的問題,特別是在那些需要頻繁動態分配內存的程序中。
分頁是現代操作系統中常用的內存管理技術之一。通過將內存分割成固定大小的頁(Page),操作系統可以更有效地管理內存,同時也為進程管理提供了便利。每個進程都有自己的一組頁,這些頁映射到物理內存中。這種隔離機制可以防止一個進程訪問或修改另一個進程的內存,從而提高了系統的穩定性和安全性。分頁機制不僅有助于防止惡意軟件隨意訪問內存,也有助于防止進程因內存溢出而相互干擾。
虛擬內存與內存隔離
操作系統為每個進程提供了一個獨立的虛擬地址空間,進程通過虛擬地址訪問內存。操作系統使用頁表將虛擬地址映射到物理內存地址,這個過程對進程是透明的。由于每個進程都有其獨立的頁表,因此它們無法訪問其他進程的虛擬內存空間。
分頁是實現內存隔離的一種技術,操作系統將內存分割成固定大小的頁(Page),每個進程只能訪問分配給它的頁。如果進程嘗試訪問未分配給它的頁,操作系統會阻止這種訪問,從而防止進程之間的內存干擾。
2. Golang中的內存模型
有一個很核心的點是,以空間換時間,一次緩存,多次復用。
因為每次申請內存的代價比較大,所以可以多申請一些內存,方便后續程序不斷地使用。如果長時間申請的內存都是閑置的,那么就可以歸還給操作系統。
內存分配流程
從操作系統的角度來看,這是用戶進程(golang程序)中緩存的內存。
從Go自己的角度來看,堆
是所有對象的內存起源,所有的對象的內存都是“堆”申請到的內存。
為了提高分配內存的效率,Go還設計了多級緩存,從而實現無鎖化、細鎖化粒度。
我們可以看看下面這個邏輯分層圖,注意,是邏輯分層圖。只是為了最開始方便理解內存模型,后邊隨著深入講解,會不斷地延伸。
堆mheap
是全局唯一的,如果要和mheap
進行操作申請內存,需要加一個全局鎖。因為堆是全局唯一的,所以這個鎖也是全局鎖,和進程(Go程序)一對一的。
在mheap
上細化粒度,建立了有mcentral
可以理解為一個等級集合的概念,根據最終需要創建的對象的大小區別,排了一個等級,當想要分配某個內存給某個對象實例的時候,會判斷這個實例的大小,然后分配對應的mcentral
。這樣就把鎖的粒度細化到了mcentral
。也就是同一個大小等級內的所有對象,去競爭這個鎖,優化了性能。
mcache
就是GMP調度器中的處理器Process
,mcache
就是每一個處理器P獨一份的、本地私有的緩存mcache
,mcache
中會冗余每一種等級的空間mspan
,也就是會為每一個處理器去冗余一個內存空間。
當去獲取內存的時候,先根據這個Process
去查看其本地私有的mcache
中有沒有適合的內存空間使用,如果有,就直接獲取使用,因為是
中私有的,所以不涉及并發,是無鎖的,這是最理想的情況下。
如果說mcache
沒有空間,沒有辦法通過無鎖的形式進行獲取內存的行為,就會把這個行為升級,去mcentral
中想辦法分配內存。如果還是不行,就會繼續升級,就回去mheap
中獲取內存空間。如果還是不行,就會發起系統調用的指令,去虛擬內存中申請更多的空間給mheap
,然后再給我們的這次行為分配內存使用。
所以大概可以梳理個流程了。假設我們正在運行一個Go協程,該程序需要創建一個大小為128字節的對象。以下是分配這個對象可能經歷的步驟:
- 本地緩存查找
(mcache)
程序首先檢查它所屬的處理器P的本地私有緩存mcache
中是否有足夠大的內存空間mspan
來存放這個128字節的對象。mcache
中為每種大小的對象都準備了冗余的內存空間,以減少鎖的競爭和提高效率。
如果mcache
中有合適大小的mspan
,那么程序將直接從mcache
中分配內存,這個過程是無鎖的,因為每個處理器都有自己的mcache
,不涉及并發問題。
- 中央緩存查找
(mcentral)
如果mcache
中沒有合適大小的mspan
,程序將嘗試從mcentral
中獲取。mcentral
是一個按對象大小分類的中央緩存,它管理著相同大小對象的內存分配。
在mcentral
中,程序會找到管理128字節對象的mcentral
實例。由于mcentral
是按大小分類的,所以這里的鎖競爭僅限于相同大小的對象,這進一步細化了鎖的粒度,提高了性能。
如果mcentral
中有空閑的mspan
,它將被分配給程序。如果沒有,mcentral
將嘗試從mheap
中獲取新的內存空間。
- 堆內存分配
(mheap)
如果mcentral
也無法提供內存,那么程序將直接向mheap
申請內存。mheap
是Go程序的全局堆內存,負責管理整個程序的內存分配。
由于mheap是全局唯一的
,操作mheap
需要加一個全局鎖,以確保內存分配的原子性和一致性。這是整個內存分配過程中鎖粒度最大的一步,但因為前面的步驟已經盡可能地減少了對mheap
的直接操作,所以這種情況相對較少。
- 系統調用(如果必要)
如果mheap
也沒有足夠的內存,那么程序將通過系統調用向操作系統請求更多的虛擬內存空間,然后將這部分空間添加到mheap
中,再進行內存分配。
內存單元mspan
mcentral
會以我們為某個實例對象所需要分配的內存的大小來建立不同的等級,那么這個大小等級是怎么劃分的?
Golang中有兩個概念,最小的存儲單元-8KB:Page
和最小的管理單元:mspan
。
最小的存儲單元也稱為頁,page
,大小為8KB
。
mspan
里邊的obj大小,從8B
到32KB
( 32,768 字節 B
)被劃分67種不同的規格【2025年3月10日,go源碼最新確認是67種,大部分教程可能是兩年前時候的,兩年前是66種,https://github.com/golang/go/blob/master/src/runtime/sizeclasses.go ,Go倉庫鏈接。】,分配對象的內存的時候,會根據大小映射到不同規格的mspan
。(所以下面的圖劃錯了,最高應該是32KB)
mcentral
會根據不同mspan
的等級,有不同的central
的實例,每個實例會以一個雙向鏈表的形式來管理mspan
。
所以mspan的特性是如圖下所示的。雙向鏈表、起始page、page的頁數等,用來連續標識。
前面我們提到,mspan
都是page
的整數倍,page
是8KB大小的,當mspan
的obj等級為8B時,那么mspan
里邊就需要劃分很多內存塊object
。mspan
內部的頁是連續的,至少在虛擬內存的視角中是這樣,因為虛擬內存分配了連續的空間給go。
同等級的mspan
會從屬同一個mcentral
,一個mcentral
會把這些同等級的span
構造成鏈表,所以上邊是雙向鏈表,有兩個指針。并且使用一個鎖進行互斥,來管理。
mspan
會基于位圖算法bitMap
來快速找到對應的空閑塊object
,塊大小對應等級的大小。使用的是ctz64
算法。
也就是下面這個分配示意圖,為0代表被占用,為1代表free可以分配出去。
mspan
等級被劃分為1-67,67級,此外還有個0級,用于處理特殊對象。
class1,就是8bytes,即8B,也就是一個8B的對象,mspan為8KB時,就代表這個mspan可以分配1024個對象出去。
當分配的對象為0-8B,都會使用calss1
對應的span
。而不是只有8B剛剛好時才進行分配。這也會導致有內存浪費。
這也會導致一個tail waste,末尾浪費,當class為3時,obj對象大小為24B,那么8KB=8192B的span會不能完全分配完obj,會造成末尾浪費,也就是8B,341x24+8=8192B。
除此之外還有max waste
,代表了這個mspan
分配的時候最多可能會造成的空間浪費。這個也很好理解,當所有對象為17B的時候,分配了341個出去,那么一共會造成total = (24-17)x341 + 8
空間的浪費,這個總共的空間除以 total / 8192 = 29%
,這就是class
為3時的 max waste
為 29%。
每個object還有個一個很重要的屬性是nocan,也就是是否object包含了指針,在垃圾回收gc時是否需要展開對應的標記。
在go中,span class
和 nocan
兩個部分信息會組成一個 uint8
,形成完整的spanclass
標識,8個bit中,高7位標識了span
中一共66個等級,最低位標識nocan
就可以了。
線程緩存mcache
mcache
是每個P獨有的緩存,因此交互無鎖。mcache
將每種spanClass
等級的mspan
都各自緩存了一個,同時分為scan
和nocan
兩個系列,也就是是否在gc時需要展開。一共是68*2=136
個。
mcache
還有一個tiny allocator
微對象分配器,用于處理小于16B的對象內存分配。(參考了TCMalloc
。)
中心緩存mcentral
每個central
會對應一種等級的spanClass
,然后把spanclass分為兩類,分別是有空間的mspan
鏈表partial還有滿空間mspan
鏈表full。
每個central
會有一把鎖,這就是細化鎖的粒度。可以把mcentral
看成是mheap
的一部分,只不過會優先從 MCentral
獲取內存,如果沒有 MCentral 會從 Arenas
中的某個 HeapArena
獲取 Page。
全局堆緩存mheap
從go上層應用的角度來看,堆就是操作系統虛擬內存的抽象,可以看作是代言人。
mheap
以頁為單位,8KB大小,作為最小內存存儲單元。注意與之前講過的span
的內存管理單元區分。
基于bitMap
標識每個頁的使用情況,每個bit對應一頁,為0就是代表可以用,為1的話代表已經被mspan給分配走,但是不一定已經被obj對象使用了。
mheap
有一個聚合頁heapArena
,有記錄頁到其所從屬的mspan
的映射信息。這是為了方便在gc時進行操作。
建立空閑頁基數樹索引radix tree index
,幫助我們能夠快速找到空閑頁。因為我們剛剛說過,mspan
中需要的page
是連續的,所以如何通過bitMap來快速找到 連續+空閑 的頁page
,是需要考慮的,也就是這個的目的,能夠找到符合我們需求數量的空閑頁。
mheap
是mcentral
的持有者,持有所有spanClass下的mcentral
,作為自身的緩存。可以把mcentral看成mheap更細化粒度的緩存。那么,我們應該如何理解這句話?
首先,mcentral
可以看成是一次性從mheap
中分配一系列的空間去給上層使用。
也就是說,mheap
是mcentral
的持有者,這意味著mheap
負責管理所有的mcentral
實例。每個mcentral
實例對應一個特定的spanClass
,用于緩存特定大小的內存塊。
mcentral
作為mheap
的緩存,這意味著mheap
通過mcentral
來間接管理內存塊。當需要分配內存時,首先會檢查相應的mcentral
是否有可用的內存塊。如果有,就直接從mcentral
中分配;如果沒有,mheap
會負責從操作系統申請新的內存,然后將其添加到相應的mcentral
中。
當內存不夠時,mheap
會向操作系統申請,申請單位為heapArena
,64M
。
heapArena
通過下面這個圖來快速知道heapArena
的概念。(下面圖中的單詞拼多了一個a,應該為heapArena)
我們說過,mheap上游是mcentral,mcentral中的mspan如果不夠了會向mheap申請,mheap下游就是直接跟操作系統虛擬內存對接,mheap如果還不夠,就直接向虛擬內存申請了,一次性申請的大小是heapArena,也就是64MB,訪問mheap的時候需要加鎖,因為是全局唯一的。
mheap
是對內存塊的管理對象,通過page
為最小內存存儲單元進行管理。一系列的page
組合成一個heapAreana
。
所以,每個 heapArena
包含 8192 個頁,大小為 8192 * 8KB = 64 MB。
heapArena
記錄了頁到 mspan
的映射. 因為 GC 時,通過地址偏移找到頁很方便,但找到其所屬的 mspan
不容易,所以我們需要通過這個映射信息進行輔助。
每個heapArena包含一個bitmap,標記當前這個heapArena的使用情況。主要是為了GC垃圾回收,bitmap有兩種標記,一種是標記對應地址中是否存在對象,另一種標記這個對象是否被GC模塊標記過,所以當前heapArena中所有的Page都會被bitmap標記。
空閑頁索引pageAlloc
到這里已經有些比較繞了,再回顧一下這個圖。注意這個只是邏輯圖!
首先,pageAlloc
是一種基于基數樹(Radix Tree)
的索引結構,它用于快速查找和分配空閑頁。pageAlloc
通過組織和管理空閑頁的索引信息,優化了內存分配過程中的查找效率,從而提高了內存分配的性能。基數樹是一種高效的數據結構,它能夠快速地定位和檢索數據,這使得pageAlloc
能夠迅速找到滿足分配要求的連續頁空間。在內存分配過程中,pageAlloc
會根據需要分配的頁數量,在基數樹中查找合適的空閑頁范圍,如果找到合適的空閑頁,就進行分配;如果沒有找到,則可能需要觸發垃圾回收或者向操作系統請求更多的內存資源。
3. Go對象分配
Go中分配對象的方式有幾種常見的,比如new(T)
、&T{}
、make(T)
等。這幾種方法都會最終通過mallocgc
方法進行分配。
Go會根據obj的大小,將對象分為3類,分別是tiny微對象(0,16B)
、small小對象【16B,32KB】
、large大對象(32KB以上)
。
不同類型的對象,會有不同的分配策略,這些分配策略可以在mallocgc
方法中查看。
微對象的分配流程如下。
- 從P專屬的
mcache
的tiny分配器中取對應內存,這個過程是無鎖的。 - 根據對應的
spanClass
,從p專屬mcache緩存
的mspan
中取內存,無鎖。 - 根據對應的
spanClass
從對應的mcentral
中取msapn
填充到mcache
,然后從mspan
中取內存,spanClass
粒度的鎖。 - 根據對應的
spanClass
,從mheap
的頁分配器pageAlloc
中取得足夠數量空閑頁組裝成mspan
填充到mcentral
中,然后再填充到mcache
中,然后從mspan
中取內存,涉及到了mheap
,所以是全局鎖。 mheap
向操作系統申請內存,更新頁分配器的索引信息,然后重復步驟4.
小對象的分配流程就是跳過上述的步驟1,直接執行2-5即可。
對于大的對象,跳過步驟1-3,直接執行步驟4和5,因為大對象是0號等級,所以在mcentral
里面找不到對應的spanClass
等級,只能去從步驟4開始直接與堆進行交互操作。
mallocgc函數
進行對象實例的時候,都會進行mallocgc
這個方法。
malloc
是內存分配的意思,gc是垃圾回收的意思,這個函數不僅是進行內存分配,還是gc垃圾回收的入口,所以叫做mallocgc
。
malloc.go
代碼如下。
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {// ... // 獲取 mmp := acquirem()// 獲取當前 p 對應的 mcachec := getMCache(mp)var span *mspanvar x unsafe.Pointer// 根據當前對象是否包含指針,標識 gc 時是否需要展開掃描noscan := typ == nil || typ.ptrdata == 0// 是否是小于 32KB 的微、小對象if size <= maxSmallSize {// 小于 16 B 且無指針,則視為微對象if noscan && size < maxTinySize {// tiny 內存塊中,從 offset 往后有空閑位置off := c.tinyoffset// 如果大小為 5 ~ 8 B,size 會被調整為 8 B,此時 8 & 7 == 0,會走進此分支if size&7 == 0 {// 將 offset 補齊到 8 B 倍數的位置off = alignUp(off, 8)// 如果大小為 3 ~ 4 B,size 會被調整為 4 B,此時 4 & 3 == 0,會走進此分支 } else if size&3 == 0 {// 將 offset 補齊到 4 B 倍數的位置off = alignUp(off, 4)// 如果大小為 1 ~ 2 B,size 會被調整為 2 B,此時 2 & 1 == 0,會走進此分支 } else if size&1 == 0 {// 將 offset 補齊到 2 B 倍數的位置off = alignUp(off, 2)}
// 如果當前 tiny 內存塊空間還夠用,則直接分配并返回if off+size <= maxTinySize && c.tiny != 0 {// 分配空間x = unsafe.Pointer(c.tiny + off)c.tinyoffset = off + sizec.tinyAllocs++mp.mallocing = 0releasem(mp) return x} // 分配一個新的 tiny 內存塊span = c.alloc[tinySpanClass] // 從 mCache 中獲取v := nextFreeFast(span) if v == 0 {// 從 mCache 中獲取失敗,則從 mCentral 或者 mHeap 中獲取進行兜底v, span, shouldhelpgc = c.nextFree(tinySpanClass)}
// 分配空間 x = unsafe.Pointer(v)(*[2]uint64)(x)[0] = 0(*[2]uint64)(x)[1] = 0size = maxTinySize} else {// 根據對象大小,映射到其所屬的 span 的等級(0~66)var sizeclass uint8if size <= smallSizeMax-8 {sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]} else {sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]} // 對應 span 等級下,分配給每個對象的空間大小(0~32KB)size = uintptr(class_to_size[sizeclass])// 創建 spanClass 標識,其中前 7 位對應為 span 的等級(0~66),最后標識表示了這個對象 gc 時是否需要掃描spc := makeSpanClass(sizeclass, noscan) // 獲取 mcache 中的 spanspan = c.alloc[spc] // 從 mcache 的 span 中嘗試獲取空間 v := nextFreeFast(span)if v == 0 {// mcache 分配空間失敗,則通過 mcentral、mheap 兜底 v, span, shouldhelpgc = c.nextFree(spc)} // 分配空間 x = unsafe.Pointer(v)// ...} // 大于 32KB 的大對象 } else {// 從 mheap 中獲取 0 號 spanspan = c.allocLarge(size, noscan)span.freeindex = 1span.allocCount = 1size = span.elemsize // 分配空間 x = unsafe.Pointer(span.base())} // ...return x
}
tiny對象分配內存
P獨有的mcache會有一個微對象分配器,基于offset偏移線性移動的方式對微對象進行分配,每16B是一個塊,對象依據其大小,向上取整為2的整數次冪(2、4、8、16)進行空間補齊,然后進行分配。
如果tiny
對象分配器沒有分配成功,那么就會到mcache
分配。
首先根據對象的大小,映射給其所屬的mspan
的等級。對應span等級下,分配給每個對象的空間大小,嘗試獲取mcache
中的span
,如果分配失敗,就通過mcentral、mheap
繼續。
// 根據對象大小,映射到其所屬的 span 的等級var sizeclass uint8// get size class .... // 對應 span 等級下,分配給每個對象的空間大小(0~32KB)// 包含了noscan,組裝在一起得到spanClassspc := makeSpanClass(sizeclass, noscan) // 獲取 mcache 中的 spanspan = c.alloc[spc] // 從 mcache 的 span 中嘗試獲取空間 // 通過ctz64算法,在bit map上找到首個obj空位// 也就是在mspan中,用ctz64算法,根據mspan.allocCache的bitmap信息快速找到空閑的object塊并且返回。v := nextFreeFast(span)if v == 0 {// mcache 分配空間失敗,則通過 mcentral、mheap 繼續 v, span, shouldhelpgc = c.nextFree(spc)} // 分配空間 x = unsafe.Pointer(v)
當mspan
也沒有可以分配的obj內存塊的時候,會進入到mcache.nextFree
方法進行繼續獲取空間的操作。
也就是上面代碼中的。
if v == 0 {// mcache 分配空間失敗,則通過 mcentral、mheap 繼續 v, span, shouldhelpgc = c.nextFree(spc)}
從mcentral
或者mheap
中獲取到了新的span
之后,填充到mcache
的alloc
中的span
集合當中去,然后再把對應的方法返回。
func (c *mcache) nextFree(spc spanClass) (v gclinkptr, s *mspan, shouldhelpgc bool) {s = c.alloc[spc]// ...// 從 mcache 的 span 中獲取 object 空位的偏移量freeIndex := s.nextFreeIndex()if freeIndex == s.nelems {// ...// 倘若 mcache 中 span 已經沒有空位,則調用 refill 方法從 mcentral 或者 mheap 中獲取新的 span c.refill(spc)// ...// 再次從替換后的 span 中獲取 object 空位的偏移量s = c.alloc[spc]freeIndex = s.nextFreeIndex()}// ...v = gclinkptr(freeIndex*s.elemsize + s.base())s.allocCount++// ...return
}
4.結合GMP模型來看內存模型
已經完整的對整個內存模型有了解了,接下來可以結合下GMP來看看內存模型,幫助我們更好的梳理。
再來回顧一下關鍵的一些概念,Page
是Go中內存管理與虛擬內存交互內存的最小單元,8KB大小。mspan
就是一組連續的Page
,mspan
的大小是page
的整數倍。
mcache
是與GMP模型中的P所綁定,而不是線程綁定,真正可運行的線程M的數量與P的數量一致,也就是GOMAXPROCS
個。mcache
與P綁定可以更節省內存空間的使用,保證每個G使用mcache
的時候不需要加鎖就可以獲得內存。
實際上我們上層應用向go內存模型取內存,就是從span中分配一個obj出去。在上邊我們已經提到過一次了。
span size class
是一塊內存的所屬規模大小,是針對obj size
來計劃分的,比如obj
在1-8B
之間的都屬于 size class 1
級別,obj大小在8B-16B
之間的都數據size Class 2
級別。
span size class
是 針對span
進行劃分的,是span
大小的級別,一個span size class
會對應兩個span ,其中一個span存放需要GC掃描的對象,也就是包含了指針的對象,另一個span包含不需要GC的對象。
我們提到過mcache
會冗余136個spanClass
,也就是68x2
,分別對應scan和noscan。
所以mcache
的展開內部結構就是這樣對應的關系。協程從mcache上獲取內存不需要加鎖,因為一個P只有一個M(線程)在上面運行,不可能出現競爭,所以沒有鎖的限制,加速了內存的分配。
mcache
中每個span class
都會對應一個mspan
,不同的span class
的mspan
的總大小不一樣,所以需要的page
也不一樣。如圖所示,比較清晰能夠看出其中關系。
go對內存規格為0的對象(也就是span class 為0 和1)申請做了特殊處理,也就是更大的內存或者真正的0內存對象,直接會返回一個固定地址,也就是直接跟mheap交互獲得地址,而不會走正常的內存管理邏輯。
如果申請struct{}、[0]int
,這種,就會直接返回一個固定地址。
這也是為什么通過channel做同步的時候,發送一個struct{}數據,不會申請任何內存,能夠節省內存空間。
協程與mcache
的內存交換單位是obj,mcache
與mcentral
的內存交換單位是span
。
mcentral
對于每個級別會存兩個span list
鏈表,一個是沒有空間的span list
,一個是空的span list。
表示還有可用空間的 Span
鏈表。鏈表中的所有 Span 都至少有 1 個空閑的 Object 空間。如果 mcentral
上游 MCache
退還 Span
,會將退還的 Span
加入到 NonEmpty Span List
鏈表中。
tiny對象分配
int32、byte、bool這種tiny微對象如過沒有tiny分配的情況下,會經常申請一個8B的空間,這樣類似bool或者1個字節的byte,也都會獨享這個8B的空間,會造成空間浪費。
如果協程申請的空間小于等于8B,那么會匹配的span size class = 1
的8B空間。
而Tiny空間是從span size class =2
中獲取一個16B的obj作為tiny的對象的分配空間。
當大量的微小對象都是用8B的時候會造成大量浪費,所以將小于16B的申請統一歸為tiny微對象申請。然后以字節對齊的方式進行內存分配。
需要注意的是,如果申請的對象有指針,會進入小對象的申請流程(因為需要GC掃描流程),而沒有指針,才會進入tiny微對象申請流程,如果tiny空間的16B沒有多余的內存大小了,會從span size class = 2(也就是第一個noscan的mspan中)申請一個16B的object對象放在tiny空間中。
5.總結
設計思想
下次有機會再梳理一篇TCMalloc的文章。
無論是操作系統虛擬內存管理,還是 C++ 的 TCMalloc、Golang 內存模型,均有一個共同特點,就是分層的緩存機制。
針對不同的內存場景采用不同的獨特解決方式,提高局部性邏輯和細微粒度內存的復用率。這也是程序設計的至高理念。
一些問題?
為什么mcache與P綁定?
這里查閱了一些資料包括GPT,按我的理解應該如下:
首先可以, 減少鎖競爭:由于每個 P 都有自己的 MCache,當多個 goroutine 在不同的 P 上執行時,它們各自的 MCache 是隔離的,不需要加鎖就能獲得內存分配。
另外是,避免內存浪費:如果 MCache 直接與 M 綁定,那么每個線程的內存緩存會相對獨立且會有較高的內存占用。并且最關鍵的是,真正可運行的線程M的數量與P的數量一致,如果mcache與線程綁定,那么很多線程是會空閑的,而不是真正可運行的。所以M可運行的數量因為=P的數量,那么與 P 綁定的話就可以通過合理共享內存緩存來節省內存空間。
span的等級到底是66級還是67級或者68級?
截止目前,3月5日,Go官方github中的代碼注釋是1-67種,算上0,一共是68種,可以看到源代碼的相關參考如下。
[https://github.com/golang/go/blob/master/src/runtime/sizeclasses.go
]官方地址如上,很多博客或者資料寫的是1-66種,可能是因為兩年前的版本,是1-66種,目前已經是67種了。
//go:generate go run mksizeclasses.gopackage runtime// class bytes/obj bytes/span objects tail waste max waste min align
// 1 8 8192 1024 0 87.50% 8
// 2 16 8192 512 0 43.75% 16
// 3 24 8192 341 8 29.24% 8
// 4 32 8192 256 0 21.88% 32
// 5 48 8192 170 32 31.52% 16
// 6 64 8192 128 0 23.44% 64
// 7 80 8192 102 32 19.07% 16
// 8 96 8192 85 32 15.95% 32
// 9 112 8192 73 16 13.56% 16
// 10 128 8192 64 0 11.72% 128
// 11 144 8192 56 128 11.82% 16
// 12 160 8192 51 32 9.73% 32
// 13 176 8192 46 96 9.59% 16
// 14 192 8192 42 128 9.25% 64
// 15 208 8192 39 80 8.12% 16
// 16 224 8192 36 128 8.15% 32
// 17 240 8192 34 32 6.62% 16
// 18 256 8192 32 0 5.86% 256
// 19 288 8192 28 128 12.16% 32
// 20 320 8192 25 192 11.80% 64
// 21 352 8192 23 96 9.88% 32
// 22 384 8192 21 128 9.51% 128
// 23 416 8192 19 288 10.71% 32
// 24 448 8192 18 128 8.37% 64
// 25 480 8192 17 32 6.82% 32
// 26 512 8192 16 0 6.05% 512
// 27 576 8192 14 128 12.33% 64
// 28 640 8192 12 512 15.48% 128
// 29 704 8192 11 448 13.93% 64
// 30 768 8192 10 512 13.94% 256
// 31 896 8192 9 128 15.52% 128
// 32 1024 8192 8 0 12.40% 1024
// 33 1152 8192 7 128 12.41% 128
// 34 1280 8192 6 512 15.55% 256
// 35 1408 16384 11 896 14.00% 128
// 36 1536 8192 5 512 14.00% 512
// 37 1792 16384 9 256 15.57% 256
// 38 2048 8192 4 0 12.45% 2048
// 39 2304 16384 7 256 12.46% 256
// 40 2688 8192 3 128 15.59% 128
// 41 3072 24576 8 0 12.47% 1024
// 42 3200 16384 5 384 6.22% 128
// 43 3456 24576 7 384 8.83% 128
// 44 4096 8192 2 0 15.60% 4096
// 45 4864 24576 5 256 16.65% 256
// 46 5376 16384 3 256 10.92% 256
// 47 6144 24576 4 0 12.48% 2048
// 48 6528 32768 5 128 6.23% 128
// 49 6784 40960 6 256 4.36% 128
// 50 6912 49152 7 768 3.37% 256
// 51 8192 8192 1 0 15.61% 8192
// 52 9472 57344 6 512 14.28% 256
// 53 9728 49152 5 512 3.64% 512
// 54 10240 40960 4 0 4.99% 2048
// 55 10880 32768 3 128 6.24% 128
// 56 12288 24576 2 0 11.45% 4096
// 57 13568 40960 3 256 9.99% 256
// 58 14336 57344 4 0 5.35% 2048
// 59 16384 16384 1 0 12.49% 8192
// 60 18432 73728 4 0 11.11% 2048
// 61 19072 57344 3 128 3.57% 128
// 62 20480 40960 2 0 6.87% 4096
// 63 21760 65536 3 256 6.25% 256
// 64 24576 24576 1 0 11.45% 8192
// 65 27264 81920 3 128 10.00% 128
// 66 28672 57344 2 0 4.91% 4096
// 67 32768 32768 1 0 12.50% 8192
0級到底是什么?是更大對象嗎?
小徐先生1212教程中寫道0級是為了更大對象的申請,可是更大對象的申請應該是直接跟mheap進行申請,并不是所謂的0級,在劉丹冰老師的博客中,驗證了申請0級span class對象的時候,返回的地址都是一樣的。所以我覺得0級應該是特殊對象,比如struct{}這種,用來做channel通道通信。
我們來看看malloc.go這部分的源碼。可以很清楚的看到,當大于32KB的時候,直接從heap中申請。
而當size==0
的時候,直接返回一個zerobase
的地址,那么這個zerobase
是什么呢?
// Al Allocate an object of size bytes.
// Sm Small objects are allocated from the per-P cache's free lists.
// La Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// ……(省略部分代碼)if size == 0 {
return unsafe.Pointer(&zerobase)
}//……(省略部分代碼)
}
運行下面的測試代碼,看看輸出結果。
//第一篇/chapter3/MyGolang/zeroBase.go
package mainimport (
"fmt"
)func main() {
var (
//0內存對象
a struct{}
b [0]int//100個0內存struct{}
c [100]struct{}//100個0內存struct{},make申請形式
d = make([]struct{}, 100)
)fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c[50]) //取任意元素
fmt.Printf("%p\n", &(d[50])) //取任意元素
}
運行結果如下,可以看到全部的 0 內存對象分配,返回的都是一個固定的地址。
go run zeroBase.go
0x11aac78
0x11aac78
0x11aac78
0x11aac78
6. 參考文章
本文撰寫過程中主要有參考以下兩位老師的文章教程,感謝:
劉丹冰老師的Go三關:https://learnku.com/articles/68142
小徐先生1212的教程:https://www.bilibili.com/video/BV1bv411c7bp