沒想到,Go 語言垃圾回收是這樣工作的!

1. 垃圾回收概述

1.1 什麼是垃圾回收

垃圾回收 (Garbage Collection,GC) 是一種自動內存管理的機制, 用於自動釋放那些不再被程序使用的內存。

它的主要思想是程序在申請內存時不需要釋放, 而是由垃圾回收器在程序運行的過程中找出那些不再使用的內存並回收它們。

這與 C/C++ 語言中的手動內存管理形成對比, C/C++ 程序員需要自己跟蹤內存的分配和釋放。

而 Go 語言中內存的分配是自動的, 程序員主要針對業務邏輯而不需要關心內存的控制, 這簡化了程序的開發。

1.2 Go 語言中的垃圾回收機制

Go 語言內置了高效的垃圾回收器,它採用了並行標記清除算法。垃圾回收器會監控程序運行時的內存分配情況,找出那些不再使用的變量所佔用的內存區域, 然後釋放掉這些內存以供後續的分配使用。

Go 中的垃圾回收主要有以下幾個特徵:

(1) 併發垃圾回收: 垃圾回收器會啓動一個單獨的 goroutine 執行回收工作, 這樣就不會阻塞主程序的運行。

(2) 準確性: 只有不再使用的對象纔會被判定爲垃圾回收。

(3) 高效: 垃圾回收的總開銷很低, 通常 around5%-10%。

1.3 爲什麼選擇垃圾回收

相比於手動內存管理, 垃圾回收器的優勢主要體現在:

(1) 簡化內存管理, 降低程序員工作量。程序員不再需要關心內存的分配、釋放等細節。

(2) 提高程序健壯性, 避免手動內存管理產生的問題, 如內存泄漏、野指針等。

(3) 提高開發效率。程序員可以更多地關注業務邏輯。

(4) 內存管理的工作由專門的垃圾回收器執行, 效率更高。

2. 垃圾回收基本原理

2.1 基於標記 - 清除算法

Go 語言的垃圾回收器基於 “標記 - 清除”(Mark and Sweep) 算法以及分代回收策略。

標記 - 清除算法分爲兩個階段:

(1) 標記階段: 垃圾回收器會先掃描所有的根對象, 標記正在使用的對象, 然後遍歷這些對象繼續標記它們引用的對象, 重複這個過程直到所有 reachable 對象都被標記完成。

(2) 清除階段: 垃圾回收器會遍歷整個內存空間, 把那些沒有被標記的對象清理掉。這些未被標記的對象就是不可達對象, 已經死亡, 佔用的內存可以回收。

import "fmt"
//對象obj1和obj2互相引用,無法被回收
var obj1 = &Object{a: 1} 
var obj2 = &Object{a: 2}
obj1.ref = obj2
obj2.ref = obj1
//obj3無法被訪問,可以被回收
var obj3 = &Object{a: 3}
// 垃圾回收時會標記obj1和obj2
// 而obj3不可達所以不會標記

上面代碼中, obj1 和 obj2 互相引用, 因此它們都可以被標記, 不會被回收。而 obj3 沒有任何引用, 無法可達, 所以會被垃圾回收器回收。

2.2 三色抽象模型

爲了描述未被標記的對象是否與標記過的對象相關聯, 抽象出三種顏色用於區分不同對象的標記過程, 這就是三色抽象模型。

  • 白色 (white): 這個對象沒有被標記, 並且沒有被任何標記過的對象引用。白色對象一定是不可達對象。

  • 灰色 (gray): 灰色對象自身被標記了, 但它引用的子對象還沒有被標記。灰色對象可能是可達的, 也可能是不可達的。

  • 黑色 (black): 黑色對象自身和它引用的所有子對象都已被標記完成。黑色對象一定都是可達的。

三色抽象模型將對象按照其標記過程區分爲白、灰、黑三種顏色。其中, 只有黑色對象是可達的, 白色對象是不可達的可以直接回收, 灰色對象只有在它引用的所有子對象都被標記完成 (變黑) 之後, 它才能確定是否是可達的。

3. 垃圾回收實現

3.1 雙色標記法

Go 語言的實現使用了雙色標記法, 對象標記爲黑或白兩種顏色。雙色標記法只使用黑白兩種顏色區分對象, 可以減少一次遍歷, 提高回收效率。

算法流程:

(1) 先將根對象標記爲灰色, 插入灰色對象工作列表

(2) 取出工作列表的下一個灰色對象 obj

(3) 掃描 obj 的子對象, 將引用的白色子對象標記爲灰色, 插入灰色工作列表

(4) 將 obj 標記爲黑色

(5) 重複步驟 2 至 4, 直到灰色工作列表爲空

最後白色的對象就不可達, 可以被回收。

func GC() {
    grayObjects = makeQueue()  
    grayObjects.append(rootObject)
    for !grayObjects.empty() {
        obj = grayObjects.pop()
        for child in obj.children {
            if child.color == white {
                child.color = gray
        grayObjects.append(child) 
            }
        }  
        obj.color = black
    }
    // 白色對象可以被回收
    sweepWhiteObjects() 
}

與三色標記法相比, 雙色標記法減少了一次黑色對象掃描, 因此效率更高。

3.2 三色標記法

三色標記法會使用白灰黑三種顏色對對象進行標記。

算法流程:

(1) 先所有對象標記爲白色, 將根對象標記爲灰色, 插入灰色工作列表。

(2) 取出灰色工作列表中的對象, 掃描它的子對象。如果子對象爲白色, 則標記爲灰色, 插入灰色工作列表。

(3) 灰色對象處理完後, 標記爲黑色。

(4) 重複步驟 2、3, 直到灰色工作列表爲空。

(5) 最後對所有的白色對象進行回收。

func GC() {
    for all objects {
        markWhite(object)  
    }
    markGrey(root)
    greySet = {root}
    while !greySet.empty() {
        node = getOneNode(greySet) 
        markBlack(node)  
        for child in node.children {
            if child.color == white {
                child.color = grey
                greySet.put(node)  
            }
        }
    }
    sweep(white) //回收白色對象
}

相比雙色標記法, 三色標記法需要第二次遍歷黑色對象來確定其引用的白色對象是否應該回收, 效率較低。但三色標記法理論上描述更簡捷準確。

3.3 標記 - 清除 vs 標記 - 整理

除了標記 - 清除算法, 還有標記 - 整理 (Mark and Compact) 算法。兩者的主要差別在於清除 / 整理階段:

  • 清除: 直接回收被標記的對象, 留下不連續的閒置內存塊。

  • 整理: 讓所有存活對象向一端移動, 然後直接清理掉端邊界以外的內存。

標記 - 清除會產生大量不連續的內存碎片, 而標記 - 整理算法沒有碎片問題, 且內存利用率高。但整理 phase 需要移動大量對象, 複製開銷很大, 效率低於清除。

Go 語言中前期是使用標記 - 清除算法來實現的內存回收,後期不能滿足併發的實時性需求,換成了三色標記清除算法。

4. 垃圾回收優化

4.1 縮短 STW 時間

STW(Stop The World) 指程序執行暫停, 阻塞主程序進程直到垃圾回收完成。這會間接導致響應延遲、界面卡頓等問題。

Go 語言做了一些優化來縮短 STW 時間:

(1) 增量標記: 將一個完整週期的標記過程分割爲幾個子階段, 避免長時間停頓。

(2) 併發回收: 使用一個單獨的回收器 goroutine 執行回收, 與用戶 goroutine 併發。

(3) 寫屏障技術: 通過引入屏障控制指令順序執行, 從而消除棧掃描。

4.2 邊界標記

邊界標記可以減少不必要的對象掃描, 優化標記過程。當垃圾回收開始時, 會從棧底標記相關對象, 這層標記就成了邊界, 超過這層的對象都不需要掃描判斷。

func main() {
    a := 1 
    if true {
        b := 2
        // 垃圾回收執行時,只需要標記stack底部,超過的不掃描
    }
}

如上代碼, b 變量在 if 作用域裏, 當垃圾回收時只要標記 main 函數棧底下的 a 對象即可, 不存在 b 變量引用 escaping 的情況, b 所佔用的內存一定可以被回收, 無需掃描標記判斷。

4.3 最佳配對分配器

最佳配對分配器 (Best-fit/first-fit) 指的是維護一個大小表, 記錄各個容量的內存塊信息, 在分配時根據所需內存大小找到最匹配的內存塊切分分配, 減少內存碎片。

最佳配對方式比一般的順序分配內存方式會更高效。Go 語言中也應用了最佳配對分配器的思想。

4.4 寫屏障優化

標記階段其中一個開銷就是掃描棧區, 需要停止 goroutine 來阻塞棧變化。

寫屏障 (Write Barrier) 可以不需要掃描整個棧就能保證標記的準確性。其原理類似於內存屏障, 通過在指針賦值操作插入寫屏障指令控制代碼執行順序, 從而省去棧掃描的開銷。

5 Go 語言垃圾回收器

5.1 GC 啓動原理

Go 語言中垃圾回收器是由兩個指標控制的:

(1) 最近 N 秒的內存分配消耗過快, 增長突破某個門限。

(2) 距離上一次 GC 超過最長門限 T。

當系統內存分配增長速度過快或者距離上次 GC 過久時, 就會觸發下一次 GC 執行。

// 假設最近5秒內存分配增長量 > 50MB 會觸發GC
// 或者距離上一次GC > 2min 也會觸發GC
func AllocMemory() {
    // Allocated > 50 MB 
    // LastGC > 2 min
    runtime.GC() 
}

5.2 GC 觸發條件

詳細的 GC 觸發邏輯, 可以通過 GOGC、GOMAXPROCS 等環境變量調整:

  • GOGC 值控制內存分配增長量速度 (默認 100, 表示允許增長到上限的 100%)。

  • GOMAXPROCS 影響最大內存增長量。

  • GCTIMEOUT 控制觸發 GC 的最長時間間隔。

5.3 GC 常見配置

通過調整相關環境變量可以配置 GC 策略:

  • GOGC: 內存增長率 (默認 100)。

  • GOMAXPROCS: 可並行 GC 使用的 CPU 邏輯核心數 (默認機器總 CPU 核心數)。

  • GCTIMEOUT: 觸發 GC 的最長時間間隔 (默認 2 分鐘)。

例如, 想要降低 GC 頻率, 提高應用程序吞吐, 可以配置:

GOGC=300 GOMAXPROCS=1 GCTIMEOUT=5m

這樣可以減少 GC 次數, 但整體內存使用會增加。需要根據實際情況 tuning 找到最佳配置。

6. 垃圾回收工作流程

6.1 垃圾回收準備

當垃圾回收被觸發時, 先會執行 STW, 停止所有的 goroutine, 然後做一些準備工作:

  • 回收運行時內存分配池中未使用的內存塊

  • 返回所有空閒 mspan 結構體

  • 檢查所有閒置 mcache 的本地緩存列表

完成這些後就可以開始 GC 標記過程。

6.2 快照標記

標記階段先會掃描所有 goroutine 的棧, mark survivals , 遍歷所有指針域找到 reachable 對象。

同時開啓新的 GC goroutine 來幫助標記 assist work, 減少 STW 耗時。

6.3 標記完成

標記過程都是增量進行, 後臺 GC goroutine 完成標記後, 主 goroutine 的標記也基本完成。這時會有一個少量任務清單由 GC worker picks up, 最後完成整個標記過程。

6.4 清掃複製

標記完成後就進入清掃階段, 主 goroutine 負責清掃工作, 同時可能也會開啓一些額外的 GC goroutine 幫助處理殘留對象複製等事宜。

6.5 記錄清掃時間

最後, 主 goroutine 負責統計這次 GC 過程的耗時, 更新相關內存統計計數器, 爲下次 GC 做準備。至此, 整個 GC 過程全部結束。

總結

Go 語言的高效垃圾回收器採用了標記 - 清除算法以及染色法, 通過寫屏障、併發回收等技術實現了低延遲的內存回收。

得益於生態成熟的 GC 策略, Go 語言可以很好地支撐大規模服務, 而開發團隊也可以更專注於業務開發而不必操心內存控制。未來 Go GC 會引入 分代回收以及壓縮 等機制繼續進行優化升級。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/O5Ru7mZyqgmhrku-TDCd7A