1. 程式人生 > >從golang的垃圾回收說起(上篇)

從golang的垃圾回收說起(上篇)

本文來自網易雲社群

1 垃圾回收中的重要概念

1.1 定義

In computer science, garbage collection (GC) is a form of automatic memory management. The garbage collector, or just collector, attempts to reclaim garbage, or memory occupied by objects that are no longer in use by the program. Garbage collection was invented by John McCarthy around 1959 to simplify manual memory management in Lisp. (引用自

維基百科))

1.2 GC效能的評價標準

  • 吞吐量:是指單位時間內是有多少時間是用來執行user application的。GC佔用的時間過多,就會導致吞吐量較低。

  • 最大暫停時間:基本上所有的垃圾回收演算法,都會在執行GC的過程中,暫停user application。如果暫停時間過長,必然會影響使用者體驗,尤其是那些互動性較強的應用。

  • 堆使用效率:影響堆使用效率的主要有兩個因素,一個是物件頭部大小,一個是堆的用法。一般來說,堆的頭部越大,儲存的資訊越多,那麼GC的效率就會越高,吞吐量什麼的也會有更佳的表現。但是,我們必須明白,物件頭越小越好。另外,不同的演算法對於堆的不同用法,也會導致堆使用效率差距非常大。比如複製演算法,使用者應用只能使用一般的堆大小。GC是自動管理記憶體的,如果因為GC導致過量佔用堆,那麼就是本末倒置了。

  • 訪問的區域性性:具有引用關係的物件之間很可能存在連續訪問的情況。因此,把具有引用關係的物件安排在堆中較勁的位置,可以充分利用記憶體訪問區域性性。有的GC演算法會根據引用關係重排物件,比如複製演算法。

  • 等等等等

設計垃圾回收演算法時,折中無處不在。較大的吞吐量和較短的最大暫停時間往往不可兼得。

2 常見的GC演算法

2.1 標記-清除演算法 STW

mark_sweep(){    mark()
    sweep()}

分兩個階段,整個過程需要STW:

  • mark phase:時間複雜度跟活動物件數量成正比

  • sweep phase:時間複雜度跟堆的大小成正比

sweep階段回收的空間會連線到空閒連結串列上,分配空間時從空閒連結串列分配,因此分配空間時間複雜度可能會有點高,即需要遍歷整個空閒連結串列。

優點是實現簡單,並且與保守式GC相容(即沒有移動物件)

缺點也非常明顯:

  • 存在記憶體碎片

  • 分配速度較慢:使用多個空閒連結串列

  • 與寫時複製技術不相容:點陣圖標記法

  • sweep操作時間複雜度同堆大小成正比:延遲清除法

延遲清除法:沒有空閒連結串列。定義一個全域性變數sweeping,從sweeping開始遍歷分配新空間,遍歷的開始位置處於上一次lazy_sweep操作發現的分塊的右邊。如果當前分塊mark=true,則取消標記。如果mark=false並且分開大小大於申請空間,則分配給他。 當遍歷到堆末尾時,需要將sweeping設定為heap_start,並且需要重新標記。

2.2 引用計數法

在標記-清除等GC演算法中,沒有分塊可用時,mutator會呼叫下面的函式啟動GC回收空間,以便進行分配:

garbage_collect(){
    ...
}

然而引用計數法是沒有啟動GC的語句的,它與mutator的執行密切相關,它在mutator的執行過程中通過增減引用計數器的值來實現實時的記憶體管理和垃圾回收。

引用計數器的更新主要有兩個場景:

  • 分配新物件時

  • 更新指標時

如果引用計數值變為0,則會立即連線到空閒連結串列上去。分配空間時,從空閒連結串列分配,分配失敗,則直接失敗返回。

優點是:

  • 最大暫停時間短

  • 可即刻回收垃圾

缺點是:

  • 引用計數值的增減處理繁重,吞吐量低

  • 迴圈引用

  • 計數器佔用很多位

2.3 複製演算法 STW

GC複製演算法將堆空間分成大小相等的兩塊:from空間和to空間。新物件只能從from空間分配。

當from空間沒有可用空間時,則會啟動GC,將from空間的活動物件複製到to空間,同時將from和to的身份互換。因此其時間複雜度,與活動物件的數量成正比,而與堆的大小無關。

複製時,是從根物件遞迴遍歷複製過去的。因此,我們定義了一個free變數記錄下從這個位置分配可用空間,分配空間的時間複雜度為常數。

優點:

  • 因為時間複雜度與活動物件數量成正比,而與堆大小無關。所以,吞吐量優秀。

  • 分配速度快

  • 不會發生碎片化

  • 訪問的區域性性原理

缺點是:

  • 堆的使用效率低下

  • 移動物件,與保守式GC不相容

2.4 標記-壓縮演算法 STW

標記-壓縮演算法時將標記-清除和複製演算法相結合的產物。

標記階段跟標記-清除演算法一樣,從根引用的活動變數開始遍歷。時間複雜度同活動物件的數量成正比。

而壓縮階段則需要遍歷整個堆,按照之前的排列順序壓縮到堆的一側去。

壓縮階段需要遍歷三次堆:

  • 第一次遍歷,需要找出計算出所有的活動物件需要移動到哪個位置去

  • 第二次遍歷,需要重寫根指標,重寫活動物件的指標

  • 第三次遍歷,移動物件到目標位置

優點是堆的利用率很高,沒有碎片。缺點也是灰常的明顯,壓縮的時間複雜度太高,而且與堆的大小成正比。

2.5 分代垃圾回收

分代垃圾回收在物件中引入了“年齡”的概念,通過優先回收容易成為垃圾的物件,提高GC的效率。

我們把剛生成的物件稱為新生代物件,到達一定年齡的物件稱為老年代物件。

在新生代空間執行的GC,稱為minor GC。在老年代空間執行的GC,稱為major GC。

堆的結構:

20180828154817afa587ec-c7bc-4034-b3c4-8ae223193680.jpg

新生成物件分配的空間都是從Eden區分配的。當Eden區滿了的時候,就會觸發minor GC,將Eden區和From區的活動物件都複製到To,然後互動To和From的身份。

複製的時候如果超過一定的年齡或者To空間不足(即Eden和From的活動物件佔用的空間超過了To),那麼直接複製到老年代空間。如果老年代空間不足則會觸發major GC。

那些大於Eden空間的物件,一般也不會直接失敗,而是直接分配到老年代空間。實際的實現,一般是超過一定大小,則會將其分配到老年代空間。

新生代空間和老年代空間採用不同的GC演算法。新生代採用複製演算法,老年代採用標記-壓縮演算法或者標記-清除演算法。

3 Golang的垃圾回收演算法

3.1 三色標記法

golang採用的是併發的三色標記清除演算法(Tri-color marking)。

  • 白色:還沒搜尋的物件

  • 灰色:正在搜尋的物件

  • 黑色:搜尋完成的物件

GC開始前所有的物件都是白色物件。GC開始時,會將從根能夠直接引用的物件標記為灰色,並且放到堆疊裡。

然後,灰色物件會被依次彈出棧,其子物件也被塗成灰色,壓入棧。當其所有的子物件都變成灰色後,就會把這個物件塗成黑色。

當GC結束時,活動物件全部為黑色物件,垃圾則為白色物件,回收白色物件即可。

主要分為四個階段:

  • root_scan:STW

  • mark...mark...mark...mark...

  • mark termination:STW

  • sweep...sweep...sweep...

接下來分別介紹一下上述的四個不同階段。

3.1.1 root scan

根查詢階段需要STW。找出能夠從根直接引用的物件,標記為灰色,壓入棧。

root_scan_phase(){    for(r : $roots){
        mark(*r)
    }    $gc_phase = GC_MARK
}

mark(obj){    if(obj.mark == false){
        obj.mark = true
        push(obj,$mark_stack)
    }
}

當我們把所有直接從根引用的物件塗成了灰色時,root scan階段就結束了,mutator(即user application)會繼續執行。

3.1.2 mark 和 mark termination

mark是分多次執行的,即增量式的mark,不需要STW,它和mutator交替執行。它主要是彈出棧裡面的物件,將其子物件塗成灰色,壓入棧,然後把這個物件塗成黑色。重複這個過程,直到棧為空。

mark termination則是需要STW的。它會root_rescan,然後重新執行mark。

然而,mark階段與mutator併發執行存在一個問題,可能誤把活動物件當做垃圾物件回收。

比如下面的情況:

2018082815484045b8fd51-ddab-4f86-8c76-350ba6844660.jpg

第二張圖,建立了從黑色物件到白色物件的引用。第三張圖,刪除了從灰色物件到白色物件的引用。這個時候就會導致C被誤認為垃圾而回收。

為了避免這種情況的發生,需要引入write barrier。

最常用的write barrier是由Dijkstra提出的insertion style write barrier。

write_barrier(obj,field,newobj){    if(newobj.mark == false){
        newobj.mark = true
        push(newobj,$mark_stack)
    }
    *field = newobj
}

即如果新引用的物件是白色物件,則直接把它塗為灰色:

20180828154854d323bb9d-3a79-481f-bbf9-d9fed36c2b25.jpg

mark和mark termination的虛擬碼為:

incremental_mark_phase(){    for(i : 1...MARK_MAX){        // 分多次mark,不需要STW
        if(is_empty($mark_stack) == false){
            obj = pop($mark_stack)            for(child : children(obj)){
                mark(*child)
            }
        }else{            // mark termination,需要STW
            for(r : roots){
                mark(*r)
            }            while(is_empty($mark_stack) == false){
                obj = pop($mark_stack)                for(child : children(obj)){
                    mark(*child)
                }
            }            $gc_phase = GC_SWEEP
            $sweeping = $heap_start
            return
        }
    }
}
3.1.3 sweep

sweep也是分多次的,增量式的回收垃圾,跟mutator交替執行。跟標記-清除演算法的實現基本一致,也是需要遍歷整個堆,將白色物件掛到空閒連結串列上,黑色物件取消mark標記。

3.1.4 分配新物件

從空閒連結串列分配。

3.2 golang為什麼沒有采用壓縮演算法和分代演算法

有點高深,想深入瞭解的可以參考golang-nuts上面的討論。

本文來自網易雲社群,經作者李嵐清授權釋出。