面試官:小松子來聊一聊內存逃逸
本文轉載自微信公眾號「Golang夢工廠」,作者AsongGo。轉載本文請聯系Golang夢工廠公眾號。
前言
哈嘍,大家好,我是asong。最近無聊看了一下Go語言的面試八股文,發現面試官都喜歡問內存逃逸這個話題,這個激起了我的興趣,我對內存逃逸的了解很淺,所以找了很多文章精讀了一下,在這里做一個總結,方便日后查閱、學習。
什么是內存逃逸
初次看到這個話題,我是懵逼的,怎么還有內存逃逸,內存逃逸到底是干什么的?接下來我們一起來看看什么是內存逃逸。
我們都知道一般情況下程序存放在rom或者Flash中,運行時需要拷貝到內存中執行,內存會分別存儲不同的信息,內存空間包含兩個最重要的區域:堆區(Stack)和棧區(Heap),對于我這種C語言出身的人,對堆內存和棧內存的了解還是挺深的。在C語言中,棧區域會專門存放函數的參數、局部變量等,棧的地址從內存高地址往低地址增長,而堆內存正好相反,堆地址從內存低地址往高地址增長,但是如果我們想在堆區域分配內存需要我們手動調用malloc函數去堆區域申請內存分配,然后我使用完了還需要自己手動釋放,如果沒有釋放就會導致內存泄漏。寫過C語言的朋友應該都知道C語言函數是不能返回局部變量地址(特指存放于棧區的局部變量地址),除非是局部靜態變量地址,字符串常量地址、動態分配地址。其原因是一般局部變量的作用域只在函數內,其存儲位置在棧區中,當程序調用完函數后,局部變量會隨此函數一起被釋放。其地址指向的內容不明(原先的數值可能不變,也可能改變)。而局部靜態變量地址和字符串常量地址存放在數據區,動態分配地址存放在堆區,函數運行結束后只會釋放棧區的內容,而不會改變數據區和堆區。
所以在C語言中我們想在一個函數中返回局部變量地址時,有三個正確的方式:返回靜態局部變量地址、返回字符串常量地址,返回動態分配在堆上的地址,因為他們都不在棧區,即使釋放函數,其內容也不會受影響,我們以在返回堆上內存地址為例看一段代碼:
- #include "stdio.h"
- #include "stdlib.h"
- //返回動態分配的地址
- int* f1()
- {
- int a = 9;
- int *pa = (int*) malloc(8);
- *pa = a;
- return pa;
- }
- int main()
- {
- int *pb;
- pb = f1();
- printf("after : *pb = %d\tpb = %p\n",*pb, pb);
- free(pb);
- return 1;
- }
通過上面的例子我們知道在C語言中動態內存的分配與釋放完全交與程序員的手中,這樣就會導致我們在寫程序時如履薄冰,好處是我們可以完全掌控內存,缺點是我們一不小心就會導致內存泄漏,所以很多現代語言都有GC機制,Go就是一門帶垃圾回收的語言,真正解放了我們程序員的雙手,我們不需要在像寫C語言那樣考慮是否能返回局部變量地址了,內存管理交與給編譯器,編譯器會經過逃逸分析把變量合理的分配到"正確"的地方。
說到這里,可以簡單總結一下什么是內存逃逸了:
在一段程序中,每一個函數都會有自己的內存區域存放自己的局部變量、返回地址等,這些內存會由編譯器在棧中進行分配,每一個函數都會分配一個棧楨,在函數運行結束后進行銷毀,但是有些變量我們想在函數運行結束后仍然使用它,那么就需要把這個變量在堆上分配,這種從"棧"上逃逸到"堆"上的現象就成為內存逃逸。
什么是逃逸分析
上面我們知道了什么是內存逃逸,下面我們就來看一看什么是逃逸分析?
上文我們說到C語言使用malloc在堆上動態分配內存后,還需要手動調用free釋放內存,如果不釋放就會造成內存泄漏的風險。在Go語言中堆內存的分配與釋放完全不需要我們去管了,Go語言引入了GC機制,GC機制會對位于堆上的對象進行自動管理,當某個對象不可達時(即沒有其對象引用它時),他將會被回收并被重用。雖然引入GC可以讓開發人員降低對內存管理的心智負擔,但是GC也會給程序帶來性能損耗,當堆內存中有大量待掃描的堆內存對象時,將會給GC帶來過大的壓力,雖然Go語言使用的是標記清除算法,并且在此基礎上使用了三色標記法和寫屏障技術,提高了效率,但是如果我們的程序仍在堆上分配了大量內存,依賴會對GC造成不可忽視的壓力。因此為了減少GC造成的壓力,Go語言引入了逃逸分析,也就是想法設法盡量減少在堆上的內存分配,可以在棧中分配的變量盡量留在棧中。
小結逃逸分析:
逃逸分析就是指程序在編譯階段根據代碼中的數據流,對代碼中哪些變量需要在棧中分配,哪些變量需要在堆上分配進行靜態分析的方法。堆和棧相比,堆適合不可預知大小的內存分配。但是為此付出的代價是分配速度較慢,而且會形成內存碎片。棧內存分配則會非??臁7峙鋬却嬷恍枰獌蓚€CPU指令:“PUSH”和“RELEASE”,分配和釋放;而堆分配內存首先需要去找到一塊大小合適的內存塊,之后要通過垃圾回收才能釋放。所以逃逸分析更做到更好內存分配,提高程序的運行速度。
Go語言中的逃逸分析
Go語言的逃逸分析總共實現了兩個版本:
- 1.13版本前是第一版
- 1.13版本后是第二版
粗略看了一下逃逸分析的代碼,大概有1500+行(go1.15.7)。代碼我倒是沒仔細看,注釋我倒是仔細看了一遍,注釋寫的還是很詳細的,代碼路徑:src/cmd/compile/internal/gc/escape.go,大家可以自己看一遍注釋,其逃逸分析原理如下:
- pointers to stack objects cannot be stored in the heap:指向棧對象的指針不能存儲在堆中
- pointers to a stack object cannot outlive that object:指向棧對象的指針不能超過該對象的存活期,也就說指針不能在棧對象被銷毀后依舊存活。(例子:聲明的函數返回并銷毀了對象的棧幀,或者它在循環迭代中被重復用于邏輯上不同的變量)
我們大概知道它的分析準則是什么就好了,具體逃逸分析是怎么做的,感興趣的同學可以根據源碼自行研究。
既然逃逸分析是在編譯階段進行的,那我們就可以通過go build -gcflags '-m -m -l'命令查看到逃逸分析的結果,我們之前在分析內聯優化時使用的-gcflags '-m -m',能看到所有的編譯器優化,這里使用-l禁用掉內聯優化,只關注逃逸優化就好了。
現在我們也知道了逃逸分析,接下來我們就看幾個逃逸分析的例子。
幾個逃逸分析的例子
1. 函數返回局部指針變量
先看例子:
- #include "stdio.h"
- #include "stdlib.h"
- //返回動態分配的地址
- int* f1()
- {
- int a = 9;
- int *pa = (int*) malloc(8);
- *pa = a;
- return pa;
- }
- int main()
- {
- int *pb;
- pb = f1();
- printf("after : *pb = %d\tpb = %p\n",*pb, pb);
- free(pb);
- return 1;
- }
查看逃逸分析結果:
- go build -gcflags="-m -m -l" ./test1.go
- # command-line-arguments
- ./test1.go:6:9: &res escapes to heap
- ./test1.go:6:9: from ~r2 (return) at ./test1.go:6:2
- ./test1.go:4:2: moved to heap: res
分析結果很明了,函數返回的局部變量是一個指針變量,當函數Add執行結束后,對應的棧楨就會被銷毀,但是引用已經返回到函數之外,如果我們在外部解引用地址,就會導致程序訪問非法內存,就像上面的C語言的例子一樣,所以編譯器經過逃逸分析后將其在堆上分配內存。
2. interface類型逃逸
先看一個例子:
- func main() {
- str := "asong太帥了吧"
- fmt.Printf("%v",str)
- }
查看逃逸分析結果:
- go build -gcflags="-m -m -l" ./test2.go
- # command-line-arguments
- ./test2.go:9:13: str escapes to heap
- ./test2.go:9:13: from ... argument (arg to ...) at ./test2.go:9:13
- ./test2.go:9:13: from *(... argument) (indirection) at ./test2.go:9:13
- ./test2.go:9:13: from ... argument (passed to call[argument content escapes]) at ./test2.go:9:13
- ./test2.go:9:13: main ... argument does not escape
str是main函數中的一個局部變量,傳遞給fmt.Println()函數后發生了逃逸,這是因為fmt.Println()函數的入參是一個interface{}類型,如果函數參數為interface{},那么在編譯期間就很難確定其參數的具體類型,也會發送逃逸。
觀察這個分析結果,我們可以看到沒有moved to heap: str,這也就是說明str變量并沒有在堆上進行分配,只是它存儲的值逃逸到堆上了,也就說任何被str引用的對象必須分配在堆上。如果我們把代碼改成這樣:
- func main() {
- str := "asong太帥了吧"
- fmt.Printf("%p",&str)
- }
查看逃逸分析結果:
- go build -gcflags="-m -m -l" ./test2.go
- # command-line-arguments
- ./test2.go:9:18: &str escapes to heap
- ./test2.go:9:18: from ... argument (arg to ...) at ./test2.go:9:12
- ./test2.go:9:18: from *(... argument) (indirection) at ./test2.go:9:12
- ./test2.go:9:18: from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12
- ./test2.go:9:18: &str escapes to heap
- ./test2.go:9:18: from &str (interface-converted) at ./test2.go:9:18
- ./test2.go:9:18: from ... argument (arg to ...) at ./test2.go:9:12
- ./test2.go:9:18: from *(... argument) (indirection) at ./test2.go:9:12
- ./test2.go:9:18: from ... argument (passed to call[argument content escapes]) at ./test2.go:9:12
- ./test2.go:8:2: moved to heap: str
- ./test2.go:9:12: main ... argument does not escape
這回str也逃逸到了堆上,在堆上進行內存分配,這是因為我們訪問str的地址,因為入參是interface類型,所以變量str的地址以實參的形式傳入fmt.Printf后被裝箱到一個interface{}形參變量中,裝箱的形參變量的值要在堆上分配,但是還要存儲一個棧上的地址,也就是str的地址,堆上的對象不能存儲一個棧上的地址,所以str也逃逸到堆上,在堆上分配內存。(這里注意一個知識點:Go語言的參數傳遞只有值傳遞)
3. 閉包產生的逃逸
- func Increase() func() int {
- n := 0
- return func() int {
- n++
- return n
- }
- }
- func main() {
- in := Increase()
- fmt.Println(in()) // 1
- }
查看逃逸分析結果:
- go build -gcflags="-m -m -l" ./test3.go
- # command-line-arguments
- ./test3.go:10:3: Increase.func1 capturing by ref: n (addr=true assign=true width=8)
- ./test3.go:9:9: func literal escapes to heap
- ./test3.go:9:9: from ~r0 (assigned) at ./test3.go:7:17
- ./test3.go:9:9: func literal escapes to heap
- ./test3.go:9:9: from &(func literal) (address-of) at ./test3.go:9:9
- ./test3.go:9:9: from ~r0 (assigned) at ./test3.go:7:17
- ./test3.go:10:3: &n escapes to heap
- ./test3.go:10:3: from func literal (captured by a closure) at ./test3.go:9:9
- ./test3.go:10:3: from &(func literal) (address-of) at ./test3.go:9:9
- ./test3.go:10:3: from ~r0 (assigned) at ./test3.go:7:17
- ./test3.go:8:2: moved to heap: n
- ./test3.go:17:16: in() escapes to heap
- ./test3.go:17:16: from ... argument (arg to ...) at ./test3.go:17:13
- ./test3.go:17:16: from *(... argument) (indirection) at ./test3.go:17:13
- ./test3.go:17:16: from ... argument (passed to call[argument content escapes]) at ./test3.go:17:13
- ./test3.go:17:13: main ... argument does not escape
因為函數也是一個指針類型,所以匿名函數當作返回值時也發生了逃逸,在匿名函數中使用外部變量n,這個變量n會一直存在直到in被銷毀,所以n變量逃逸到了堆上。
4. 變量大小不確定及??臻g不足引發逃逸
我們先使用ulimit -a查看操作系統的棧空間:
- ulimit -a
- -t: cpu time (seconds) unlimited
- -f: file size (blocks) unlimited
- -d: data seg size (kbytes) unlimited
- -s: stack size (kbytes) 8192
- -c: core file size (blocks) 0
- -v: address space (kbytes) unlimited
- -l: locked-in-memory size (kbytes) unlimited
- -u: processes 2784
- -n: file descriptors 256
我的電腦的??臻g大小是8192,所以根據這個我們寫一個測試用例:
- package main
- import (
- "math/rand"
- )
- func LessThan8192() {
- nums := make([]int, 100) // = 64KB
- for i := 0; i < len(nums); i++ {
- nums[i] = rand.Int()
- }
- }
- func MoreThan8192(){
- nums := make([]int, 1000000) // = 64KB
- for i := 0; i < len(nums); i++ {
- nums[i] = rand.Int()
- }
- }
- func NonConstant() {
- number := 10
- s := make([]int, number)
- for i := 0; i < len(s); i++ {
- s[i] = i
- }
- }
- func main() {
- NonConstant()
- MoreThan8192()
- LessThan8192()
- }
查看逃逸分析結果:
- go build -gcflags="-m -m -l" ./test4.go
- # command-line-arguments
- ./test4.go:8:14: LessThan8192 make([]int, 100) does not escape
- ./test4.go:16:14: make([]int, 1000000) escapes to heap
- ./test4.go:16:14: from make([]int, 1000000) (non-constant size) at ./test4.go:16:14
- ./test4.go:25:11: make([]int, number) escapes to heap
- ./test4.go:25:11: from make([]int, number) (non-constant size) at ./test4.go:25:11
我們可以看到,當??臻g足夠時,不會發生逃逸,但是當變量過大時,已經完全超過??臻g的大小時,將會發生逃逸到堆上分配內存。
同樣當我們初始化切片時,沒有直接指定大小,而是填入的變量,這種情況為了保證內存的安全,編譯器也會觸發逃逸,在堆上進行分配內存。
參考文章(建議大家閱讀一遍)
- https://driverzhang.github.io/post/golang%E5%86%85%E5%AD%98%E5%88%86%E9%85%8D%E9%80%83%E9%80%B8%E5%88%86%E6%9E%90/
- https://segmentfault.com/a/1190000039843497
- https://tonybai.com/2021/05/24/understand-go-escape-analysis-by-example/
- https://cloud.tencent.com/developer/article/1732263
- https://geektutu.com/post/hpg-escape-analysis.html
總結
本文到這里結束了,這篇文章我們一起分析了什么是內存逃逸以及Go語言中的逃逸分析,上面只列舉了幾個例子,因為發生的逃逸的情況是列舉不全的,我們只需要了解什么是逃逸分析,了解逃逸的策略就可以了,后面在實戰中可以根據具體代碼具體分析,寫出更優質的代碼。
最后對逃逸做一個總結:
- 逃逸分析在編譯階段確定哪些變量可以分配在棧中,哪些變量分配在堆上
- 逃逸分析減輕了GC壓力,提高程序的運行速度
- 棧上內存使用完畢不需要GC處理,堆上內存使用完畢會交給GC處理
- 函數傳參時對于需要修改原對象值,或占用內存比較大的結構體,選擇傳指針。對于只讀的占用內存較小的結構體,直接傳值能夠獲得更好的性能
- 根據代碼具體分析,盡量減少逃逸代碼,減輕GC壓力,提高性能