一篇文章把 Go 中的內(nèi)存分配扒得干干凈凈
本文轉(zhuǎn)載自微信公眾號「Go編程時光」,作者寫代碼的明哥 。轉(zhuǎn)載本文請聯(lián)系Go編程時光公眾號。
大家好,我是明哥~
今天給大家盤一盤 Go 中關(guān)于內(nèi)存管理比較常問幾個知識點。
1. 分配內(nèi)存三大組件
Go 分配內(nèi)存的過程,主要由三大組件所管理,級別從上到下分別是:
mheap
Go 在程序啟動時,首先會向操作系統(tǒng)申請一大塊內(nèi)存,并交由mheap結(jié)構(gòu)全局管理。
具體怎么管理呢?mheap 會將這一大塊內(nèi)存,切分成不同規(guī)格的小內(nèi)存塊,我們稱之為 mspan,根據(jù)規(guī)格大小不同,mspan 大概有 70類左右,劃分得可謂是非常的精細,足以滿足各種對象內(nèi)存的分配。
那么這些 mspan 大大小小的規(guī)格,雜亂在一起,肯定很難管理對吧?
因此就有了 mcentral 這下一級組件
mcentral
啟動一個 Go 程序,會初始化很多的 mcentral ,每個 mcentral 只負責管理一種特定規(guī)格的 mspan。
相當于 mcentral 實現(xiàn)了在 mheap 的基礎(chǔ)上對 mspan 的精細化管理。
但是 mcentral 在 Go 程序中是全局可見的,因此如果每次協(xié)程來 mcentral 申請內(nèi)存的時候,都需要加鎖。
可以預(yù)想,如果每個協(xié)程都來 mcentral 申請內(nèi)存,那頻繁的加鎖釋放鎖開銷是非常大的。
因此需要有一個 mcentral 的二級代理來緩沖這種壓力
mcache
在一個 Go 程序里,每個線程M會綁定給一個處理器P,在單一粒度的時間里只能做多處理運行一個goroutine,每個P都會綁定一個叫 mcache 的本地緩存。
當需要進行內(nèi)存分配時,當前運行的goroutine會從mcache中查找可用的mspan。從本地mcache里分配內(nèi)存時不需要加鎖,這種分配策略效率更高。
mspan 供應(yīng)鏈
mcache 的 mspan 數(shù)量并不總是充足的,當供不應(yīng)求的時候,mcache 會從 mcentral 再次申請更多的 mspan,同樣的,如果 mcentral 的 mspan 數(shù)量也不夠的話,mcentral 也會向它的上級 mheap 申請 mspan。再極端一點,如果 mheap 里的 mspan 也無法滿足程序的內(nèi)存申請,那該怎么辦?
那就沒辦法啦,mheap 只能厚著臉皮跟操作系統(tǒng)這個老大哥申請了。
以上的供應(yīng)流程,只適用于內(nèi)存塊小于 64KB 的場景,原因在于Go 沒法使用工作線程的本地緩存mcache和全局中心緩存 mcentral 上管理超過 64KB 的內(nèi)存分配,所以對于那些超過 64KB 的內(nèi)存申請,會直接從堆上(mheap)上分配對應(yīng)的數(shù)量的內(nèi)存頁(每頁大小是 8KB)給程序。
2. 什么是堆內(nèi)存和棧內(nèi)存?
根據(jù)內(nèi)存管理(分配和回收)方式的不同,可以將內(nèi)存分為 堆內(nèi)存 和 棧內(nèi)存。
那么他們有什么區(qū)別呢?
堆內(nèi)存:由內(nèi)存分配器和垃圾收集器負責回收
棧內(nèi)存:由編譯器自動進行分配和釋放
一個程序運行過程中,也許會有多個棧內(nèi)存,但肯定只會有一個堆內(nèi)存。
每個棧內(nèi)存都是由線程或者協(xié)程獨立占有,因此從棧中分配內(nèi)存不需要加鎖,并且棧內(nèi)存在函數(shù)結(jié)束后會自動回收,性能相對堆內(nèi)存好要高。
而堆內(nèi)存呢?由于多個線程或者協(xié)程都有可能同時從堆中申請內(nèi)存,因此在堆中申請內(nèi)存需要加鎖,避免造成沖突,并且堆內(nèi)存在函數(shù)結(jié)束后,需要 GC (垃圾回收)的介入?yún)⑴c,如果有大量的 GC 操作,將會吏程序性能下降得歷害。
3. 逃逸分析的必要性
由此可以看出,為了提高程序的性能,應(yīng)當盡量減少內(nèi)存在堆上分配,這樣就能減少 GC 的壓力。
在判斷一個變量是在堆上分配內(nèi)存還是在棧上分配內(nèi)存,雖然已經(jīng)有前人已經(jīng)總結(jié)了一些規(guī)律,但依靠程序員能夠在編碼的時候時刻去注意這個問題,對程序員的要求相當之高。
好在 Go 的編譯器,也開放了逃逸分析的功能,使用逃逸分析,可以直接檢測出你程序員所有分配在堆上的變量(這種現(xiàn)象,即是逃逸)。
方法是執(zhí)行如下命令
- go build -gcflags '-m -l' demo.go
- # 或者再加個 -m 查看更詳細信息
- go build -gcflags '-m -m -l' demo.go
內(nèi)存分配位置的規(guī)律
如果逃逸分析工具,其實人工也可以判斷到底有哪些變量是分配在堆上的。
那么這些規(guī)律是什么呢?
經(jīng)過總結(jié),主要有如下四種情況
- 根據(jù)變量的使用范圍
- 根據(jù)變量類型是否確定
- 根據(jù)變量的占用大小
- 根據(jù)變量長度是否確定
接下來我們一個一個分析驗證
根據(jù)變量的使用范圍
當你進行編譯的時候,編譯器會做逃逸分析(escape analysis),當發(fā)現(xiàn)一個變量的使用范圍僅在函數(shù)中,那么可以在棧上為它分配內(nèi)存。
比如下邊這個例子
- func foo() int {
- v := 1024
- return v
- }
- func main() {
- m := foo()
- fmt.Println(m)
- }
我們可以通過 go build -gcflags '-m -l' demo.go 來查看逃逸分析的結(jié)果,其中 -m 是打印逃逸分析的信息,-l 則是禁止內(nèi)聯(lián)優(yōu)化。
從分析的結(jié)果我們并沒有看到任何關(guān)于 v 變量的逃逸說明,說明其并沒有逃逸,它是分配在棧上的。
- $ go build -gcflags '-m -l' demo.go
- # command-line-arguments
- ./demo.go:12:13: ... argument does not escape
- ./demo.go:12:13: m escapes to heap
而如果該變量還需要在函數(shù)范圍之外使用,如果還在棧上分配,那么當函數(shù)返回的時候,該變量指向的內(nèi)存空間就會被回收,程序勢必會報錯,因此對于這種變量只能在堆上分配。
比如下邊這個例子,返回的是指針
- func foo() *int {
- v := 1024
- return &v
- }
- func main() {
- m := foo()
- fmt.Println(*m) // 1024
- }
從逃逸分析的結(jié)果中可以看到 moved to heap: v ,v 變量是從堆上分配的內(nèi)存,和上面的場景有著明顯的區(qū)別。
- $ go build -gcflags '-m -l' demo.go
- # command-line-arguments
- ./demo.go:6:2: moved to heap: v
- ./demo.go:12:13: ... argument does not escape
- ./demo.go:12:14: *m escapes to heap
除了返回指針之外,還有其他的幾種情況也可歸為一類:
第一種情況:返回任意引用型的變量:Slice 和 Map
- func foo() []int {
- a := []int{1,2,3}
- return a
- }
- func main() {
- b := foo()
- fmt.Println(b)
- }
逃逸分析結(jié)果
- $ go build -gcflags '-m -l' demo.go
- # command-line-arguments
- ./demo.go:6:12: []int literal escapes to heap
- ./demo.go:12:13: ... argument does not escape
- ./demo.go:12:13: b escapes to heap
第二種情況:在閉包函數(shù)中使用外部變量
- func Increase() func() int {
- n := 0
- return func() int {
- n++
- return n
- }
- }
- func main() {
- in := Increase()
- fmt.Println(in()) // 1
- fmt.Println(in()) // 2
- }
逃逸分析結(jié)果
- $ go build -gcflags '-m -l' demo.go
- # command-line-arguments
- ./demo.go:6:2: moved to heap: n
- ./demo.go:7:9: func literal escapes to heap
- ./demo.go:15:13: ... argument does not escape
- ./demo.go:15:16: in() escapes to heap
根據(jù)變量類型是否確定
在上邊例子中,也許你發(fā)現(xiàn)了,所有編譯輸出的最后一行中都是 m escapes to heap 。
奇怪了,為什么 m 會逃逸到堆上?
其實就是因為我們調(diào)用了 fmt.Println() 函數(shù),它的定義如下
- func Println(a ...interface{}) (n int, err error) {
- return Fprintln(os.Stdout, a...)
- }
可見其接收的參數(shù)類型是 interface{} ,對于這種編譯期不能確定其參數(shù)的具體類型,編譯器會將其分配于堆上。
根據(jù)變量的占用大小
最開始的時候,就介紹到,以 64KB 為分界線,我們將內(nèi)存塊分為 小內(nèi)存塊 和 大內(nèi)存塊。
小內(nèi)存塊走常規(guī)的 mspan 供應(yīng)鏈申請,而大內(nèi)存塊則需要直接向 mheap,在堆區(qū)申請。
以下的例子來說明
- func foo() {
- nums1 := make([]int, 8191) // < 64KB
- for i := 0; i < 8191; i++ {
- nums1[i] = i
- }
- }
- func bar() {
- nums2 := make([]int, 8192) // = 64KB
- for i := 0; i < 8192; i++ {
- nums2[i] = i
- }
- }
給 -gcflags 多加個 -m 可以看到更詳細的逃逸分析的結(jié)果
- $ go build -gcflags '-m -l' demo.go
- # command-line-arguments
- ./demo.go:5:15: make([]int, 8191) does not escape
- ./demo.go:12:15: make([]int, 8192) escapes to heap
那為什么是 64 KB 呢?
我只能說是試出來的 (8191剛好不逃逸,8192剛好逃逸),網(wǎng)上有很多文章千篇一律的說和 ulimit -a 中的 stack size 有關(guān),但經(jīng)過了解這個值表示的是系統(tǒng)棧的最大限制是 8192 KB,剛好是 8M。
- $ ulimit -a
- -t: cpu time (seconds) unlimited
- -f: file size (blocks) unlimited
- -d: data seg size (kbytes) unlimited
- -s: stack size (kbytes) 8192
我個人實在無法理解這個 8192 (8M) 和 64 KB 是如何對應(yīng)上的,如果有朋友知道,還請指教一下。
根據(jù)變量長度是否確定
由于逃逸分析是在編譯期就運行的,而不是在運行時運行的。因此避免有一些不定長的變量可能會很大,而在棧上分配內(nèi)存失敗,Go 會選擇把這些變量統(tǒng)一在堆上申請內(nèi)存,這是一種可以理解的保險的做法。
- func foo() {
- length := 10
- arr := make([]int, 0 ,length) // 由于容量是變量,因此不確定,因此在堆上申請
- }
- func bar() {
- arr := make([]int, 0 ,10) // 由于容量是常量,因此是確定的,因此在棧上申請
- }
# 參考文章
https://xie.infoq.cn/article/ee1d2416d884b229dfe57bbcc