面試官:小松子來聊一聊內(nèi)存逃逸
本文轉(zhuǎn)載自微信公眾號「Golang夢工廠」,作者AsongGo。轉(zhuǎn)載本文請聯(lián)系Golang夢工廠公眾號。
前言
哈嘍,大家好,我是asong。最近無聊看了一下Go語言的面試八股文,發(fā)現(xiàn)面試官都喜歡問內(nèi)存逃逸這個話題,這個激起了我的興趣,我對內(nèi)存逃逸的了解很淺,所以找了很多文章精讀了一下,在這里做一個總結(jié),方便日后查閱、學(xué)習(xí)。
什么是內(nèi)存逃逸
初次看到這個話題,我是懵逼的,怎么還有內(nèi)存逃逸,內(nèi)存逃逸到底是干什么的?接下來我們一起來看看什么是內(nèi)存逃逸。
我們都知道一般情況下程序存放在rom或者Flash中,運行時需要拷貝到內(nèi)存中執(zhí)行,內(nèi)存會分別存儲不同的信息,內(nèi)存空間包含兩個最重要的區(qū)域:堆區(qū)(Stack)和棧區(qū)(Heap),對于我這種C語言出身的人,對堆內(nèi)存和棧內(nèi)存的了解還是挺深的。在C語言中,棧區(qū)域會專門存放函數(shù)的參數(shù)、局部變量等,棧的地址從內(nèi)存高地址往低地址增長,而堆內(nèi)存正好相反,堆地址從內(nèi)存低地址往高地址增長,但是如果我們想在堆區(qū)域分配內(nèi)存需要我們手動調(diào)用malloc函數(shù)去堆區(qū)域申請內(nèi)存分配,然后我使用完了還需要自己手動釋放,如果沒有釋放就會導(dǎo)致內(nèi)存泄漏。寫過C語言的朋友應(yīng)該都知道C語言函數(shù)是不能返回局部變量地址(特指存放于棧區(qū)的局部變量地址),除非是局部靜態(tài)變量地址,字符串常量地址、動態(tài)分配地址。其原因是一般局部變量的作用域只在函數(shù)內(nèi),其存儲位置在棧區(qū)中,當(dāng)程序調(diào)用完函數(shù)后,局部變量會隨此函數(shù)一起被釋放。其地址指向的內(nèi)容不明(原先的數(shù)值可能不變,也可能改變)。而局部靜態(tài)變量地址和字符串常量地址存放在數(shù)據(jù)區(qū),動態(tài)分配地址存放在堆區(qū),函數(shù)運行結(jié)束后只會釋放棧區(qū)的內(nèi)容,而不會改變數(shù)據(jù)區(qū)和堆區(qū)。
所以在C語言中我們想在一個函數(shù)中返回局部變量地址時,有三個正確的方式:返回靜態(tài)局部變量地址、返回字符串常量地址,返回動態(tài)分配在堆上的地址,因為他們都不在棧區(qū),即使釋放函數(shù),其內(nèi)容也不會受影響,我們以在返回堆上內(nèi)存地址為例看一段代碼:
- #include "stdio.h"
- #include "stdlib.h"
- //返回動態(tài)分配的地址
- 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語言中動態(tài)內(nèi)存的分配與釋放完全交與程序員的手中,這樣就會導(dǎo)致我們在寫程序時如履薄冰,好處是我們可以完全掌控內(nèi)存,缺點是我們一不小心就會導(dǎo)致內(nèi)存泄漏,所以很多現(xiàn)代語言都有GC機制,Go就是一門帶垃圾回收的語言,真正解放了我們程序員的雙手,我們不需要在像寫C語言那樣考慮是否能返回局部變量地址了,內(nèi)存管理交與給編譯器,編譯器會經(jīng)過逃逸分析把變量合理的分配到"正確"的地方。
說到這里,可以簡單總結(jié)一下什么是內(nèi)存逃逸了:
在一段程序中,每一個函數(shù)都會有自己的內(nèi)存區(qū)域存放自己的局部變量、返回地址等,這些內(nèi)存會由編譯器在棧中進行分配,每一個函數(shù)都會分配一個棧楨,在函數(shù)運行結(jié)束后進行銷毀,但是有些變量我們想在函數(shù)運行結(jié)束后仍然使用它,那么就需要把這個變量在堆上分配,這種從"棧"上逃逸到"堆"上的現(xiàn)象就成為內(nèi)存逃逸。
什么是逃逸分析
上面我們知道了什么是內(nèi)存逃逸,下面我們就來看一看什么是逃逸分析?
上文我們說到C語言使用malloc在堆上動態(tài)分配內(nèi)存后,還需要手動調(diào)用free釋放內(nèi)存,如果不釋放就會造成內(nèi)存泄漏的風(fēng)險。在Go語言中堆內(nèi)存的分配與釋放完全不需要我們?nèi)ス芰耍珿o語言引入了GC機制,GC機制會對位于堆上的對象進行自動管理,當(dāng)某個對象不可達時(即沒有其對象引用它時),他將會被回收并被重用。雖然引入GC可以讓開發(fā)人員降低對內(nèi)存管理的心智負(fù)擔(dān),但是GC也會給程序帶來性能損耗,當(dāng)堆內(nèi)存中有大量待掃描的堆內(nèi)存對象時,將會給GC帶來過大的壓力,雖然Go語言使用的是標(biāo)記清除算法,并且在此基礎(chǔ)上使用了三色標(biāo)記法和寫屏障技術(shù),提高了效率,但是如果我們的程序仍在堆上分配了大量內(nèi)存,依賴會對GC造成不可忽視的壓力。因此為了減少GC造成的壓力,Go語言引入了逃逸分析,也就是想法設(shè)法盡量減少在堆上的內(nèi)存分配,可以在棧中分配的變量盡量留在棧中。
小結(jié)逃逸分析:
逃逸分析就是指程序在編譯階段根據(jù)代碼中的數(shù)據(jù)流,對代碼中哪些變量需要在棧中分配,哪些變量需要在堆上分配進行靜態(tài)分析的方法。堆和棧相比,堆適合不可預(yù)知大小的內(nèi)存分配。但是為此付出的代價是分配速度較慢,而且會形成內(nèi)存碎片。棧內(nèi)存分配則會非常快。棧分配內(nèi)存只需要兩個CPU指令:“PUSH”和“RELEASE”,分配和釋放;而堆分配內(nèi)存首先需要去找到一塊大小合適的內(nèi)存塊,之后要通過垃圾回收才能釋放。所以逃逸分析更做到更好內(nèi)存分配,提高程序的運行速度。
Go語言中的逃逸分析
Go語言的逃逸分析總共實現(xiàn)了兩個版本:
- 1.13版本前是第一版
- 1.13版本后是第二版
粗略看了一下逃逸分析的代碼,大概有1500+行(go1.15.7)。代碼我倒是沒仔細(xì)看,注釋我倒是仔細(xì)看了一遍,注釋寫的還是很詳細(xì)的,代碼路徑: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:指向棧對象的指針不能超過該對象的存活期,也就說指針不能在棧對象被銷毀后依舊存活。(例子:聲明的函數(shù)返回并銷毀了對象的棧幀,或者它在循環(huán)迭代中被重復(fù)用于邏輯上不同的變量)
我們大概知道它的分析準(zhǔn)則是什么就好了,具體逃逸分析是怎么做的,感興趣的同學(xué)可以根據(jù)源碼自行研究。
既然逃逸分析是在編譯階段進行的,那我們就可以通過go build -gcflags '-m -m -l'命令查看到逃逸分析的結(jié)果,我們之前在分析內(nèi)聯(lián)優(yōu)化時使用的-gcflags '-m -m',能看到所有的編譯器優(yōu)化,這里使用-l禁用掉內(nèi)聯(lián)優(yōu)化,只關(guān)注逃逸優(yōu)化就好了。
現(xiàn)在我們也知道了逃逸分析,接下來我們就看幾個逃逸分析的例子。
幾個逃逸分析的例子
1. 函數(shù)返回局部指針變量
先看例子:
- #include "stdio.h"
- #include "stdlib.h"
- //返回動態(tài)分配的地址
- 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;
- }
查看逃逸分析結(jié)果:
- 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
分析結(jié)果很明了,函數(shù)返回的局部變量是一個指針變量,當(dāng)函數(shù)Add執(zhí)行結(jié)束后,對應(yīng)的棧楨就會被銷毀,但是引用已經(jīng)返回到函數(shù)之外,如果我們在外部解引用地址,就會導(dǎo)致程序訪問非法內(nèi)存,就像上面的C語言的例子一樣,所以編譯器經(jīng)過逃逸分析后將其在堆上分配內(nèi)存。
2. interface類型逃逸
先看一個例子:
- func main() {
- str := "asong太帥了吧"
- fmt.Printf("%v",str)
- }
查看逃逸分析結(jié)果:
- 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函數(shù)中的一個局部變量,傳遞給fmt.Println()函數(shù)后發(fā)生了逃逸,這是因為fmt.Println()函數(shù)的入?yún)⑹且粋€interface{}類型,如果函數(shù)參數(shù)為interface{},那么在編譯期間就很難確定其參數(shù)的具體類型,也會發(fā)送逃逸。
觀察這個分析結(jié)果,我們可以看到?jīng)]有moved to heap: str,這也就是說明str變量并沒有在堆上進行分配,只是它存儲的值逃逸到堆上了,也就說任何被str引用的對象必須分配在堆上。如果我們把代碼改成這樣:
- func main() {
- str := "asong太帥了吧"
- fmt.Printf("%p",&str)
- }
查看逃逸分析結(jié)果:
- 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也逃逸到了堆上,在堆上進行內(nèi)存分配,這是因為我們訪問str的地址,因為入?yún)⑹莍nterface類型,所以變量str的地址以實參的形式傳入fmt.Printf后被裝箱到一個interface{}形參變量中,裝箱的形參變量的值要在堆上分配,但是還要存儲一個棧上的地址,也就是str的地址,堆上的對象不能存儲一個棧上的地址,所以str也逃逸到堆上,在堆上分配內(nèi)存。(這里注意一個知識點:Go語言的參數(shù)傳遞只有值傳遞)
3. 閉包產(chǎn)生的逃逸
- func Increase() func() int {
- n := 0
- return func() int {
- n++
- return n
- }
- }
- func main() {
- in := Increase()
- fmt.Println(in()) // 1
- }
查看逃逸分析結(jié)果:
- 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
因為函數(shù)也是一個指針類型,所以匿名函數(shù)當(dāng)作返回值時也發(fā)生了逃逸,在匿名函數(shù)中使用外部變量n,這個變量n會一直存在直到in被銷毀,所以n變量逃逸到了堆上。
4. 變量大小不確定及棧空間不足引發(fā)逃逸
我們先使用ulimit -a查看操作系統(tǒng)的??臻g:
- 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,所以根據(jù)這個我們寫一個測試用例:
- 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()
- }
查看逃逸分析結(jié)果:
- 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
我們可以看到,當(dāng)??臻g足夠時,不會發(fā)生逃逸,但是當(dāng)變量過大時,已經(jīng)完全超過??臻g的大小時,將會發(fā)生逃逸到堆上分配內(nèi)存。
同樣當(dāng)我們初始化切片時,沒有直接指定大小,而是填入的變量,這種情況為了保證內(nèi)存的安全,編譯器也會觸發(fā)逃逸,在堆上進行分配內(nèi)存。
參考文章(建議大家閱讀一遍)
- 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
總結(jié)
本文到這里結(jié)束了,這篇文章我們一起分析了什么是內(nèi)存逃逸以及Go語言中的逃逸分析,上面只列舉了幾個例子,因為發(fā)生的逃逸的情況是列舉不全的,我們只需要了解什么是逃逸分析,了解逃逸的策略就可以了,后面在實戰(zhàn)中可以根據(jù)具體代碼具體分析,寫出更優(yōu)質(zhì)的代碼。
最后對逃逸做一個總結(jié):
- 逃逸分析在編譯階段確定哪些變量可以分配在棧中,哪些變量分配在堆上
- 逃逸分析減輕了GC壓力,提高程序的運行速度
- 棧上內(nèi)存使用完畢不需要GC處理,堆上內(nèi)存使用完畢會交給GC處理
- 函數(shù)傳參時對于需要修改原對象值,或占用內(nèi)存比較大的結(jié)構(gòu)體,選擇傳指針。對于只讀的占用內(nèi)存較小的結(jié)構(gòu)體,直接傳值能夠獲得更好的性能
- 根據(jù)代碼具體分析,盡量減少逃逸代碼,減輕GC壓力,提高性能