介紹
瞭解操作系統對內存的管理機制後,現在可以去看下 Go 語言是如何利用底層的這些特性來優化內存的。Go 的內存管理基本上參考 tcmalloc
來實現的,只是細節上根據自身的需要做了一些小的優化調整。
Go 的內存是自動管理的,我們可以隨意定義變量直接使用,不需要考慮變量背後的內存申請和釋放的問題。本文意在搞清楚 Go 在方面幫我們做了什麼,使我們不用關心那些複雜內存的問題,還依舊能寫出較爲高效的程序。
本篇只介紹 Go 的內存管理模型,與其相關的還有逃逸分析和垃圾回收內容,因爲篇幅的關係,打算後面找時間各自整理出一篇。
池
程序動態申請內存空間,是要使用系統調用的,比如 Linux 系統上是調用 mmap
方法實現的。但對於大型系統服務來說,直接調用 mmap
申請內存,會有一定的代價。比如:
- 系統調用會導致程序進入內核態,內核分配完內存後(也就是上篇所講的,對虛擬地址和物理地址進行映射等操作),再返回到用戶態。
- 頻繁申請很小的內存空間,容易出現大量內存碎片,增大操作系統整理碎片的壓力。
- 爲了保證內存訪問具有良好的局部性,開發者需要投入大量的精力去做優化,這是一個很重的負擔。
如何解決上面的問題呢?有經驗的人,可能很快就想到解決方案,那就是我們常說的對象池(也可以說是緩存)。
假設系統需要頻繁動態申請內存來存放一個數據結構,比如 [10]int
。那麼我們完全可以在程序啓動之初,一次性申請幾百甚至上千個 [10]int
。這樣完美的解決了上面遇到的問題:
- 不需要頻繁申請內存了,而是從對象池裏拿,程序不會頻繁進入內核態
- 因爲一次性申請一個連續的大空間,對象池會被重複利用,不會出現碎片。
- 程序頻繁訪問的就是對象池背後的同一塊內存空間,局部性良好。
這樣做會造成一定的內存浪費,我們可以定時檢測對象池的大小,保證可用對象的數量在一個合理的範圍,少了就提前申請,多了就自動釋放。
如果某種資源的申請和回收是昂貴的,我們都可以通過建立資源池的方式來解決,其他比如連接池,內存池等等,都是一個思路。
Golang 內存管理
Golang 的內存管理本質上就是一個內存池,只不過內部做了很多的優化。比如自動伸縮內存池大小,合理的切割內存塊等等。
內存池 mheap
Golang 的程序在啓動之初,會一次性從操作系統那裏申請一大塊內存作爲內存池。這塊內存空間會放在一個叫 mheap
的 struct
中管理,mheap 負責將這一整塊內存切割成不同的區域,並將其中一部分的內存切割成合適的大小,分配給用戶使用。
我們需要先知道幾個重要的概念:
page
: 內存頁,一塊8K
大小的內存空間。Go 與操作系統之間的內存申請和釋放,都是以page
爲單位的。span
: 內存塊,一個或多個連續的page
組成一個span
。如果把page
比喻成工人,span
可看成是小隊,工人被分成若干個隊伍,不同的隊伍幹不同的活。sizeclass
: 空間規格,每個span
都帶有一個sizeclass
,標記着該span
中的page
應該如何使用。使用上面的比喻,就是sizeclass
標誌着span
是一個什麼樣的隊伍。object
: 對象,用來存儲一個變量數據內存空間,一個span
在初始化時,會被切割成一堆等大的object
。假設object
的大小是16B
,span
大小是8K
,那麼就會把span
中的page
就會被初始化8K / 16B = 512
個object
。所謂內存分配,就是分配一個object
出去。
示意圖:
上圖中,不同顏色代表不同的 span
,不同 span
的 sizeclass
不同,表示裏面的 page
將會按照不同的規格切割成一個個等大的 object
用作分配。
使用 Go1.11.5 版本測試了下初始堆內存應該是 64M
左右,低版本會少點。
測試代碼:
package main
import "runtime"
var stat runtime.MemStats
func main() {
runtime.ReadMemStats(&stat)
println(stat.HeapSys)
}
內部的整體內存佈局如下圖所示:
mheap.spans
:用來存儲page
和span
信息,比如一個 span 的起始地址是多少,有幾個 page,已使用了多大等等。mheap.bitmap
存儲着各個span
中對象的標記信息,比如對象是否可回收等等。mheap.arena_start
: 將要分配給應用程序使用的空間。
再說明下,圖中的空間大小,是 Go 向操作系統申請的虛擬內存地址空間,操作系統會將該段地址空間預留出來不做它用;而不是真的創建出這麼大的虛擬內存,在頁表中創建出這麼大的映射關係。
mcentral
用途相同的 span
會以鏈表的形式組織在一起。 這裏的用途用 sizeclass
來表示,就是指該 span
用來存儲哪種大小的對象。比如當分配一塊大小爲 n
的內存時,系統計算 n
應該使用哪種 sizeclass
,然後根據 sizeclass
的值去找到一個可用的 span
來用作分配。其中 sizeclass
一共有 67 種(Go1.5 版本,後續版本可能會不會改變不好說),如圖所示:
找到合適的 span
後,會從中取一個 object
返回給上層使用。這些 span
被放在一個叫做 mcentral 的結構中管理。
mheap 將從 OS 那裏申請過來的內存初始化成一個大 span
(sizeclass=0)。然後根據需要從這個大 span
中切出小 span
,放在 mcentral 中來管理。大 span
由 mheap.freelarge
和 mheap.busylarge
等管理。如果 mcentral 中的 span
不夠用了,會從 mheap.freelarge
上再切一塊,如果 mheap.freelarge
空間不夠,會再次從 OS 那裏申請內存重複上述步驟。下面是 mheap 和 mcentral 的數據結構:
type mheap struct {
// other fields
lock mutex
free [_MaxMHeapList]mspan // free lists of given length, 1M 以下
freelarge mspan // free lists length >= _MaxMHeapList, >= 1M
busy [_MaxMHeapList]mspan // busy lists of large objects of given length
busylarge mspan // busy lists of large objects length >= _MaxMHeapList
central [_NumSizeClasses]struct { // _NumSizeClasses = 67
mcentral mcentral
// other fields
}
// other fields
}
// Central list of free objects of a given size.
type mcentral struct {
lock mutex // 分配時需要加鎖
sizeclass int32 // 哪種 sizeclass
nonempty mspan // 還有可用的空間的 span 鏈表
empty mspan // 沒有可用的空間的 span 列表
}
這種方式可以避免出現外部碎片(文章最後面有外部碎片的介紹),因爲同一個 span 是按照固定大小分配和回收的,不會出現不可利用的一小塊內存把內存分割掉。這個設計方式與現代操作系統中的夥伴系統有點類似。
mcache
如果你閱讀的比較仔細,會發現上面的 mcentral 結構中有一個 lock 字段;因爲併發情況下,很有可能多個線程同時從 mcentral 那裏申請內存的,必須要用鎖來避免衝突。
但鎖是低效的,在高併發的服務中,它會使內存申請成爲整個系統的瓶頸;所以在 mcentral 的前面又增加了一層 mcache。
每一個 mcache 和每一個處理器(P) 是一一對應的,也就是說每一個 P 都有一個 mcache 成員。 Goroutine 申請內存時,首先從其所在的 P 的 mcache 中分配,如果 mcache 沒有可用 span
,再從 mcentral 中獲取,並填充到 mcache 中。
從 mcache 上分配內存空間是不需要加鎖的,因爲在同一時間裏,一個 P 只有一個線程在其上面運行,不可能出現競爭。沒有了鎖的限制,大大加速了內存分配。
所以整體的內存分配模型大致如下圖所示:
其他優化
zero size
有一些對象所需的內存大小是0,比如 [0]int
, struct{}
,這種類型的數據根本就不需要內存,所以沒必要走上面那麼複雜的邏輯。
系統會直接返回一個固定的內存地址。源碼如下:
func mallocgc(size uintptr, typ *_type, flags uint32) unsafe.Pointer {
// 申請的 0 大小空間的內存
if size == 0 {
return unsafe.Pointer(&zerobase)
}
//.....
}
測試代碼:
package main
import (
"fmt"
)
func main() {
var (
a struct{}
b [0]int
c [100]struct{}
d = make([]struct{}, 1024)
)
fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c)
fmt.Printf("%p\n", &(d[0]))
fmt.Printf("%p\n", &(d[1]))
fmt.Printf("%p\n", &(d[1000]))
}
// 運行結果,6 個變量的內存地址是相同的:
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88
0x1180f88
Tiny對象
上面提到的 sizeclass=1
的 span,用來給 <= 8B
的對象使用,所以像 int32
, byte
, bool
以及小字符串等常用的微小對象,都會使用 sizeclass=1
的 span,但分配給他們 8B
的空間,大部分是用不上的。並且這些類型使用頻率非常高,就會導致出現大量的內部碎片。
所以 Go 儘量不使用 sizeclass=1
的 span, 而是將 < 16B
的對象爲統一視爲 tiny 對象(tinysize)。分配時,從 sizeclass=2
的 span 中獲取一個 16B
的 object 用以分配。如果存儲的對象小於 16B
,這個空間會被暫時保存起來 (mcache.tiny
字段),下次分配時會複用這個空間,直到這個 object 用完爲止。
如圖所示:
以上圖爲例,這樣的方式空間利用率是 (1+2+8) / 16 * 100% = 68.75%
,而如果按照原始的管理方式,利用率是 (1+2+8) / (8 * 3) = 45.83%
。
源碼中註釋描述,說是對 tiny 對象的特殊處理,平均會節省 20%
左右的內存。
如果要存儲的數據裏有指針,即使 <= 8B
也不會作爲 tiny 對象對待,而是正常使用 sizeclass=1
的 span
。
大對象
如上面所述,最大的 sizeclass 最大隻能存放 32K
的對象。如果一次性申請超過 32K
的內存,系統會直接繞過 mcache 和 mcentral,直接從 mheap 上獲取,mheap 中有一個 freelarge
字段管理着超大 span。
總結
內存的釋放過程,沒什麼特別之處。就是分配的返過程,當 mcache 中存在較多空閒 span 時,會歸還給 mcentral;而 mcentral 中存在較多空閒 span 時,會歸還給 mheap;mheap 再歸還給操作系統。這裏就不詳細介紹了。
總結一下,這種設計之所以快,主要有以下幾個優勢:
- 內存分配大多時候都是在用戶態完成的,不需要頻繁進入內核態。
- 每個 P 都有獨立的 span cache,多個 CPU 不會併發讀寫同一塊內存,進而減少 CPU L1 cache 的 cacheline 出現 dirty 情況,增大 cpu cache 命中率。
- 內存碎片的問題,Go 是自己在用戶態管理的,在 OS 層面看是沒有碎片的,使得操作系統層面對碎片的管理壓力也會降低。
- mcache 的存在使得內存分配不需要加鎖。
當然這不是沒有代價的,Go 需要預申請大塊內存,這必然會出現一定的浪費,不過好在現在內存比較廉價,不用太在意。
總體上來看,Go 內存管理也是一個金字塔結構:
這種設計比較通用,比如現在常見的 web 服務設計,爲提升系統性能,一般都會設計成 客戶端 cache -> 服務端 cache -> 服務端 db
這幾層(當然也可能會加入更多層),也是金字塔結構。
將有限的計算資源佈局成金字塔結構,再將數據從熱到冷分爲幾個層級,放置在金字塔結構上。調度器不斷做調整,將熱數據放在金字塔頂層,冷數據放在金字塔底層。
這種設計利用了計算的局部性特徵,認爲冷熱數據的交替是緩慢的。所以最怕的就是,數據訪問出現冷熱驟變。在操作系統上我們稱這種現象爲內存顛簸,系統架構上通常被說成是緩存穿透。其實都是一個意思,就是過度的使用了金字塔低端的資源。
這套內部機制,使得開發高性能服務容易很多,通俗來講就是坑少了。一般情況下你隨便寫寫性能都不會太差。我遇到過的導致內存分配出現壓力的主要有 2 中情況:
- 頻繁申請大對象,常見於文本處理,比如寫一個海量日誌分析的服務,很多日誌內容都很長。這種情況建議自己維護一個對象([]byte)池,避免每次都要去 mheap 上分配。
- 濫用指針,指針的存在不僅容易造成內存浪費,對 GC 也會造成額外的壓力,所以儘量不要使用指針。
附
內存碎片
內存碎片是系統在內存管理過程中,會不可避免的出現一塊塊無法被使用的內存空間,這是內存管理的產物。
內部碎片
一般都是因爲字節對齊,如上面介紹 Tiny 對象分配的部分;爲了字節對齊,會導致一部分內存空間直接被放棄掉,不做分配使用。
再比如申請 28B 大小的內存空間,系統會分配 32B 的空間給它,這也導致了其中 4B 空間是被浪費掉的。這就是內部碎片。
外部碎片
一般是因爲內存的不斷分配釋放,導致一些釋放的小內存塊分散在內存各處,無法被用以分配。如圖:
上面的 8B 和 16B 的小空間,很難再被利用起來。不過 Go 的內存管理機制不會引起大量外部碎片。
源代碼調用流程圖
針對 Go1.5 源碼
runtime.MemStats 部分註釋
type MemStats struct {
// heap 分配出去的字節總數,和 HeapAlloc 值相同
Alloc uint64
// TotalAlloc 是 heap 累計分配出去字節數,每次分配
// 都會累加這個值,但是釋放時候不會減少
TotalAlloc uint64
// Sys 是指程序從 OS 那裏一共申請了多少內存
// 因爲除了 heap,程序棧及其他內部結構都使用着從 OS 申請過來的內存
Sys uint64
// Mallocs heap 累積分配出去的對象數
// 活動中的對象總數,即是 Mallocs - Frees
Mallocs uint64
// Frees 值 heap 累積釋放掉的對象總數
Frees uint64
// HeapAlloc 是分配出去的堆對象總和大小,單位字節
// object 的聲明週期是 待分配 -> 分配使用 -> 待回收 -> 待分配
// 只要不是待分配的狀態,都會加到 HeapAlloc 中
// 它和 HeapInuse 不同,HeapInuse 算的是使用中的 span,
// 使用中的 span 裏面可能還有很多 object 閒置
HeapAlloc uint64
// HeapSys 是 heap 從 OS 那裏申請來的堆內存大小,單位字節
// 指的是虛擬內存的大小,不是物理內存,物理內存大小 Go 語言層面是看不到的。
// 等於 HeapIdle + HeapInuse
HeapSys uint64
// HeapIdle 表示所有 span 中還有多少內存是沒使用的
// 這些 span 上面沒有 object,也就是完全閒置的,可以隨時歸還給 OS
// 也可以用於堆棧分配
HeapIdle uint64
// HeapInuse 是處在使用中的所有 span 中的總字節數
// 只要一個 span 中有至少一個對象,那麼就表示它被使用了
// HeapInuse - HeapAlloc 就表示已經被切割成固定 sizeclass 的 span 裏
HeapInuse uint64
// HeapReleased 是返回給操作系統的物理內存總數
HeapReleased uint64
// HeapObjects 是分配出去的對象總數
// 如同 HeapAlloc 一樣,分配時增加,被清理或被釋放時減少
HeapObjects uint64
// NextGC is the target heap size of the next GC cycle.
// NextGC 表示當 HeapAlloc 增長到這個值時,會執行一次 GC
// 垃圾回收的目標是保持 HeapAlloc ≤ NextGC,每次 GC 結束
// 下次 GC 的目標,是根據當前可達數據和 GOGC 參數計算得來的
NextGC uint64
// LastGC 是最近一次垃圾回收結束的時間 (the UNIX epoch).
LastGC uint64
// PauseTotalNs 是自程序啓動起, GC 造成 STW 暫停的累積納秒值
// STW 期間,所有的 goroutine 都會被暫停,只有 GC 的 goroutine 可以運行
PauseTotalNs uint64
// PauseNs 是循環隊列,記錄着 GC 引起的 STW 總時間
//
// 一次 GC 循環,可能會出現多次暫停,這裏每項記錄的是一次 GC 循環裏多次暫停的綜合。
// 最近一次 GC 的數據所在的位置是 PauseNs[NumGC%256]
PauseNs [256]uint64
// PauseEnd 是一個循環隊列,記錄着最近 256 次 GC 結束的時間戳,單位是納秒。
//
// 它和 PauseNs 的存儲方式一樣。一次 GC 可能會引發多次暫停,這裏只記錄一次 GC 最後一次暫停的時間
PauseEnd [256]uint64
// NumGC 指完成 GC 的次數
NumGC uint32
// NumForcedGC 是指應用調用了 runtime.GC() 進行強制 GC 的次數
NumForcedGC uint32
// BySize 統計各個 sizeclass 分配和釋放的對象的個數
//
// BySize[N] 統計的是對象大小 S,滿足 BySize[N-1].Size < S ≤ BySize[N].Size 的對象
// 這裏不記錄大於 BySize[60].Size 的對象分配
BySize [61]struct {
// Size 表示該 sizeclass 的每個對象的空間大小
// size class.
Size uint32
// Mallocs 是該 sizeclass 分配出去的對象的累積總數
// Size * Mallocs 就是累積分配出去的字節總數
// Mallocs - Frees 就是當前正在使用中的對象總數
Mallocs uint64
// Frees 是該 sizeclass 累積釋放對象總數
Frees uint64
}
}