Go垃圾回收系列之五:棧與棧對象
棧掃描
在進行根對象掃描的過程中,棧掃描是其中最重要的部分。因為在一個程序中,可能會有成千上萬的協(xié)程棧。棧掃描需要編譯時與運行時的共同努力,運行時能夠計算出當前協(xié)程棧的所有棧幀信息,而編譯時能夠得知棧上哪些地方有指針,以及對象中的哪一個部分包含了指針。
每一個函數(shù)在執(zhí)行過程中都使用一塊棧內(nèi)存用來保存返回地址、局部變量、函數(shù)參數(shù)等,我們將這一塊區(qū)域稱為某函數(shù)的棧幀(stack frame)。
當發(fā)生函數(shù)調(diào)用時,因為調(diào)用者還沒有執(zhí)行完,其棧內(nèi)存中保存的數(shù)據(jù)還有用,所以被調(diào)用函數(shù)不能覆蓋調(diào)用者的棧幀,只能把被調(diào)用函數(shù)的棧幀壓棧,等被調(diào)函數(shù)執(zhí)行完成后再把其棧幀出棧。這樣,棧的大小就會隨函數(shù)調(diào)用層級的增加而生長,隨函數(shù)的返回而縮小,也就是說函數(shù)調(diào)用層級越深,消耗的棧空間就越大。
因為數(shù)據(jù)是以先進先出的方式添加和刪除的,所以基于堆棧的內(nèi)存分配非常簡單,并且通常比基于堆的動態(tài)內(nèi)存分配內(nèi)存快得多。另外,當函數(shù)退出時,堆棧上的內(nèi)存會自動高效地回收,這是垃圾回收一種最初的形式。雖然維護和管理函數(shù)的棧幀非常重要,但是通常對于高級編程語言來說是隱藏的。例如Go語言中借助于編譯器,在開發(fā)中不用關心局部變量在棧中的布局與釋放。許多計算機指令集在硬件級別提供了用于管理棧的特殊指令,例如80x86指令集提供的SP寄存器用于管理棧,
以A函數(shù)調(diào)用B函數(shù)為例,抽象的函數(shù)棧的結(jié)構如下所示。

Go語言中實際的棧幀布局如下所示(源代碼中的注釋):

運行時可以計算出當前棧幀的函數(shù)參數(shù)、函數(shù)本地變量、寄存器信息SP、BP等一系列信息。
對每一個棧幀函數(shù)中的參數(shù)和局部變量,都需要對其進行掃描,掃描該對象是否仍然在使用。如果在使用,需要掃描bytedata位圖判斷對象中是否包含指針,如果包含指針則需要進行標記。其中函數(shù)執(zhí)行到某一位置時,與某個參數(shù)和局部變量對應的位圖bytedata是借助于編譯時計算出來的。
func scanframeworker(frame *stkframe, state *stackScanState, gcw *gcWork) {
// 掃描局部變量
if locals.n > 0 {
size := uintptr(locals.n) * sys.PtrSize
scanblock(frame.varp-size, size, locals.bytedata, gcw, state)
}
// 掃描函數(shù)參數(shù)
if args.n > 0 {
scanblock(frame.argp, uintptr(args.n)*sys.PtrSize, args.bytedata, gcw, state)
}
}什么情況下對象可能沒有在使用了呢?例如如下所示,當foo()函數(shù)執(zhí)行到調(diào)用bar() 函數(shù)時,局部對象t就已經(jīng)沒有被使用了,所以即便對象t中有指針,位圖bytedata中全為0,代表參數(shù)不再被使用。一個不再被使用的對象,可以被回收,不需要再進行掃描。
func foo(){
t := T{}
t.a = 2
bar()
}棧對象(stack object)
在Go語言早期就是通過上述方式對協(xié)程棧中的對象進行掃描的。但是這種方法在有些情況下會出現(xiàn)問題,例如在如下函數(shù)中, 對象t首先被p所引用,但是在之后的程序中,變量p的值發(fā)生了變化,這意味著,t其實并沒有使用了。但是編譯器由于難以知道P在何時會重新賦值導致t不再被引用,因此,編譯器會采取保守的策略認為t對象仍然存在,從而,如果對象t中有指針指向了堆內(nèi)存,就造成了內(nèi)存泄露問題。因為這部分內(nèi)存本應該被釋放。
t := T{...}
p := &t
for {
if … {
p = …
}
}為了解決內(nèi)存泄露的問題,Go語言引進了 棧對象(stack object) 的概念。棧對象是在棧上能夠被尋址的對象。例如上例中的t,由于其能夠被&t的形式尋址,其一定在棧上有地址。所以t就被叫做 棧對象。因為并不是所有的變量都會存儲在棧上,例如存儲在寄存器中的變量就是不能被尋址的。
首先,編譯器會在編譯時將所有的棧對象記錄下來,在垃圾回收期間,所有的棧對象會存儲到一顆二叉搜索樹中。接著,第二步將棧中所有可能指向棧對象的指針都進行追蹤。
如下所示,假設F為一個局部變量指針,其引用了棧幀上的棧對象E→C→D→A, 因此說明棧對象E、C、D、A都是存活的,需要被掃描。 相反如果棧對象B沒有被掃描,并且接下來在foo()函數(shù)中沒有使用到B對象,那么B棧對象不會被掃描,從而解決了內(nèi)存泄露問題。

推薦閱讀
