寫過C/C++的同學(xué)都知道旺罢,調(diào)用著名的malloc和new函數(shù)可以在堆上分配一塊內(nèi)存旷余,這塊內(nèi)存的使用和銷毀的責(zé)任都在程序員。一不小心扁达,就會(huì)發(fā)生內(nèi)存泄露正卧,搞得膽戰(zhàn)心驚。
切換到Golang后跪解,基本不會(huì)擔(dān)心內(nèi)存泄露了炉旷。雖然也有new函數(shù),但是使用new函數(shù)得到的內(nèi)存不一定就在堆上叉讥。堆和棧的區(qū)別對(duì)程序員“模糊化”了窘行,當(dāng)然這一切都是Go編譯器在背后幫我們完成的。
一個(gè)變量是在堆上分配图仓,還是在棧上分配罐盔,是經(jīng)過編譯器的逃逸分析
之后得出的結(jié)論。
這篇文章救崔,就將帶領(lǐng)大家一起去探索逃逸分析
——變量到底去哪兒惶看,堆還是棧?
什么是逃逸分析
以前寫C/C++代碼時(shí)六孵,為了提高效率纬黎,常常將pass-by-value
(傳值)“升級(jí)”成pass-by-reference
,企圖避免構(gòu)造函數(shù)的運(yùn)行劫窒,并且直接返回一個(gè)指針本今。
你一定還記得,這里隱藏了一個(gè)很大的坑:在函數(shù)內(nèi)部定義了一個(gè)局部變量,然后返回這個(gè)局部變量的地址(指針)冠息。這些局部變量是在棧上分配的(靜態(tài)內(nèi)存分配)挪凑,一旦函數(shù)執(zhí)行完畢,變量占據(jù)的內(nèi)存會(huì)被銷毀铐达,任何對(duì)這個(gè)返回值作的動(dòng)作(如解引用)岖赋,都將擾亂程序的運(yùn)行,甚至導(dǎo)致程序直接崩潰瓮孙。比如下面的這段代碼:
int *foo ( void )
{
int t = 3;
return &t;
}
有些同學(xué)可能知道上面這個(gè)坑,用了個(gè)更聰明的做法:在函數(shù)內(nèi)部使用new函數(shù)構(gòu)造一個(gè)變量(動(dòng)態(tài)內(nèi)存分配)选脊,然后返回此變量的地址杭抠。因?yàn)樽兞渴窃诙焉蟿?chuàng)建的,所以函數(shù)退出時(shí)不會(huì)被銷毀恳啥。但是偏灿,這樣就行了嗎?new出來的對(duì)象該在何時(shí)何地delete呢钝的?調(diào)用者可能會(huì)忘記delete或者直接拿返回值傳給其他函數(shù)翁垂,之后就再也不能delete它了,也就是發(fā)生了內(nèi)存泄露硝桩。關(guān)于這個(gè)坑沿猜,大家可以去看看《Effective C++》條款21,講得非常好碗脊!
C++是公認(rèn)的語法最復(fù)雜的語言啼肩,據(jù)說沒有人可以完全掌握C++的語法。而這一切在Go語言中就大不相同了衙伶。像上面示例的C++代碼放到Go里祈坠,沒有任何問題。
你表面的光鮮矢劲,一定是背后有很多人為你撐起的赦拘!Go語言里就是編譯器的逃逸分析
。它是編譯器執(zhí)行靜態(tài)代碼分析后芬沉,對(duì)內(nèi)存管理進(jìn)行的優(yōu)化和簡(jiǎn)化躺同。
在編譯原理中,分析指針動(dòng)態(tài)范圍的方法稱之為逃逸分析
花嘶。通俗來講笋籽,當(dāng)一個(gè)對(duì)象的指針被多個(gè)方法或線程引用時(shí),我們稱這個(gè)指針發(fā)生了逃逸椭员。
更簡(jiǎn)單來說车海,逃逸分析
決定一個(gè)變量是分配在堆上還是分配在棧上。
為什么要逃逸分析
前面講的C/C++中出現(xiàn)的問題,在Go中作為一個(gè)語言特性被大力推崇侍芝。真是C/C++之砒霜Go之蜜糖研铆!
C/C++中動(dòng)態(tài)分配的內(nèi)存需要我們手動(dòng)釋放,導(dǎo)致猿們平時(shí)在寫程序時(shí)州叠,如履薄冰棵红。這樣做有他的好處:程序員可以完全掌控內(nèi)存。但是缺點(diǎn)也是很多的:經(jīng)常出現(xiàn)忘記釋放內(nèi)存咧栗,導(dǎo)致內(nèi)存泄露逆甜。所以,很多現(xiàn)代語言都加上了垃圾回收機(jī)制。
Go的垃圾回收,讓堆和棧對(duì)程序員保持透明忌锯。真正解放了程序員的雙手箍鼓,讓他們可以專注于業(yè)務(wù),“高效”地完成代碼編寫。把那些內(nèi)存管理的復(fù)雜機(jī)制交給編譯器,而程序員可以去享受生活。
逃逸分析
這種“騷操作”把變量合理地分配到它該去的地方御毅,“找準(zhǔn)自己的位置”。即使你是用new申請(qǐng)到的內(nèi)存怜珍,如果我發(fā)現(xiàn)你竟然在退出函數(shù)后沒有用了端蛆,那么就把你丟到棧上,畢竟棧上的內(nèi)存分配比堆上快很多绘面;反之欺税,即使你表面上只是一個(gè)普通的變量,但是經(jīng)過逃逸分析后發(fā)現(xiàn)在退出函數(shù)之后還有其他地方在引用揭璃,那我就把你分配到堆上晚凿。真正地做到“按需分配”,提前實(shí)現(xiàn)共產(chǎn)主義瘦馍!
如果變量都分配到堆上歼秽,堆不像棧可以自動(dòng)清理情组。它會(huì)引起Go頻繁地進(jìn)行垃圾回收燥筷,而垃圾回收會(huì)占用比較大的系統(tǒng)開銷(占用CPU容量的25%)。
堆和棧相比院崇,堆適合不可預(yù)知大小的內(nèi)存分配肆氓。但是為此付出的代價(jià)是分配速度較慢,而且會(huì)形成內(nèi)存碎片底瓣。棧內(nèi)存分配則會(huì)非承痪荆快。棧分配內(nèi)存只需要兩個(gè)CPU指令:“PUSH”和“RELEASSE”,分配和釋放拨扶;而堆分配內(nèi)存首先需要去找到一塊大小合適的內(nèi)存塊凳鬓,之后要通過垃圾回收才能釋放。
通過逃逸分析患民,可以盡量把那些不需要分配到堆上的變量直接分配到棧上缩举,堆上的變量少了,會(huì)減輕分配堆內(nèi)存的開銷匹颤,同時(shí)也會(huì)減少gc的壓力仅孩,提高程序的運(yùn)行速度。
逃逸分析是怎么完成的
Go逃逸分析最基本的原則是:如果一個(gè)函數(shù)返回對(duì)一個(gè)變量的引用印蓖,那么它就會(huì)發(fā)生逃逸杠氢。
簡(jiǎn)單來說,編譯器會(huì)分析代碼的特征和代碼生命周期另伍,Go中的變量只有在編譯器可以證明在函數(shù)返回后不會(huì)再被引用的,才分配到棧上绞旅,其他情況下都是分配到堆上摆尝。
Go語言里沒有一個(gè)關(guān)鍵字或者函數(shù)可以直接讓變量被編譯器分配到堆上,相反因悲,編譯器通過分析代碼來決定將變量分配到何處堕汞。
對(duì)一個(gè)變量取地址,可能會(huì)被分配到堆上晃琳。但是編譯器進(jìn)行逃逸分析后讯检,如果考察到在函數(shù)返回后,此變量不會(huì)被引用卫旱,那么還是會(huì)被分配到棧上人灼。套個(gè)取址符,就想騙補(bǔ)助顾翼?Too young投放!
簡(jiǎn)單來說,編譯器會(huì)根據(jù)變量是否被外部引用來決定是否逃逸:
- 如果函數(shù)外部沒有引用适贸,則優(yōu)先放到棧中灸芳;
- 如果函數(shù)外部存在引用,則必定放到堆中拜姿;
針對(duì)第一條烙样,可能放到堆上的情形:定義了一個(gè)很大的數(shù)組,需要申請(qǐng)的內(nèi)存過大蕊肥,超過了棧的存儲(chǔ)能力谒获。
逃逸分析實(shí)例
Go提供了相關(guān)的命令,可以查看變量是否發(fā)生逃逸。
還是用上面我們提到的例子:
package main
import "fmt"
func foo() *int {
t := 3
return &t;
}
func main() {
x := foo()
fmt.Println(*x)
}
foo函數(shù)返回一個(gè)局部變量的指針究反,main函數(shù)里變量x接收它寻定。執(zhí)行如下命令:
go build -gcflags '-m -l' main.go
加-l
是為了不讓foo函數(shù)被內(nèi)聯(lián)。得到如下輸出:
# command-line-arguments
src/main.go:7:9: &t escapes to heap
src/main.go:6:7: moved to heap: t
src/main.go:12:14: *x escapes to heap
src/main.go:12:13: main ... argument does not escape
foo函數(shù)里的變量t
逃逸了精耐,和我們預(yù)想的一致狼速。讓我們不解的是為什么main函數(shù)里的x
也逃逸了?這是因?yàn)橛行┖瘮?shù)參數(shù)為interface類型卦停,比如fmt.Println(a ...interface{})向胡,編譯期間很難確定其參數(shù)的具體類型,也會(huì)發(fā)生逃逸惊完。
使用反匯編命令也可以看出變量是否發(fā)生逃逸僵芹。
go tool compile -S main.go
截取部分結(jié)果,圖中標(biāo)記出來的說明t
是在堆上分配內(nèi)存小槐,發(fā)生了逃逸拇派。
總結(jié)
堆上動(dòng)態(tài)分配內(nèi)存比棧上靜態(tài)分配內(nèi)存,開銷大很多凿跳。
變量分配在棧上需要能在編譯期確定它的作用域件豌,否則會(huì)分配到堆上。
Go編譯器會(huì)在編譯期對(duì)考察變量的作用域控嗜,并作一系列檢查茧彤,如果它的作用域在運(yùn)行期間對(duì)編譯器一直是可知的,那么就會(huì)分配到棧上疆栏。
簡(jiǎn)單來說曾掂,編譯器會(huì)根據(jù)變量是否被外部引用來決定是否逃逸。對(duì)于Go程序員來說壁顶,編譯器的這些逃逸分析規(guī)則不需要掌握珠洗,我們只需通過go build -gcflags '-m'
命令來觀察變量逃逸情況就行了。
不要盲目使用變量的指針作為函數(shù)參數(shù)博助,雖然它會(huì)減少?gòu)?fù)制操作险污。但其實(shí)當(dāng)參數(shù)為變量自身的時(shí)候,復(fù)制是在棧上完成的操作富岳,開銷遠(yuǎn)比變量逃逸后動(dòng)態(tài)地在堆上分配內(nèi)存少的多蛔糯。
最后,盡量寫出少一些逃逸的代碼窖式,提升程序的運(yùn)行效率蚁飒。
參考資料
【逃逸是怎么發(fā)生的?很贊 結(jié)尾有很多參考資料】https://www.do1618.com/archives/1328/go-%E5%86%85%E5%AD%98%E9%80%83%E9%80%B8%E8%AF%A6%E7%BB%86%E5%88%86%E6%9E%90/
【Go的變量到底在堆還是棧中分配】https://github.com/developer-learning/night-reading-go/blob/master/content/discuss/2018-07-09-make-new-in-go.md
【Golang堆棧的理解】https://segmentfault.com/a/1190000017498101
【逃逸分析 編寫棧分配內(nèi)存建議】https://segment.com/blog/allocation-efficiency-in-high-performance-go-services/
【逃逸分析 比較簡(jiǎn)潔】https://studygolang.com/articles/17584
【逃逸分析定義】https://cloud.tencent.com/developer/article/1117410
【逃逸分析例子】https://my.oschina.net/renhc/blog/2222104
https://gocn.vip/article/355
【匯編代碼 傳參】https://github.com/maniafish/about_go/blob/master/heap_stack.md
【逃逸分析的缺陷】https://studygolang.com/articles/12396
【比較好的逃逸分析的例子】http://www.agardner.me/golang/garbage/collection/gc/escape/analysis/2015/10/18/go-escape-analysis.html