摘要:今天我們來了解一下 Golang 中的內存逃逸的概念。
引言:寫過C/C++的同學都知道,調用著名的malloc和new函數可以在堆上分配一塊內存,這塊內存的使用和銷毀的責任都在程序員。一不小心,就會發生內存泄露,搞得膽戰心驚;切換到Golang后,基本不會擔心內存泄露了。雖然也有new函數,但是使用new函數得到的內存不一定就在堆上。堆和棧的區別對程序員“模糊化”了,當然這一切都是Go編譯器在背后幫我們完成的。一個變量是在堆上分配,還是在棧上分配,是經過編譯器的逃逸分析之后得出的結論。
什么是逃逸分析
以前寫C/C++代碼時,為了提高效率,常常將pass-by-value(傳值)“升級”成pass-by-reference(傳引用),企圖避免構造函數的運行,並且直接返回一個指針。但是這里有一個坑;在函數內部定義了一個局部變量,然后返回這個局部變量的地址(指針)。這些局部變量是在棧上分配的(靜態內存分配),一旦函數執行完畢,變量占據的內存會被銷毀,任何對這個返回值作的動作(如解引用),都將擾亂程序的運行,甚至導致程序直接崩潰。比如下面的這段代碼:
int *foo ( void ) { int t = 3; return &t; }
當然我們可以在函數內部使用 new函數構造一個變量(動態內存分配),然后返回此變量的地址,因為變量是在堆上創建的,所以函數退出時不會被銷毀,但是調用者可能會忘記delete或者直接拿返回值傳給其他函數,之后就再也不能delete它了,也就是發生了內存泄露。在編譯原理中,分析指針動態范圍的方法稱之為逃逸分析。通俗來講,當一個對象的指針被多個方法或線程引用時,我們稱這個指針發生了逃逸。簡單來說,逃逸分析決定一個變量是分配在堆上還是分配在棧上。
為什么要逃逸分析
逃逸分析這種“騷操作”把變量合理地分配到它該去的地方,“找准自己的位置”。即使你是用new申請到的內存,如果我發現你竟然在退出函數后沒有用了,那么就把你丟到棧上,畢竟棧上的內存分配比堆上快很多;反之,即使你表面上只是一個普通的變量,但是經過逃逸分析后發現在退出函數之后還有其他地方在引用,那我就把你分配到堆上。真正地做到“按需分配”。
如果變量都分配到堆上,堆不像棧可以自動清理。它會引起Go頻繁地進行垃圾回收,而垃圾回收會占用比較大的系統開銷(占用CPU容量的25%);堆和棧相比,堆適合不可預知大小的內存分配。但是為此付出的代價是分配速度較慢,而且會形成內存碎片。棧內存分配則會非常快。棧分配內存只需要兩個CPU指令:“PUSH”和“RELEASE”,分配和釋放;而堆分配內存首先需要去找到一塊大小合適的內存塊,之后要通過垃圾回收才能釋放。
通過逃逸分析,可以盡量把那些不需要分配到堆上的變量直接分配到棧上,堆上的變量少了,會減輕分配堆內存的開銷,同時也會減少gc的壓力,提高程序的運行速度。
逃逸分析原理
Go逃逸分析最基本的原則是:如果一個函數返回對一個變量的引用,那么它就會發生逃逸。
簡單來說,編譯器會分析代碼的特征和代碼生命周期,Go中的變量只有在編譯器可以證明在函數返回后不會再被引用的,才分配到棧上,其他情況下都是分配到堆上。Go語言里沒有一個關鍵字或者函數可以直接讓變量被編譯器分配到堆上,相反,編譯器通過分析代碼來決定將變量分配到何處。
簡單來說,編譯器會根據變量是否被外部引用來決定是否逃逸:
1:如果函數外部沒有引用,則優先放到棧中;
2:如果函數外部存在引用,則必定放到堆中;
逃逸分析實例
Go 提供了相關的命令,可以查看變量是否發生逃逸。下面是一個示例代碼:
package main import "fmt" func foo() *int { t := 3 return &t //返回對局部變量的引用 } func main() { x := foo() fmt.Println(*x) }
我們執行如下命令來查看逃逸情況:
//在 windows 下用雙引號
//-m: 查看逃逸過程;-l: 禁止函數內聯
$ go build -gcflags '-m -l' main.go
我們可以得到如下輸出:
C:\Users\sweenzhang\go\src\test>go build -gcflags "-m -l" main.go # command-line-arguments .\main.go:6:2: moved to heap: t .\main.go:12:13: main ... argument does not escape .\main.go:12:14: *x escapes to heap
我們可以看到:t 發生了逃逸,局部變量應該被分配到棧上,但是現在逃逸到了堆上,這和我們前面分析的一致。但是 x 為什么也逃逸到了呢?這是因為有些函數參數為interface類型,比如fmt.Println(a ...interface{}),編譯期間很難確定其參數的具體類型,也會發生逃逸。
當然我們也可以使用反匯編命令查看執行的匯編代碼來查看變量是否發生逃逸,命令如下:
$ go tool compile -S main.go
截取部分結果如下:
0x0024 00036 (main.go:6) PCDATA $0, $1 0x0024 00036 (main.go:6) PCDATA $1, $0 0x0024 00036 (main.go:6) LEAQ type.int(SB), AX 0x002b 00043 (main.go:6) PCDATA $0, $0 0x002b 00043 (main.go:6) MOVQ AX, (SP) 0x002f 00047 (main.go:6) CALL runtime.newobject(SB) 0x0034 00052 (main.go:6) PCDATA $0, $1 0x0034 00052 (main.go:6) MOVQ 8(SP), AX 0x0039 00057 (main.go:6) MOVQ $3, (AX) 0x0040 00064 (main.go:7) PCDATA $0, $0 0x0040 00064 (main.go:7) PCDATA $1, $1 0x0040 00064 (main.go:7) MOVQ AX, "".~r0+32(SP) 0x0045 00069 (main.go:7) MOVQ 16(SP), BP 0x004a 00074 (main.go:7) ADDQ $24, SP 0x004e 00078 (main.go:7) RET
結果中標記出來的 newobject() 說明發生了堆內存的申請,我們在前面的一篇 Golang 內存分配 的博客中,詳細講解了該函數的內存分配過程,可移步觀看。
總結
1:堆上動態分配內存比棧上靜態分配內存,開銷大很多;
2:變量分配在棧上需要能在編譯期確定它的作用域,否則會分配到堆上;
3:Go編譯器會在編譯期對考察變量的作用域,並作一系列檢查,如果它的作用域在運行期間對編譯器一直是可知的,那么就會分配到棧上;
簡單來說,編譯器會根據變量是否被外部引用來決定是否逃逸。對於Go程序員來說,編譯器的這些逃逸分析規則不需要掌握,我們只需通過上述命令來觀察變量逃逸情況就行了。另外需要提醒的是:不要盲目使用變量的指針作為函數參數,雖然它會減少復制操作。但其實當參數為變量自身的時候,復制是在棧上完成的操作,開銷遠比變量逃逸后動態地在堆上分配內存少的多。
參考資料:
