一篇搞懂 JAVA 與 GO 垃圾回收

導語 現代高級編程語言管理內存的方式分自動和手動兩種。手動管理內存的典型代表是 C 和 C++,編寫代碼過程中需要主動申請或者釋放內存;而 PHP、Java 和 Go 等語言使用自動的內存管理系統,由內存分配器和垃圾收集器來代爲分配和回收內存,其中垃圾收集器就是我們常說的 GC。本文中,筆者將從原理出發,介紹 Java 和 Golang 垃圾回收算法,並從原理上對他們做一個對比。

Java 垃圾回收

垃圾回收區域及劃分

在介紹 Java 垃圾回收之前,我們需要了解 Java 的垃圾主要存在於哪個區域。JVM 內存運行時區域劃分如下圖所示:

圖源:深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 3 版) —機械工業出版社

Java 內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧 3 個區域隨着線程而生,隨着線程而滅;棧中的棧幀隨着方法的進入退出而進棧出棧,在類結構確定下來時就已知每個棧幀中的分配內存。而 Java 堆和方法區則不同,一個接口中的多個實現類需要的內存可能不同,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,而在 java8 中,方法區存放於元空間中,元空間與堆共享物理內存,因此,Java 堆和方法區是垃圾收集器管理的主要區域

從垃圾回收的角度,由於 JVM 垃圾收集器基本都採用分代垃圾收集理論,所以 Java 堆還可以細分爲如下幾個區域(以 HotSpot 虛擬機默認情況爲例):

其中,Eden 區、From Survivor0(“From”) 區、To Survivor1(“To”) 區都屬於新生代,Old Memory 區屬於老年代。

大部分情況,對象都會首先在 Eden 區域分配;在一次新生代垃圾回收後,如果對象還存活,則會進入 To 區,並且對象的年齡還會加 1(Eden 區 ->Survivor 區後對象的初始年齡變爲 1),當它的年齡增加到一定程度(超過了 survivor 區的一半時,取這個值和 MaxTenuringThreshold中更小的一個值,作爲新的晉升年齡閾值),就會晉升到老年代中。經過這次 GC 後,Eden 區和 From 區已經被清空。這個時候,From 和 To 會交換他們的角色,保證名爲 To 的 Survivor 區域是空的。Minor GC 會一直重複這樣的過程。在這個過程中,有可能當次 Minor GC 後,Survivor 的 "From" 區域空間不夠用,有一些還達不到進入老年代條件的實例放不下,則放不下的部分會提前進入老年代。

針對 HotSpot VM 的實現,它裏面的 GC 其實準確分類只有兩大種:

Java 堆內存常見分配策略

判斷對象死亡

堆中幾乎放着所有的對象實例,對堆垃圾回收前的第一步就是要判斷哪些對象已經死亡(即不能再被任何途徑使用的對象)。判斷一個對象是否存活有引用計數、可達性分析這兩種算法,兩種算法各有優缺點。Java 和 Go 都使用可達性分析算法,一些動態腳本語言(如: ActionScript)一般使用引用計數算法。

引用計數法

引用計數法給每個對象的對象頭添加一個引用計數器,每當其他地方引用一次該對象,計數器就加 1;當引用失效,計數器就減 1;任何時候計數器爲 0 的對象就是不可能再被使用的。

這個方法實現簡單,效率高,但是主流的 Java 虛擬機中並沒有選擇這個算法來管理內存,其最主要的原因是它很難解決對象之間相互循環引用的問題。即如下代碼所示:除了對象 objA 和 objB 相互引用着對方之外,這兩個對象之間再無任何引用。但是他們因爲互相引用對方,導致它們的引用計數器都不爲 0,於是引用計數算法無法通知 GC 回收器回收他們。

目前 Python 語言使用的是引用計數法,它採用了 “標記 - 清除” 算法,解決容器對象可能產生的循環引用問題,關於詳細原理可以參考 Python 垃圾回收機制詳解

可達性分析算法

這個算法的基本思想就是通過一系列的稱爲 “GC Roots” 的對象作爲起點,從這些節點開始向下搜索,節點所走過的路徑稱爲引用鏈,當一個對象到 GC Roots 沒有任何引用鏈相連的話,則證明此對象是不可用的。算法優點是能準確標識所有的無用對象,包括相互循環引用的對象;缺點是算法的實現相比引用計數法複雜。比如如下圖所示 Root1 和 Root2 都爲 “GC Roots” ,白色節點爲應被垃圾回收的

關於 Java 查看可達性分析、內存泄露的工具,強烈推薦 “Memory Analyzer Tool”,可以查看內存分佈、對象間依賴、對象狀態。

在 Java 中,可以作爲 “GC Roots” 的對象有很多,比如:

不可達的對象並非 “非死不可”

即使在可達性分析法中不可達的對象,也並非是 “非死不可” 的,這時候它們暫時處於“緩刑階段”,要真正宣告一個對象死亡,至少要經歷兩次標記過程;可達性分析法中不可達的對象被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法。當對象沒有覆蓋 finalize 方法,或 finalize 方法已經被虛擬機調用過時,虛擬機將這兩種情況視爲沒有必要執行。被判定爲需要執行的對象將會被放在一個隊列中進行第二次標記,除非這個對象與引用鏈上的任何一個對象建立關聯,否則就會被真的回收。

判斷一個運行時常量池中的常量是廢棄常量

  1. JDK1.7 之前運行時常量池邏輯包含字符串常量池存放在方法區, 此時 hotspot 虛擬機對方法區的實現爲永久代

  2. JDK1.7 字符串常量池被從方法區拿到了堆中, 這裏沒有提到運行時常量池, 也就是說字符串常量池被單獨拿到堆, 運行時常量池剩下的東西還在方法區, 也就是 hotspot 中的永久代 。

  3. JDK1.8 hotspot 移除了永久代用元空間 (Metaspace) 取而代之, 這時候字符串常量池還在堆, 運行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間(Metaspace)

假如在字符串常量池中存在字符串 "abc",如果當前沒有任何 String 對象引用該字符串常量的話,就說明常量 "abc" 就是廢棄常量,如果這時發生內存回收的話而且有必要的話,"abc" 就會被系統清理出常量池了。

如何判斷一個方法區的類是無用的類

類需要同時滿足下面 3 個條件才能算是 “無用的類”,虛擬機可以對無用類進行回收。

垃圾收集算法

當確定了哪些對象可以回收後,就要需要考慮如何對這些對象進行回收,目前垃圾回收算法主要有以下幾種。

標記清除算法

該算法分爲 “標記” 和“清除”階段:首先標記出所有不需要回收的對象,在標記完成後統一回收掉所有沒有被標記的對象。

適用場合:存活對象較多的情況、適用於年老代(即舊生代)

缺點:

標記複製算法

爲了解決效率問題,出現了 “標記 - 複製” 收集算法。它可以將內存分爲大小相同的兩塊,每次使用其中的一塊。當這一塊的內存使用完後,就將還存活的對象複製到另一塊去,然後再把使用的空間一次清理掉。使用複製算法,回收過程中就不會出現內存碎片,也提高了內存分配和釋放的效率

適用場合:存活對象較少的情況下比較高效、用於年輕代(即新生代)

缺點:需要一塊兒空的內存空間,整理階段,由於移動了可用對象,需要去更新引用。

標記整理算法

對於對象存活率較高的場景,複製算法要進行較多複製操作,使得效率會變低,這種場景更適合標記 - 整理算法,與標記 - 清理一樣,標記整理算法先標記出對象的存活狀態,但在清理時,是先把所有存活對象往一端移動,然後直接清掉邊界以外的內存。

適用場合:對象存活率較高(即老年代)

缺點:整理階段,由於移動了可用對象,需要去更新引用。

分代收集算法

當前 Java 虛擬機的垃圾收集採用分代收集算法,一般根據對象存活週期的不同將內存分爲新生代和老年代。在新生代中,每次收集都會有大量對象死去,可以選擇”標記 - 複製 “算法,只需要付出少量對象的複製成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高,而且沒有額外的空間對它進行分配擔保,所以我們選擇“標記 - 清除” 或“標記 - 整理”算法進行垃圾收集。

垃圾收集器

圖源:深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 3 版) —機械工業出版社

雖然我們對各個收集器進行比較,但並非要挑選出一個最好的收集器。因爲直到現在爲止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇適合自己的垃圾收集器。

Golang 垃圾回收

從 Go v1.12 版本開始,Go 使用了非分代的、併發的、基於三色標記清除的垃圾回收器。相關標記清除算法可以參考和 C/C++ 一樣,Go 是一種靜態類型的編譯型語言。因此,Go 不需要 VM,Go 應用程序二進制文件中嵌入了一個小型運行時 (Go runtime),可以處理諸如垃圾收集 (GC),調度和併發之類的語言功能。首先讓我們看一下 Go 內部的內存管理是什麼樣子的。

Golang 內存管理 [6]

這裏先簡單介紹一下 Golang 運行調度。在 Golang 裏面有三個基本的概念:G, M, P。

  • G: Goroutine 執行的上下文環境。

  • M: 操作系統線程。

  • P: Processer。進程調度的關鍵,調度器,也可以認爲約等於 CPU。

一個 Goroutine 的運行需要 G + P + M 三部分結合起來。

圖源:Golang--- 內存管理 (內存分配)

TCMalloc

Go 將內存劃分和分組爲頁(Page),這和 Java 的內存結構完全不同,沒有分代內存,這樣的原因是 Go 的內存分配器採用了 TCMalloc 的設計思想:

Page

與 TCMalloc 中的 Page 相同,x64 下 1 個 Page 的大小是 8KB。上圖的最下方,1 個淺藍色的長方形代表 1 個 Page。

Span

與 TCMalloc 中的 Span 相同,Span 是內存管理的基本單位,代碼中爲 mspan,一組連續的 Page 組成 1 個 Span,所以上圖一組連續的淺藍色長方形代表的是一組 Page 組成的 1 個 Span,另外,1 個淡紫色長方形爲 1 個 Span。

mcache

mcache 是提供給 P(邏輯處理器)的高速緩存,用於存儲小對象(對象大小 <= 32Kb)。儘管這類似於線程堆棧,但它是堆的一部分,用於動態數據。所有類大小的 mcache 包含 scan 和 noscan 類型 mspan。Goroutine 可以從 mcache 沒有任何鎖的情況下獲取內存,因爲一次 P 只能有一個鎖 G。因此,這更有效。mcache 從 mcentral 需要時請求新的 span。

mcentral

mcentral 與 TCMalloc 中的 CentralCache 類似,是所有線程共享的緩存,需要加鎖訪問,它按 Span class 對 Span 分類,串聯成鏈表,當 mcache 的某個級別 Span 的內存被分配光時,它會向 mcentral 申請 1 個當前級別的 Span。每個 mcentral 包含兩個 mspanList:

mheap

mheap 與 TCMalloc 中的 PageHeap 類似,它是堆內存的抽象,也是垃圾回收的重點區域,把從 OS 申請出的內存頁組織成 Span,並保存起來。當 mcentral 的 Span 不夠用時會向 mheap 申請,mheap 的 Span 不夠用時會向 OS 申請,向 OS 的內存申請是按頁來的,然後把申請來的內存頁生成 Span 組織起來,同樣也是需要加鎖訪問的。

這是棧存儲區,每個 Goroutine(G)有一個棧。在這裏存儲了靜態數據,包括函數棧幀,靜態結構,原生類型值和指向動態結構的指針。這與分配給每個 P 的 mcache 不是一回事。

內存分配

Go 中的內存分類並不像 TCMalloc 那樣分成小、中、大對象,但是它的小對象裏又細分了一個 Tiny 對象,Tiny 對象指大小在 1Byte 到 16Byte 之間並且不包含指針的對象。小對象和大對象只用大小劃定,無其他區分。

核心思想:把內存分爲多級管理,降低鎖的粒度 (只是去 mcentral 和 mheap 會申請鎖), 以及多種對象大小類型,減少分配產生的內存碎片。

內存回收

go 內存會分成堆區(Heap)和棧區(Stack)兩個部分,程序在運行期間可以主動從堆區申請內存空間,這些內存由內存分配器分配並由垃圾收集器負責回收。棧區的內存由編譯器自動進行分配和釋放,棧區中存儲着函數的參數以及局部變量,它們會隨着函數的創建而創建,函數的返回而銷燬。如果只申請和分配內存,內存終將枯竭。Go 使用垃圾回收收集不再使用的 span,把 span 釋放交給 mheap,mheap 對 span 進行 span 的合併,把合併後的 span 加入 scav 樹中,等待再分配內存時,由 mheap 進行內存再分配。因此,Go 堆是 Go 垃圾收集器管理的主要區域。

標記清除算法

當成功區分出 Go 垃圾收集器管理區域的存活對象和死亡對象後,Go 垃圾收集器接下來的任務就是執行 GC,釋放無用對象佔用的內存空間,以便有足夠的可用內存空間爲新對象分配內存。目前常見的垃圾回收算法在垃圾收集算法中已有介紹,而 Go 使用的是標記清除算法,這是一種非常基礎和常見的垃圾收集算法,於 1960 年被 J.McCarthy 等人提出。

當堆空間被耗盡的時,就會 STW(也被稱爲 stop the world),其執行過程可以分成標記和清除兩個階段,具體可參照標記清除算法。Go 垃圾收集器從根結點開始遍歷,執行可達性分析算法,遞歸標記所有被引用的對象爲存活狀態;標記階段結束後,垃圾收集器會依次遍歷堆中的對象並清除其中的未被標記爲存活的對象。

由於用戶程序在垃圾收集的過程中也不能執行(STW)。在可達性分析算法中,Go 的 GC Roots 一般爲全局變量和 G Stack 中的引用指針,和整堆的對象相比只是極少數,因此它帶來的停頓是非常短暫且相對固定的,不隨堆容量增長。在從 GC Roots 往下遍歷對象的過程,堆越大,存儲對象越多,遞歸遍歷越複雜,要標記更多對象而產生的停頓時間自然就更長。因此我們需要用到更復雜的機制來解決 STW 的問題。

三色可達性分析

爲了解決標記清除算法帶來的 STW 問題,Go 和 Java 都會實現三色可達性分析標記算法的變種以縮短 STW 的時間。三色可達性分析標記算法按 “是否被訪問過” 將程序中的對象分成白色、黑色和灰色:

  1. 從 GC Roots 開始枚舉,它們所有的直接引用變爲灰色(移入灰色集合),GC Roots 變爲黑色。

  2. 從灰色集合中取出一個灰色對象進行分析:

  1. 重複步驟 2,一直重複直到灰色集合爲空

  2. 分析完成,仍然是白色的對象就是 GC Roots 不可達的對象,可以作爲垃圾被清理

具體例子如下圖所示,經過三色可達性分析,最後白色 H 爲不可達的對象,是需要垃圾回收的對象。

三色標記清除算法本身是不可以併發或者增量執行的,它需要 STW,而如果併發執行,用戶程序可能在標記執行的過程中修改對象的指針

這種情況一般會有 2 種:

屏障技術


爲了解決上述的 “對象消失” 的現象,Wilson 於 1994 年在理論上證明了,當且僅當以下兩個條件同時滿足時,會產生 “對象消失” 的問題,即原本應該是黑色的對象被誤標爲白色[7]:

因此爲了我們要解決併發掃描時的對象消失問題,保證垃圾收集算法的正確性,只需破壞這兩個條件的任意一個即可,屏障技術就是在併發或者增量標記過程中保證三色不變性的重要技術。

內存屏障技術是一種屏障指令,它可以讓 CPU 或者編譯器在執行內存相關操作時遵循特定的約束,目前多數的現代處理器都會亂序執行指令以最大化性能,但是該技術能夠保證內存操作的順序性,在內存屏障前執行的操作一定會先於內存屏障後執行的操作。垃圾收集中的屏障技術更像是一個鉤子方法,它是在用戶程序讀取對象、創建新對象以及更新對象指針時執行的一段代碼,根據操作類型的不同,我們可以將它們分成讀屏障(Read barrier)和寫屏障(Write barrier)兩種,因爲讀屏障需要在讀操作中加入代碼片段,對用戶程序的性能影響很大,所以編程語言往往都會採用寫屏障保證三色不變性 [1]。

插入寫屏障

Dijkstra 在 1978 年提出了插入寫屏障,也被叫做增量更新,通過如下所示的寫屏障,破壞上述第一個條件(賦值器插入了一條或多條從黑色對象到白色對象的新引用):

func DijkstraWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer)
     shade(ptr)  //先將新下游對象 ptr 標記爲灰色
*slot = ptr
}
//說明:
添加下游對象(當前下游對象slot, 新下游對象ptr) {
//step 1
標記灰色(新下游對象ptr)
//step 2
當前下游對象slot = 新下游對象ptr
}
//場景:
A.添加下游對象(nil, B) //A 之前沒有下游, 新添加一個下游對象B, B被標記爲灰色
A.添加下游對象(C, B) //A 將下游對象C 更換爲B, B被標記爲灰色

上述僞代碼非常好理解,當黑色對象(slot)插入新的指向白色對象(ptr)的引用關係時,就嘗試使用 shade 函數將這個新插入的引用(ptr)標記爲灰色。

假設我們上圖的例子併發可達性分析中使用插入寫屏障,

  1. GC 將根對象 Root2 指向的 B 對象標記成黑色並將 B 對象指向的對象 D 標記成灰色;

  2. 用戶程序修改指針, B.next=H 這時觸發寫屏障將 H 對象標記成灰色

  3. 用戶程序修改指針 D.next=null

  4. GC 依次遍歷程序中的 H 和 D 將它們分別標記成黑色;

由於棧上的對象在垃圾回收中被認爲是根對象,並沒有寫屏障,那麼導致黑色的棧可能指向白色的堆對象,例如上圖 1 中 Root2 指向 H,且刪除了由 D 指向 H 的引用,由於沒有寫屏障,那麼 H 將會被刪除。爲了保障內存安全,Dijkstra 必須爲棧上的對象增加寫屏障或者在標記階段完成重新對棧上的對象進行掃描,這兩種方法各有各的缺點,前者會大幅度增加寫入指針的額外開銷,後者重新掃描棧對象時需要暫停程序,垃圾收集算法的設計者需要在這兩者之前做出權衡 [1]。

刪除寫屏障

Yuasa 在 1990 年的論文 Real-time garbage collection on general-purpose machines 中提出了刪除寫屏障,因爲一旦該寫屏障開始工作,它會保證開啓寫屏障時堆上所有對象的可達。起始時 STW 掃描所有的 goroutine 棧,保證所有堆上在用的對象都處於灰色保護下,所以也被稱作快照垃圾收集(Snapshot GC),這是破壞了 “對象消失” 的第二個條件(賦值器刪除了全部從灰色對象到該白色對象的直接或間接引用)。

// 黑色賦值器 Yuasa 屏障
func YuasaWritePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot) 先將*slot標記爲灰色
*slot = ptr
}
//說明:
添加下游對象(當前下游對象slot, 新下游對象ptr) {
//step 1
if(當前下游對象slot是灰色 || 當前下游對象slot是白色) {
標記灰色(當前下游對象slot)     //slot爲被刪除對象, 標記爲灰色
}
//step 2
當前下游對象slot = 新下游對象ptr
}
//場景
A.添加下游對象(B, nil)   //A對象,刪除B對象的引用。B被A刪除,被標記爲灰(如果B之前爲白)
A.添加下游對象(B, C)     //A對象,更換下游B變成C。B被A刪除,被標記爲灰(如果B之前爲白)
上述代碼會在老對象的引用被刪除時,將白色的老對象塗成灰色,這樣刪除寫屏障就可以保證弱三色不變性,老對象引用的下游對象一定可以被灰色對象引用。

但是這樣也會導致一個問題,由於會將有存活可能的對象都標記成灰色,因此最後可能會導致應該回收的對象未被回收,這個對象只有在下一個循環纔會被回收,比如下圖的 D 對象。

由於原始快照的原因,起始也是執行 STW,刪除寫屏障不適用於棧特別大的場景,棧越大,STW 掃描時間越長

混合寫屏障

在 Go 語言 v1.7 版本之前,運行時會使用 Dijkstra 插入寫屏障保證強三色不變性,但是運行時並沒有在所有的垃圾收集根對象上開啓插入寫屏障。因爲應用程序可能包含成百上千的 Goroutine,而垃圾收集的根對象一般包括全局變量和棧對象,如果運行時需要在幾百個 Goroutine 的棧上都開啓寫屏障,會帶來巨大的額外開銷,所以 Go 團隊在 v1.8 結合上述 2 種寫屏障構成了混合寫屏障,實現上選擇了在標記階段完成時暫停程序、將所有棧對象標記爲灰色並重新掃描 [1]。

Go 語言在 v1.8 組合 Dijkstra 插入寫屏障和 Yuasa 刪除寫屏障構成了如下所示的混合寫屏障,該寫屏障會將被覆蓋的對象標記成灰色並在當前棧沒有掃描時將新對象也標記成灰色:

writePointer(slot, ptr):
    shade(*slot)
if current stack is grey:
        shade(ptr)
*slot = ptr

爲了移除棧的重掃描過程,除了引入混合寫屏障之外,在垃圾收集的標記階段,我們還需要將創建的所有新對象都標記成黑色,防止新分配的棧內存和堆內存中的對象被錯誤地回收,因爲棧內存在標記階段最終都會變爲黑色,所以不再需要重新掃描棧空間。總結來說主要有這幾點:

GC 過程

Golang GC 相關的代碼在 runtime/mgc.go文件下,可以看見 gc 總共分爲 4 個階段 (翻譯自 golang v1.16 版本源碼):

GC 過程代碼示例

func gcfinished() *int{
    p := 1
    runtime.SetFinalizer(&p, func(_ *int) {
        println("gc finished")
})
return&p
}
func allocate() {
    _ = make([]byte, int((1<<20)*0.25))
}
func main() {
    f, _ := os.Create("trace.out")
    defer f.Close()
    trace.Start(f)
    defer trace.Stop()
    gcfinished()
// 當完成 GC 時停止分配
for n := 1; n < 50; n++ {
        println("#allocate: ", n)
        allocate()
}
    println("terminate")
}

運行程序

hewittwang@HEWITTWANG-MB0 rtx % GODEBUG=gctrace=1 go run new1.go
gc 1@0.015s0%: 0.015+0.36+0.043 ms clock, 0.18+0.55/0.64/0.13+0.52 ms cpu, 4->4->0 MB, 5 MB goal, 12 P
gc 2@0.024s1%: 0.045+0.19+0.018 ms clock, 0.54+0.37/0.31/0.041+0.22 ms cpu, 4->4->0 MB, 5 MB goal, 12 P
....

棧分析

gc 2: 第一個GC週期
@0.024s: 從程序開始運行到第一次GC時間爲0.024秒
1%        : 此次GC過程中CPU 佔用率
wall clock
0.045+0.19+0.018 ms clock
0.045 ms  : STW,MarkingStart, 開啓寫屏障
0.19 ms   : Marking階段
0.018 ms  : STW,Marking終止,關閉寫屏障
CPU time
0.54+0.37/0.31/0.041+0.22 ms cpu
0.54 ms   : STW,MarkingStart
0.37 ms  : 輔助標記時間
0.31 ms  : 併發標記時間
0.041 ms   : GC 空閒時間
0.22 ms   : Mark終止時間
4->4->0 MB, 5 MB goal
4 MB      :標記開始時,堆大小實際值
4 MB      :標記結束時,堆大小實際值
0 MB      :標記結束時,標記爲存活對象大小
5 MB      :標記結束時,堆大小預測值
12 P      :本次GC過程中使用的goroutine 數量

GC 觸發條件

運行時會通過 runtime.gcTrigger.test方法決定是否需要觸發垃圾收集,當滿足觸發垃圾收集的基本條件(即滿足 _GCoff階段的退出條件)時 — 允許垃圾收集、程序沒有崩潰並且沒有處於垃圾收集循環,該方法會根據三種不同方式觸發進行不同的檢查:

//mgc.go 文件 runtime.gcTrigger.test
 func (t gcTrigger) test() bool{
//測試是否滿足觸發垃圾手機的基本條件
if!memstats.enablegc || panicking != 0|| gcphase != _GCoff{
returnfalse
}
switch t.kind {
case gcTriggerHeap:    //堆內存的分配達到達控制器計算的觸發堆大小
// Non-atomic access to gcController.heapLive for performance. If
// we are going to trigger on this, this thread just
// atomically wrote gcController.heapLive anyway and we'll see our
// own write.
return gcController.heapLive >= gcController.trigger
case gcTriggerTime:      //如果一定時間內沒有觸發,就會觸發新的循環,該出發條件由 `runtime.forcegcperiod`變量控制,默認爲 2 分鐘;
if gcController.gcPercent < 0{
returnfalse
}
         lastgc := int64(atomic.Load64(&memstats.last_gc_nanotime))
return lastgc != 0&& t.now-lastgc > forcegcperiod
case gcTriggerCycle:      //如果當前沒有開啓垃圾收集,則觸發新的循環;
// t.n > work.cycles, but accounting for wraparound.
return int32(t.n-work.cycles) > 0
}
returntrue
}

用於開啓垃圾回收的方法爲 runtime.gcStart,因此所有調用該函數的地方都是觸發 GC 的代碼

申請內存觸發 runtime.mallocgc

Go 運行時會將堆上的對象按大小分成微對象、小對象和大對象三類,這三類對象的創建都可能會觸發新的 GC

  1. 當前線程的內存管理單元中不存在空閒空間時,創建微對象 (noscan&&size<maxTinySize)和小對象需要調用 runtime.mcache.nextFree從中心緩存或者頁堆中獲取新的管理單元,這時如果 span 滿了就會導致返回的 shouldhelpgc=true,就可能觸發垃圾收集;

  2. 當用戶程序申請分配 32KB 以上的大對象時,一定會構建 runtime.gcTrigger結構體嘗試觸發垃圾收集;

func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer{
省略代碼 ...
    shouldhelpgc := false
  dataSize := size
  c := getMCache()       //嘗試獲取mCache。如果沒啓動或者沒有P,返回nil;
省略代碼 ...
if size <= maxSmallSize {
if noscan && size < maxTinySize { // 微對象分配
省略代碼 ...
          v := nextFreeFast(span)
if v == 0{
             v, span, shouldhelpgc = c.nextFree(tinySpanClass)
}
省略代碼 ...
} else{      //小對象分配
省略代碼 ...
if v == 0{
             v, span, shouldhelpgc = c.nextFree(spc)
}
省略代碼 ...
}
} else{
       shouldhelpgc = true
省略代碼 ...
}
省略代碼 ...
if shouldhelpgc {      //是否應該觸發gc
if t := (gcTrigger{kind: gcTriggerHeap}); t.test() {   //如果滿足gc觸發條件就調用gcStart()
          gcStart(t)
}
}
省略代碼 ...
return x
}

這個時候調用 t.test()執行的是 gcTriggerHeap情況,只需要判斷 gcController.heapLive>=gcController.trigger的真假就可以了。heapLive 表示垃圾收集中存活對象字節數, trigger表示觸發標記的堆內存大小的;當內存中存活的對象字節數大於觸發垃圾收集的堆大小時,新一輪的垃圾收集就會開始。

  1. heapLive — 爲了減少鎖競爭,運行時只會在中心緩存分配或者釋放內存管理單元以及在堆上分配大對象時纔會更新;

  2. trigger — 在標記終止階段調用 runtime.gcSetTriggerRatio 更新觸發下一次垃圾收集的堆大小,它能夠決定觸發垃圾收集的時間以及用戶程序和後臺處理的標記任務的多少,利用反饋控制的算法根據堆的增長情況和垃圾收集 CPU 利用率確定觸發垃圾收集的時機。

手動觸發 runtime.GC

用戶程序會通過 runtime.GC 函數在程序運行期間主動通知運行時執行,該方法在調用時會阻塞調用方直到當前垃圾收集循環完成,在垃圾收集期間也可能會通過 STW 暫停整個程序:

func GC() {
//在正式開始垃圾收集前,運行時需要通過runtime.gcWaitOnMark等待上一個循環的標記終止、標記和清除終止階段完成;
    n := atomic.Load(&work.cycles)
    gcWaitOnMark(n)
//調用 `runtime.gcStart` 觸發新一輪的垃圾收集
    gcStart(gcTrigger{kind: gcTriggerCycle, n: n + 1})
//`runtime.gcWaitOnMark` 等待該輪垃圾收集的標記終止階段正常結束;
    gcWaitOnMark(n + 1)
// 持續調用 `runtime.sweepone` 清理全部待處理的內存管理單元並等待所有的清理工作完成
for atomic.Load(&work.cycles) == n+1&& sweepone() != ^uintptr(0) {
        sweep.nbgsweep++
Gosched()  //等待期間會調用 `runtime.Gosched` 讓出處理器
}
//
for atomic.Load(&work.cycles) == n+1&& !isSweepDone() {
Gosched()
}
// 完成本輪垃圾收集的清理工作後,通過 `runtime.mProf_PostSweep` 將該階段的堆內存狀態快照發布出來,我們可以獲取這時的內存狀態
    mp := acquirem()
    cycle := atomic.Load(&work.cycles)
if cycle == n+1|| (gcphase == _GCmark&& cycle == n+2) {   //僅限於沒有啓動其他標記終止過程
        mProf_PostSweep()
}
    releasem(mp)
}

後臺運行定時檢查觸發 runtime.forcegchelper

運行時會在應用程序啓動時在後臺開啓一個用於強制觸發垃圾收集的 Goroutine,該 Goroutine 調用 runtime.gcStart 嘗試啓動新一輪的垃圾收集:

// start forcegc helper goroutine
func init() {
   go forcegchelper()
}
func forcegchelper() {
   forcegc.g = getg()
   lockInit(&forcegc.lock, lockRankForcegc)
for{
lock(&forcegc.lock)
if forcegc.idle != 0{
throw("forcegc: phase error")
}
      atomic.Store(&forcegc.idle, 1)
//該 Goroutine 會在循環中調用runtime.goparkunlock主動陷入休眠等待其他 Goroutine 的喚醒
      goparkunlock(&forcegc.lock, waitReasonForceGCIdle, traceEvGoBlock, 1)
if debug.gctrace > 0{
         println("GC forced")
}
// Time-triggered, fully concurrent.
      gcStart(gcTrigger{kind: gcTriggerTime, now: nanotime()})
}
}

Java 和 Go GC 對比

垃圾回收區域

Java 內存運行時區域的各個部分,其中程序計數器、虛擬機棧、本地方法棧 3 個區域隨着線程而生,隨着線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧的操作,每個棧幀中分配多少內存基本是在類結構確定下來時就已知的。而 Java 堆和方法區則不同,一個接口中的多個實現類需要的內存可能不同,一個方法中的多個分支需要的內存也可能不一樣,我們只有在程序處於運行期間時才能知道會創建哪些對象,這部分內存的分配和回收都是動態的,因此,Java 堆和方法區是 Java 垃圾收集器管理的主要區域

go 內存會分成堆區(Heap)和棧區(Stack)兩個部分,程序在運行期間可以主動從堆區申請內存空間,這些內存由內存分配器分配並由垃圾收集器負責回收。棧區的內存由編譯器自動進行分配和釋放,棧區中存儲着函數的參數以及局部變量,它們會隨着函數的創建而創建,函數的返回而銷燬。如果只申請和分配內存,內存終將枯竭。Go 使用垃圾回收收集不再使用的 span,把 span 釋放交給 mheap,mheap 對 span 進行 span 的合併,把合併後的 span 加入 scav 樹中,等待再分配內存時,由 mheap 進行內存再分配。因此,Go 堆是 Go 垃圾收集器管理的主要區域。

觸發垃圾回收的時機

Java 當應用程序空閒時, 即沒有應用線程在運行時, GC 會被調用。因爲 GC 在優先級最低的線程中進行, 所以當應用忙時, GC 線程就不會被調用, 但以下條件除外。

Java 堆內存不足時, GC 會被調用。但是這種情況由於 java 是分代收集算法且垃圾收集器種類十分多,因此其觸發各種垃圾收集器的 GC 時機可能不完全一致,這裏我們說的爲一般情況。

  1. 當 Eden 區空間不足時 Minor GC

  2. 對象年齡增加到一定程度時 Young GC

  3. 新生代對象轉入老年代及創建爲大對象、大數組時會導致老年代空間不足,觸發 Old GC

  4. System.gc() 調用觸發 Full GC

  5. 各種區塊佔用超過閾值的情況

Go 則會根據以下條件進行觸發:

收集算法

當前 Java 虛擬機的垃圾收集採用分代收集算法,根據對象存活週期的不同將內存分爲幾塊。比如在新生代中,每次收集都會有大量對象死去,所以可以選擇 “標記 - 複製” 算法,只需要付出少量對象的複製成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇 “標記 - 清除” 或“標記 - 整理”算法進行垃圾收集。

當前 Go 的都是基於標記清除算法進行垃圾回收。

垃圾碎片的處理

由於 Java 的內存管理劃分,因此容易產生垃圾對象,JVM 這些年不斷的改進和更新 GC 算法,JVM 在處理內存碎片問題上更多采用空間壓縮和分代收集的思想,例如在新生代使用 “標記 - 複製” 算法,G1 收集器支持了對象移動以消減長時間運行的內存碎片問題,劃分 region 的設計更容易把空閒內存歸還給 OS 等設計。

由於 Go 的內存管理的實現,很難實現分代,而移動對象也可能會導致 runtime 更龐大複雜,因此 Go 在關於內存碎片的處理方案和 Java 並不太一樣。

1. Go 語言 span 內存池的設計,減輕了很多內存碎片的問題。

Go 內存釋放的過程如下:當 mcache 中存在較多空閒 span 時,會歸還給 mcentral;而 mcentral 中存在較多空閒 span 時,會歸還給 mheap;mheap 再歸還給操作系統。這種設計主要有以下幾個優勢:

2. tcmalloc 分配機制,Tiny 對象和大對象分配優化,在某種程度上也導致基本沒有內存碎片會出現。

比如常規上 sizeclass=1 的 span,用來給 <= 8B 的對象使用,所以像 int32, byte, bool 以及小字符串等常用的微小對象,都會使用 sizeclass=1 的 span,但分配給他們 8B 的空間,大部分是用不上的。並且這些類型使用頻率非常高,就會導致出現大量的內部碎片。

因此 Go 儘量不使用 sizeclass=1 的 span, 而是將 <16B 的對象爲統一視爲 tiny 對象。分配時,從 sizeclass=2 的 span 中獲取一個 16B 的 object 用以分配。如果存儲的對象小於 16B,這個空間會被暫時保存起來 (mcache.tiny 字段),下次分配時會複用這個空間,直到這個 object 用完爲止。

以上圖爲例,這樣的方式空間利用率是 (1+2+8) / 16 * 100% = 68.75%,而如果按照原始的管理方式,利用率是 (1+2+8) / (8 * 3) = 45.83%。源碼中註釋描述,說是對 tiny 對象的特殊處理,平均會節省 20% 左右的內存。如果要存儲的數據裏有指針,即使 <= 8B 也不會作爲 tiny 對象對待,而是正常使用 sizeclass=1 的 span。

Go 中,最大的 sizeclass 最大隻能存放 32K 的對象。如果一次性申請超過 32K 的內存,系統會直接繞過 mcache 和 mcentral,直接從 mheap 上獲取,mheap 中有一個 freelarge 字段管理着超大 span。

3. Go 的對象 (即 struct 類型) 是可以分配在棧上的

Go 會在編譯時做靜態逃逸分析 (Escape Analysis), 如果發現某個對象並沒有逃出當前作用域,則會將對象分配在棧上而不是堆上,從而減輕了 GC 內存碎片回收壓力。

比如如下代碼

func F() {
    temp := make([]int, 0, 20) //只是內函數內部申請的臨時變量,並不會作爲返回值返回,它就是被編譯器申請到棧裏面。
    temp = append(temp, 1)
}
func main() {
    F()
}

運行代碼如下,結果顯示 temp 變量被分配在棧上並沒有分配在堆上

hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m
# hello
./new1.go:4:6: can inline F
./new1.go:9:6: can inline main
./new1.go:10:3: inlining call to F
./new1.go:5:14: make([]int, 0, 20) does not escape
./new1.go:10:3: make([]int, 0, 20) does not escapeh

當我們把上述代碼更改

package main
import"fmt"
func F() {
    temp := make([]int, 0, 20)
    fmt.Print(temp)
}
func main() {
    F()
}

運行代碼如下,結果顯示 temp 變量被分配在堆上,這是由於 temp 傳入了 print 函數里,編譯器會認爲變量之後還會被使用。因此就申請到堆上,申請到堆上面的內存纔會引起垃圾回收,如果這個過程(特指垃圾回收不斷被觸發)過於高頻就會導致 gc 壓力過大,程序性能出問題。

hewittwang@HEWITTWANG-MB0 rtx % go build -gcflags=-m
# hello
./new1.go:9:11: inlining call to fmt.Print
./new1.go:12:6: can inline main
./new1.go:8:14: make([]int, 0, 20) escapes to heap
./new1.go:9:11: temp escapes to heap
./new1.go:9:11: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

“GC Roots” 的對象的選擇

在 Java 中由於內存運行時區域的劃分,通常會選擇以下幾種作爲 “GC Roots” 的對象:

而在 Java 中的不可達對象有可能會逃脫。即使在可達性分析法中不可達的對象,也並非是 “非死不可” 的,這時候它們暫時處於“緩刑階段”,要真正宣告一個對象死亡,至少要經歷兩次標記過程;此外 Java 中由於存在運行時常量池和類,因此也需要對運行時常量池和方法區的類進行清理。

而 Go 的選擇就相對簡單一點,即全局變量和 G Stack 中的引用指針,簡單來說就是全局量和 go 程中的引用指針。因爲 Go 中沒有類的封裝概念,因而 Gc Root 選擇也相對簡單一些。

寫屏障

爲了解決併發三色可達性分析中的懸掛指針問題,出現了 2 種解決方案,分別是分別是 “Dijkstra 插入寫屏障” 和“Yuasa 刪除寫屏障”

在 java 中,對上述 2 種方法都有應用,比如 CMS 是基於 Dijkstra 插入寫屏障做併發標記的,G1、Shenandoah 則是使用 Yuasa 刪除寫屏障來實現的

在 Go 語言 v1.7 版本之前,運行時會使用 Dijkstra 插入寫屏障保證強三色不變性,Go 語言在 v1.8 組合 Dijkstra 插入寫屏障和 Yuasa 刪除寫屏障構成了混合寫屏障,混合寫屏障結合兩者特點,通過以下方式實現併發穩定的 gc:

  1. 將棧上的對象全部掃描並標記爲黑色

  2. GC 期間,任何在棧上創建的新對象,均爲黑色。

  3. 被刪除的對象標記爲灰色。

  4. 被添加的對象標記爲灰色。

由於要保證棧的運行效率,混合寫屏障是針對於堆區使用的。即棧區不會觸發寫屏障,只有堆區觸發,由於棧區初始標記的可達節點均爲黑色節點,因而也不需要第二次 STW 下的掃描。本質上是融合了插入屏障和刪除屏障的特點,解決了插入屏障需要二次掃描的問題。同時針對於堆區和棧區採用不同的策略,保證棧的運行效率不受損。

總結

從垃圾回收的角度來說,經過多代發展,Java 的垃圾回收機制較爲完善,Java 劃分新生代、老年代來存儲對象。對象通常會在新生代分配內存,多次存活的對象會被移到老年代,由於新生代存活率低,產生空間碎片的可能性高,通常選用 “標記 - 複製” 作爲回收算法,而老年代存活率高,通常選用 “標記 - 清除” 或“標記 - 整理”作爲回收算法,壓縮整理空間。

Go 是非分代的、併發的、基於三色標記和清除的垃圾回收器,它的優勢要結合它 tcmalloc 內存分配策略才能體現出來,因爲小微對象的分配均有自己的內存池,所有的碎片都能被完美複用,所以 GC 不用考慮空間碎片的問題。

參考文獻

  1. Go 語言設計與實現

    https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/

  2. 一個專家眼中的 Go 與 Java 垃圾回收算法大對比

    https://blog.csdn.net/u011277123/article/details/53991572

  3. Go 語言問題集

    https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.19.GC-GC.md

  4. CMS 垃圾收集器

    https://juejin.cn/post/6844903782107578382

  5. Golang v 1.16 版本源碼

    https://github.com/golang/go

  6. Golang--- 內存管理 (內存分配)

    http://t.zoukankan.com/zpcoding-p-13259943.html

  7. 《深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 3 版)》—機械工業出版社

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