逃逸分析是什么????????
??逃逸分析是編譯器用于決定變量分配到堆上還是棧上的一種行為。
????????一個變量是在堆上分配,還是在棧上分配,是經過編譯器的逃逸分析之后得出的“結論”。
Go 語言里編譯器的逃逸分析:它是編譯器執行靜態代碼分析后,對內存管理進行的優化和簡化。
在編譯原理中,分析指針動態范圍的方法被稱之為逃逸分析。通俗來講,當一個對象的指針被多個方法或線程引用時,則稱這個指針發生了逃逸。逃逸分析決定一個變量是分配在堆上還是分配在棧上。
逃逸分析有什么作用
????????函數的運行都是在棧上面運行的,在棧上面聲明臨時變量,分配內存,函數運行完畢之后,回收內存,每個函數的棧空間都是獨立的,其他函數是無法進行訪問,但是在某些情況下棧上面的數據需要在函數結束之后還能被訪問,這時候就會設計到內存逃逸了,什么是逃逸,就是抓不住
????????如果變量從棧上面逃逸,會跑到堆上面,棧上面的變量在函數結束的時候回自動回收,回收代價比較小,棧的內存分配和使用一般只需要兩個CPU指令"PUSH"和"RELEASE",分配和釋放,而堆分配內存,則是首先需要找到一塊大小合適的內存,之后通過GC回收才能釋放,對于這種情況,頻繁的使用垃圾回收,則會占用比較大的系統開銷,所以盡量分配內存到棧上面,減少gc的壓力,提高程序運行速度
????????Go 的垃圾回收,讓堆和棧對程序員保持透明。真正解放了程序員的雙手,讓他們可以專注于
業務,“高效”地完成代碼編寫,而把那些內存管理的復雜機制交給編譯器。
逃逸分析把變量合理地分配到它該去的地方,“找準自己的位置”。即使是用 new 函數申請到的內存,如果編譯器發現這塊內存在退出函數后就沒有使用了,那就分配到棧上,畢竟棧上的內存
分配比堆上快很多;反之,即使表面上只是一個普通的變量,但是經過編譯器的逃逸分析后發現,
在函數之外還有其他的地方在引用,那就分配到堆上。真正地做到“按需分配”。
如果變量都分配到堆上,堆不像棧可以自動清理。就會引起 Go 頻繁地進行垃圾回收,而垃圾
回收會占用比較大的系統開銷。
堆和棧相比,堆適合不可預知大小的內存分配。但是為此付出的代價是分配速度較慢,而且會形成內存碎片;棧內存分配則會非常快。棧分配內存只需要通過 PUSH 指令,并且會被自動釋放;而堆分配內存首先需要去找到一個大小合適的內存塊,之后要通過垃圾回收才能釋放。
通過逃逸分析,可以盡量把那些不需要分配到堆上的變量直接分配到棧上,堆上的變量少了,會減輕堆內存分配的開銷,同時也會減少垃圾回收(Garbage Collection,GC)的壓力,提高程序的運行速度。
逃逸分析過程/是怎么完成的
????????Go 語言逃逸分析最基本的原則是:如果一個函數返回對一個變量的引用,那么這個變量就會發生逃逸。
在任何情況下,如果一個值被分配到了棧之外的地方,那么一定是到了堆上面。簡而概之:編譯器會分析代碼的特征和代碼的生命周期,Go 中的變量只有在編譯器可以證明在函數返回后不會再被引用的,才分配到棧上,其他情況下都是分配到堆上。
Go 語言里沒有一個關鍵字或者函數可以直接讓變量被編譯器分配到堆上。相反,編譯器通過
分析代碼來決定將變量分配到何處。
對一個變量取地址,可能會被分配到堆上。但是編譯器進行逃逸分析后,如果考慮到在函數返回后,此變量不會被引用,那么還是會被分配到棧上。簡單來說,編譯器會根據變量是否被外部引用來決定是否逃逸:
1)如果變量在函數外部沒有引用,則優先放到棧上。
2)如果變量在函數外部存在引用,則必定放到堆上。
針對第一條,放到堆上的情形:定義了一個很大的數組,需要申請的內存過大,超過了棧的存儲能力。
如何確定是否發生逃逸?
Go 提供了相關的命令,可以查看變量是否發生逃逸。使用前面提到的例子:
package main
import "fmt"
func foo() *int {t := 3return &t;
}
func main() {x := foo()fmt.Println(*x)
}
????????foo 函數返回一個局部變量的指針,使用 main 函數里變量 x 接收它。執行如下命令:
go build -gcflags '-m -l' main.go
????????其中 -gcflags 參數用于啟用編譯器支持的額外標志。例如,-m 用于輸出編譯器的優化細節
(包括使用逃逸分析這種優化),相反可以使用 -N 來關閉編譯器優化;而 -l 則用于禁用 foo 函數
的內聯優化,防止逃逸被編譯器通過內聯徹底的抹除。得到如下輸出:?
### 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 函數里的變量 t 逃逸了,和預想的一致,不解的是為什么 main 函數里的 x 也逃逸了?
這是因為有些函數的參數為 interface 類型,比如 fmt.Println(a ...interface{}),編譯期間很難確定其
參數的具體類型,也會發生逃逸。?
指針逃逸
????????我們知道傳遞指針可以減少底層值的拷貝,可以提高效率,但是如果拷貝的數據量小,由于指針傳遞會產生逃逸,可能會使用堆,也可能會增加GC的負擔,所以傳遞指針不一定是高效的。
????????如下實例:
package maintype Student struct {Name stringAge int
}func StudentRegister(name string, age int) *Student {s := new(Student) //局部變量s逃逸到堆s.Name = names.Age = agereturn s
}func main() {StudentRegister("Jim", 18)
}
????????雖然在函數 StudentRegister() 內部 s 為局部變量,其值通過函數返回值返回,s 本身為一指針,其指向的內存地址不會是棧而是堆,這就是典型的逃逸案例。?
棧空間不足
package mainfunc MakeSlice() {s := make([]int, 100, 100)for index, _ := range s {s[index] = index}
}func main() {MakeSlice()
}
此時棧空間充足,slice分配在棧上,未發生逃逸,假設將slice擴大100倍,再看一下
package mainfunc MakeSlice() {s := make([]int, 10000, 10000)for index, _ := range s {s[index] = index}
}func main() {MakeSlice()
}
此時,分配的slice容量太大,當棧空間不足以存放當前對象時或無法判斷當前切片長度時會將對象分配到堆中
動態類型逃逸?
很多函數參數為interface類型。比如:
func Printf(format string, a ...interface{}) (n int, err error)
func Sprintf(format string, a ...interface{}) string
func Fprint(w io.Writer, a ...interface{}) (n int, err error)
func Print(a ...interface{}) (n int, err error)
func Println(a ...interface{}) (n int, err error)
編譯期間很難確定其參數的具體類型,也能產生逃逸。
變量大小不確定?
????????在創建切片的時候,初始化切片容量的時候,傳入一個變量來指定其大小,由于變量的值不能在編譯器確定,所以就不能確定其占用空間的大小,直接將對象分配在堆上
package mainfunc MakeSlice() {length := 1a := make([]int, length, length)for i := 0; i < length; i++ {a[i] = i}
}func main() {MakeSlice()
}
逃逸常見情況
指針逃逸,函數內部返回一個局部變量指針
分配大對象,導致棧空間不足,不得不分配到堆上
調用接口類型的方法。接口類型的方法調用是動態調度 - 實際使用的具體實現只能在運行時確定。考慮一個接口類型為 io.Reader 的變量 r。對 r.Read(b) 的調用將導致 r 的值和字節片b的后續轉義并因此分配到堆上。
盡管能夠符合分配到棧的場景,但是其大小不能夠在在編譯時候確定的情況,也會分配到堆上
如何避免
go 中的接口類型的方法調用是動態調度,因此不能夠在編譯階段確定,所有類型結構轉換成接口的過程會涉及到內存逃逸的情況發生。如果對于性能要求比較高且訪問頻次比較高的函數調用,應該盡量避免使用接口類型
由于切片一般都是使用在函數傳遞的場景下,而且切片在 append 的時候可能會涉及到重新分配內存,如果切片在編譯期間的大小不能夠確認或者大小超出棧的限制,多數情況下都會分配到堆上
總結
堆上動態分配內存比棧上靜態分配內存,開銷大很多。
變量分配在棧上需要能在編譯期確定它的作用域,否則會分配到堆上。
Go編譯器會在編譯期對考察變量的作用域,并作一系列檢查,如果它的作用域在運行期間對編譯器一直是可知的,那么就會分配到棧上。簡單來說,編譯器會根據變量是否被外部引用來決定是否逃逸。
對于Go程序員來說,編譯器的這些逃逸分析規則不需要掌握,我們只需通過go build -gcflags '-m'命令來觀察變量逃逸情況就行了
不要盲目使用變量的指針作為函數參數,雖然它會減少復制操作。但其實當參數為變量自身的時候,復制是在棧上完成的操作,開銷遠比變量逃逸后動態地在堆上分配內存少的多。
下面代碼中的變量發生逃逸了嗎?
示例1:?
package main
type S struct {}func main() {var x S_ = identity(x)
}func identity(x S) S {return x
}
分析:Go語言函數傳遞都是通過值的,調用函數的時候,直接在棧上copy出一份參數,不存在逃逸。?
?示例2:
package maintype S struct {}func main() {var x Sy := &x_ = *identity(y)
}func identity(z *S) *S {return z
}
分析:identity函數的輸入直接當成返回值了,因為沒有對z作引用,所以z沒有逃逸。對x的引用也沒有逃出main函數的作用域,因此x也沒有發生逃逸。
?示例3:
package maintype S struct {}func main() {var x S_ = *ref(x)
}func ref(z S) *S {return &z
}
分析:z是對x的拷貝,ref函數中對z取了引用,所以z不能放在棧上,z必須要逃逸到堆上。否則在ref函數之外,通過引用如何找到z。僅管在main函數中,直接丟棄了ref的結果,但是Go的編譯器還沒有那么智能,分析不出來這種情況。而對x從來就沒有取引用,所以x不會發生逃逸。?
?示例4:如果對一個結構體成員賦引用會如何
package maintype S struct {M *int
}func main() {var i intrefStruct(i)
}func refStruct(y int) (z S) {z.M = &yreturn z
}
分析:refStruct函數對y取了引用,所以y發生了逃逸。?
示例5:
package maintype S struct {M *int
}func main() {var i intrefStruct(&i)
}func refStruct(y *int) (z S) {z.M = yreturn z
}
分析:在main函數里對i取了引用,并且把它傳給了refStruct函數,i的引用一直在main函數的作用域用,因此i沒有發生逃逸。和上一個例子相比,有一點小差別,但是導致的程序效果是不同的:例子4中,i先在main的棧幀中分配,之后又在refStruct棧幀中分配,然后又逃逸到堆上,到堆上分配了一次,共3次分配。本例中,i只分配了一次,然后通過引用傳遞。?
?示例6:
package maintype S struct {M *int
}func main() {var x Svar i intref(&i, &x)
}func ref(y *int, z *S) {z.M = y
}
分析:本例i發生了逃逸,按照前面例子5的分析,i不會逃逸。兩個例子的區別是例子5中的S是在返回值里的,輸入只能“流入”到輸出,本例中的S是在輸入參數中,所以逃逸分析失敗,i要逃逸到堆上。
?Go 與C/C++中的堆和棧是同一個概念嗎
????????在前面的分析中,其實隱式地默認了所提及Go 中堆和棧這些概念與 C/C++ 中堆和棧的概念
是同一種事物。但讀者應該需要進一步認識到這里面的區別。
首先要明確,C/C++ 中提及的“程序堆棧”本質上其實是操作系統層級的概念,它通過
C/C++ 語言的編譯器和所在的系統環境來共同決定。在程序啟動時,操作系統會自動維護一個所啟動程序消耗內存的地址空間,并自動將這個空間從邏輯上劃分為堆內存空間和棧內存空間。這時,“棧”的概念是指程序運行時自動獲得的一小塊內存,而后續的函數調用所消耗的棧大小,會在編譯期間由編譯器決定,用于保存局部變量或者保存函數調用棧。如果在 C/C++ 中聲明一個局部變量,則會執行邏輯上的壓棧操作,在棧中記錄局部變量。而當局部變量離開作用域之后,所謂的自動釋放本質上是該位置的內存在下一次函數調用壓棧的過程中,可以被無條件的覆蓋;對于堆而
言,每當程序通過系統調用向操作系統申請內存時,會將所需的空間從維護的堆內存地址空間中分
配出去,而在歸還時則會將歸還的內存合并到所維護的地址空間中。
Go 程序也是運行在操作系統上的程序,自然同樣擁有前面提及的堆和棧的概念。但區別在于
傳統意義上的“棧”被 Go 語言的運行時全部消耗了,用于維護運行時各個組件之間的協調,例如調度器、垃圾回收、系統調用等。而對于用戶態的 Go 代碼而言,它們所消耗的“堆和棧”,其實只是 Go 運行時通過管理向操作系統申請的堆內存,構造的邏輯上的“堆和棧”,它們的本質都是從操作系統申請而來的堆內存。由于用戶態 Go 程序的“棧空間”是由運行時管理堆內存得來,相較于只有 1MB 的 C/C++ 中的“棧”而言,Go 程序擁有“幾乎”無限的棧內存(1GB)。更進一步,對于用戶態 Go 代碼消耗的棧,Go 語言運行時會為了防止內存碎片化,會在適當的時候對整個棧進行深拷貝,將其整個復制到另一塊內存區域(當然,這個過程對用戶態的代碼是不可見的),這也是相較于傳統意義上棧是一塊固定分配好的內存所出現的另一處差異。也正是由于這個特點的存在,指針的算術運算不再能奏效,因為在沒有特殊說明的情況下,無法確定運算前后指針所指向的地址的內容是否已經被 Go 運行時移動。