一篇搞懂 JAVA 與 GO 垃圾回收
導語 現代高級編程語言管理內存的方式分自動和手動兩種。手動管理內存的典型代表是 C 和 C++,編寫代碼過程中需要主動申請或者釋放內存;而 PHP、Java 和 Go 等語言使用自動的內存管理系統,由內存分配器和垃圾收集器來代爲分配和回收內存,其中垃圾收集器就是我們常說的 GC。本文中,筆者將從原理出發,介紹 Java 和 Golang 垃圾回收算法,並從原理上對他們做一個對比。
Java 垃圾回收
垃圾回收區域及劃分
在介紹 Java 垃圾回收之前,我們需要了解 Java 的垃圾主要存在於哪個區域。JVM 內存運行時區域劃分如下圖所示:
圖源:深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 3 版) —機械工業出版社
-
程序計數器:是一塊較小的內存空間,它可以看作是當前線程所執行的字節碼的行號指示器,各條線程之間計數器互不影響,獨立存儲。
-
虛擬機棧:它描述的是 Java 方法執行的內存模型:每個方法在執行的同時都會創建一個棧幀(Stack Frame,是方法運行時的基礎數據結構)用於存儲局部變量表、操作數棧、動態鏈接、方法出口等信息。每一個方法從調用直至執行完成的過程,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程。
-
本地方法棧:它與虛擬機棧所發揮的作用是非常相似的,它們之間的區別不過是虛擬機棧爲虛擬機執行 Java 方法(也就是字節碼)服務,而本地方法棧則爲虛擬機使用到的 Native 方法服務。
-
Java 堆:它是 Java 虛擬機所管理的內存中最大的一塊。Java 堆是被所有線程共享的一塊內存區域,在虛擬機啓動時創建。此內存區域的唯一目的就是存放對象實例,幾乎所有的對象實例都在這裏分配內存。
-
方法區:它與 Java 堆一樣,是各個線程共享的內存區域,它用於存儲已被虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。
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 其實準確分類只有兩大種:
-
部分收集 (Partial GC):
-
新生代收集(Minor GC / Young GC):只對新生代進行垃圾收集;
-
老年代收集(Major GC / Old GC):只對老年代進行垃圾收集。需要注意的是 Major GC 在有的語境中也用於指代整堆收集;
-
混合收集(Mixed GC):對整個新生代和部分老年代進行垃圾收集。
-
整堆收集 (Full GC):收集整個 Java 堆和方法區。
Java 堆內存常見分配策略
-
對象優先在 eden 區分配。大部分對象朝生夕滅
-
大對象直接進入老年代。大對象就是需要大量連續內存空間的對象(比如:字符串、數組),容易導致內存還有不少空間就提前觸發垃圾收集獲取足夠的連續空間來安置它們。爲了避免爲大對象分配內存時,由於分配擔保機制帶來的複製而降低效率,建議大對象直接進入空間較大的老年代。
-
長期存活的對象將進入老年代,動態對象年齡判定:在一次新生代垃圾回收後,如果對象還存活,則會進入 s0 或者 s1,並且對象的年齡還會加 1(Eden 區 ->Survivor 區後對象的初始年齡變爲 1),當它的年齡增加到一定程度(超過了 survivor 區的一半時,取這個值和
MaxTenuringThreshold
中更小的一個值,作爲新的晉升年齡閾值),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold
來設置。 -
空間分配擔保。在發生 Minor GC 之前,虛擬機會先檢查老年代最大可用連續內存空間是否大於新生代所有對象總空間。如果這個條件成立, 那麼 Minor GC 可以確保是安全的。如果不成立,則虛擬機會查看 HandlePromotionFailure 設置值是否允許【擔保失敗】
-
如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小
-
如果大於,將嘗試着進行一次 Minor GC,儘管這次 Minor GC 是有風險的
-
如果小於,或者 HandlePromotionFailure 設置不允許冒險,那這時也要改爲進行一次 Full GC
判斷對象死亡
堆中幾乎放着所有的對象實例,對堆垃圾回收前的第一步就是要判斷哪些對象已經死亡(即不能再被任何途徑使用的對象)。判斷一個對象是否存活有引用計數、可達性分析這兩種算法,兩種算法各有優缺點。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” 的對象有很多,比如:
-
在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。
-
在方法區中類靜態屬性引用的對象,譬如 Java 類的應用類型靜態變量
-
在方法區中常量應用的對象,譬如字符串池中的引用
-
在本地方法棧中 JNI 引用的對象
-
Java 虛擬機內部的引用,如基本數據類型對應的 Class 對象,一些常駐異常對象(如 NPE),還有系統類加載器。
-
所有被同步鎖(synchronized)持有的對象
-
反映 Java 虛擬機內部情況的 JMXBean、JVMTI 中註冊的回調、本地代碼緩存等。
不可達的對象並非 “非死不可”
即使在可達性分析法中不可達的對象,也並非是 “非死不可” 的,這時候它們暫時處於“緩刑階段”,要真正宣告一個對象死亡,至少要經歷兩次標記過程;可達性分析法中不可達的對象被第一次標記並且進行一次篩選,篩選的條件是此對象是否有必要執行 finalize 方法。當對象沒有覆蓋 finalize 方法,或 finalize 方法已經被虛擬機調用過時,虛擬機將這兩種情況視爲沒有必要執行。被判定爲需要執行的對象將會被放在一個隊列中進行第二次標記,除非這個對象與引用鏈上的任何一個對象建立關聯,否則就會被真的回收。
判斷一個運行時常量池中的常量是廢棄常量
JDK1.7 之前運行時常量池邏輯包含字符串常量池存放在方法區, 此時 hotspot 虛擬機對方法區的實現爲永久代
JDK1.7 字符串常量池被從方法區拿到了堆中, 這裏沒有提到運行時常量池, 也就是說字符串常量池被單獨拿到堆, 運行時常量池剩下的東西還在方法區, 也就是 hotspot 中的永久代 。
JDK1.8 hotspot 移除了永久代用元空間 (Metaspace) 取而代之, 這時候字符串常量池還在堆, 運行時常量池還在方法區, 只不過方法區的實現從永久代變成了元空間(Metaspace)
假如在字符串常量池中存在字符串 "abc",如果當前沒有任何 String 對象引用該字符串常量的話,就說明常量 "abc" 就是廢棄常量,如果這時發生內存回收的話而且有必要的話,"abc" 就會被系統清理出常量池了。
如何判斷一個方法區的類是無用的類
類需要同時滿足下面 3 個條件才能算是 “無用的類”,虛擬機可以對無用類進行回收。
-
該類所有的實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
-
加載該類的 ClassLoader 已經被回收。
-
該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
垃圾收集算法
當確定了哪些對象可以回收後,就要需要考慮如何對這些對象進行回收,目前垃圾回收算法主要有以下幾種。
標記清除算法
該算法分爲 “標記” 和“清除”階段:首先標記出所有不需要回收的對象,在標記完成後統一回收掉所有沒有被標記的對象。
適用場合:存活對象較多的情況、適用於年老代(即舊生代)
缺點:
-
空間問題,易產生內存碎片,當爲一個大對象分配空間時可能會提前觸發垃圾回收(例如,對象的大小大於空閒表中的每一塊兒大小但是小於其中兩塊兒的和)
-
效率問題,掃描了整個空間兩次(第一次:標記存活對象;第二次:清除沒有標記的對象)
標記複製算法
爲了解決效率問題,出現了 “標記 - 複製” 收集算法。它可以將內存分爲大小相同的兩塊,每次使用其中的一塊。當這一塊的內存使用完後,就將還存活的對象複製到另一塊去,然後再把使用的空間一次清理掉。使用複製算法,回收過程中就不會出現內存碎片,也提高了內存分配和釋放的效率
適用場合:存活對象較少的情況下比較高效、用於年輕代(即新生代)
缺點:需要一塊兒空的內存空間,整理階段,由於移動了可用對象,需要去更新引用。
標記整理算法
對於對象存活率較高的場景,複製算法要進行較多複製操作,使得效率會變低,這種場景更適合標記 - 整理算法,與標記 - 清理一樣,標記整理算法先標記出對象的存活狀態,但在清理時,是先把所有存活對象往一端移動,然後直接清掉邊界以外的內存。
適用場合:對象存活率較高(即老年代)
缺點:整理階段,由於移動了可用對象,需要去更新引用。
分代收集算法
當前 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:
-
empty:雙向 span 鏈表,包括沒有空閒對象的 span 或緩存 mcache 中的 span。當此處的 span 被釋放時,它將被移至 non-empty span 鏈表。
-
non-empty:有空閒對象的 span 雙向鏈表。當從 mcentral 請求新的 span,mcentral 將從該鏈表中獲取 span 並將其移入 empty span 鏈表。
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 會申請鎖), 以及多種對象大小類型,減少分配產生的內存碎片。
-
微小對象 (Tiny)(size <16B):使用 mcache 的微小分配器分配小於 16 個字節的對象,並且在單個 16 字節塊上可完成多個微小分配。
-
小對象(尺寸 16B〜32KB):大小在 16 個字節和 32k 字節之間的對象被分配在 G 運行所在的 P 的 mcache 的對應的 mspan size class 上。
-
大對象(大小 > 32KB):大於 32 KB 的對象直接分配在 mheap 的相應大小類上 (size class)。如果 mheap 爲空或沒有足夠大的頁面滿足分配請求,則它將從操作系統中分配一組新的頁(至少 1MB)
-
如果對應的大小規格在 mcache 中沒有可用的塊,則向 mcentral 申請
-
如果 mcentral 中沒有可用的塊,則向 mheap 申請,並根據 BestFit 算法找到最合適的 mspan。如果申請到的 mspan 超出申請大小,將會根據需求進行切分,以返回用戶所需的頁數。剩餘的頁構成一個新的 mspan 放回 mheap 的空閒列表。
-
如果 mheap 中沒有可用 span,則向操作系統申請一系列新的頁(最小 1MB)。Go 會在操作系統分配超大的頁(稱作 arena)。分配一大批頁會減少和操作系統通信的成本。
內存回收
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 的時間。三色可達性分析標記算法按 “是否被訪問過” 將程序中的對象分成白色、黑色和灰色:
-
白色對象 — 對象尚未被垃圾收集器訪問過,在可達性分析剛開始的階段,所有的對象都是白色的,若在分析結束階段,仍然是白色的對象,即代表不可達。
-
黑色對象 — 表示對象已經被垃圾收集器訪問過,且這個對象的所有引用都已經被掃描過,黑色的對象代表已經被掃描過而且是安全存活的,如果有其他對象只想黑色對象無需再掃描一遍,黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
-
灰色對象 — 表示對象已經被垃圾收集器訪問過,但是這個對象上至少存在一個引用還沒有被掃描過,因爲存在指向白色對象的外部指針,垃圾收集器會掃描這些對象的子對象;
三色可達性分析算法大致的流程是(初始狀態所有對象都是白色):
-
從 GC Roots 開始枚舉,它們所有的直接引用變爲灰色(移入灰色集合),GC Roots 變爲黑色。
-
從灰色集合中取出一個灰色對象進行分析:
-
將這個對象所有的直接引用變爲灰色,放入灰色集合中,
-
將這個對象變爲黑色;
-
重複步驟 2,一直重複直到灰色集合爲空
-
分析完成,仍然是白色的對象就是 GC Roots 不可達的對象,可以作爲垃圾被清理
具體例子如下圖所示,經過三色可達性分析,最後白色 H 爲不可達的對象,是需要垃圾回收的對象。
三色標記清除算法本身是不可以併發或者增量執行的,它需要 STW,而如果併發執行,用戶程序可能在標記執行的過程中修改對象的指針
這種情況一般會有 2 種:
-
一種是把原本應該垃圾回收的死亡對象錯誤的標記爲存活。雖然這不好,但是不會導致嚴重後果,只不過產生了一點逃過本次回收的浮動垃圾而已,下次清理就可以,比如上圖所示的三色標記過程中,用戶程序取消了從 B 對象到 E 對象的引用,但是因爲 B 到 E 已經被標記完成不會繼續執行步驟 2,所以 E 對象最終會被錯誤的標記成黑色,不會被回收,這個 D 就是浮動垃圾,會在下次垃圾收集中清理。
-
一種是把原本存活的對象錯誤的標記爲已死亡,導致 “對象消失”,這在內存管理中是非常嚴重的錯誤。比如上圖所示的三色標記過程中,用戶程序建立了從 B 對象到 H 對象的引用 (例如
B.next=H
),接着執行D.next=nil
,但是因爲 B 到 H 中不存在灰色對象,因此在這之間不會繼續執行三色併發標記中的步驟 2,D 到 H 之間的鏈接被斷開,所以 H 對象最終會被標記成白色,會被垃圾收集器錯誤地回收。我們將這種錯誤稱爲懸掛指針,即指針沒有指向特定類型的合法對象,影響了內存的安全性。
屏障技術
爲了解決上述的 “對象消失” 的現象,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)標記爲灰色。
假設我們上圖的例子併發可達性分析中使用插入寫屏障,
-
GC 將根對象 Root2 指向的 B 對象標記成黑色並將 B 對象指向的對象 D 標記成灰色;
-
用戶程序修改指針,
B.next=H
這時觸發寫屏障將 H 對象標記成灰色 -
用戶程序修改指針
D.next=null
-
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 開始將棧上的對象全部掃描並標記爲黑色;
-
GC 期間,任何在棧上創建的新對象,均爲黑色;
-
被刪除的堆對象標記爲灰色;
-
被添加的堆對象標記爲灰色;
GC 演進過程
-
v1.0 — 完全串行的標記和清除過程,需要暫停整個程序;
-
v1.1 — 在多核主機並行執行垃圾收集的標記和清除階段;
-
v1.3 — 運行時基於只有指針類型的值包含指針的假設增加了對棧內存的精確掃描支持,實現了真正精確的垃圾收集;
-
將
unsafe.Pointer
類型轉換成整數類型的值認定爲不合法的,可能會造成懸掛指針等嚴重問題; -
v1.5 — 實現了基於三色標記清掃的併發垃圾收集器;
-
大幅度降低垃圾收集的延遲從幾百 ms 降低至 10ms 以下;
-
計算垃圾收集啓動的合適時間並通過併發加速垃圾收集的過程;
-
v1.6 — 實現了去中心化的垃圾收集協調器;
-
基於顯式的狀態機使得任意 Goroutine 都能觸發垃圾收集的狀態遷移;
-
使用密集的位圖替代空閒鏈表表示的堆內存,降低清除階段的 CPU 佔用;
-
v1.7 — 通過並行棧收縮將垃圾收集的時間縮短至 2ms 以內;
-
v1.8 — 使用混合寫屏障將垃圾收集的時間縮短至 0.5ms 以內;
-
v1.9 — 徹底移除暫停程序的重新掃描棧的過程;
-
v1.10 — 更新了垃圾收集調頻器(Pacer)的實現,分離軟硬堆大小的目標;
-
v1.12 — 使用新的標記終止算法簡化垃圾收集器的幾個階段;
-
v1.13 — 通過新的 Scavenger 解決瞬時內存佔用過高的應用程序向操作系統歸還內存的問題;
-
v1.14 — 使用全新的頁分配器優化內存分配的速度;
-
v1.15 — 改進編譯器和運行時內部的 CL 226367,它使編譯器可以將更多的 x86 寄存器用於垃圾收集器的寫屏障調用
-
v1.16 — Go runtime 默認使用 MADV_DONTNEED 更積極的將不用的內存釋放給 OS
GC 過程
Golang GC 相關的代碼在 runtime/mgc.go
文件下,可以看見 gc 總共分爲 4 個階段 (翻譯自 golang v1.16 版本源碼):
-
1. sweep termination(清理終止)
a. 暫停程序,觸發 STW。所有的 P(處理器)都會進入 safe-point(安全點);
b. 清理未被清理的 span 。如果當前垃圾收集是強制觸發的,需要處理還未被清理的內存管理單元;
-
2. the mark phase(標記階段)
a. 將 GC 狀態
gcphase
從_GCoff
改成_GCmark
、開啓寫屏障、啓用協助線程(mutator assists)、將根對象入隊b. 恢復程序執行,標記進程(mark workers)和協助程序會開始併發標記內存中的對象,寫屏障會覆蓋的重寫指針和新指針(標記成灰色),而所有新創建的對象都會被直接標記成黑色;
c. GC 執行根節點的標記,這包括掃描所有的棧、全局對象以及不在堆中的運行時數據結構。掃描 goroutine 棧會導致 goroutine 停止,並對棧上找到的所有指針加置灰,然後繼續執行 goroutine。
d. GC 遍歷灰色對象隊列,會將灰色對象變成黑色,並將該指針指向的對象置灰。
e. 由於 GC 工作分佈在本地緩存中,GC 會使用分佈式終止算法(distributed termination algorithm)來檢測何時不再有根標記作業或灰色對象,如果沒有了 GC 會轉爲 mark termination(標記終止)
-
3. mark termination(標記終止)
a. STW
b. 將 GC 狀態
gcphase
切換至_GCmarktermination
,關閉 gc 工作線程和協助程序c. 執行 housekeeping,例如刷新 mcaches
-
4. the sweep phase(清理階段)
a. 將 GC 狀態
gcphase
切換至_GCoff
來準備清理階段,初始化清理階段並關閉寫屏障b. 恢復用戶程序,從現在開始,所有新創建的對象會標記成白色;如果有必要,在使用前分配清理 spans
c. 後臺併發清理所有的內存管理類單元
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
申請內存時根據堆大小觸發 GC -
runtime.GC
用戶程序手動觸發 GC -
runtime.forcegchelper
後臺運行定時檢查觸發 GC
申請內存觸發 runtime.mallocgc
Go 運行時會將堆上的對象按大小分成微對象、小對象和大對象三類,這三類對象的創建都可能會觸發新的 GC
-
當前線程的內存管理單元中不存在空閒空間時,創建微對象
(noscan&&size<maxTinySize)
和小對象需要調用runtime.mcache.nextFree
從中心緩存或者頁堆中獲取新的管理單元,這時如果 span 滿了就會導致返回的shouldhelpgc=true
,就可能觸發垃圾收集; -
當用戶程序申請分配 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
表示觸發標記的堆內存大小的;當內存中存活的對象字節數大於觸發垃圾收集的堆大小時,新一輪的垃圾收集就會開始。
-
heapLive
— 爲了減少鎖競爭,運行時只會在中心緩存分配或者釋放內存管理單元以及在堆上分配大對象時纔會更新; -
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 時機可能不完全一致,這裏我們說的爲一般情況。
-
當 Eden 區空間不足時 Minor GC
-
對象年齡增加到一定程度時 Young GC
-
新生代對象轉入老年代及創建爲大對象、大數組時會導致老年代空間不足,觸發 Old GC
-
System.gc() 調用觸發 Full GC
-
各種區塊佔用超過閾值的情況
Go 則會根據以下條件進行觸發:
-
runtime.mallocgc
申請內存時根據堆大小觸發 GC -
runtime.GC
用戶程序手動觸發 GC -
runtime.forcegchelper
後臺運行定時檢查觸發 GC
收集算法
當前 Java 虛擬機的垃圾收集採用分代收集算法,根據對象存活週期的不同將內存分爲幾塊。比如在新生代中,每次收集都會有大量對象死去,所以可以選擇 “標記 - 複製” 算法,只需要付出少量對象的複製成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇 “標記 - 清除” 或“標記 - 整理”算法進行垃圾收集。
當前 Go 的都是基於標記清除算法進行垃圾回收。
垃圾碎片的處理
由於 Java 的內存管理劃分,因此容易產生垃圾對象,JVM 這些年不斷的改進和更新 GC 算法,JVM 在處理內存碎片問題上更多采用空間壓縮和分代收集的思想,例如在新生代使用 “標記 - 複製” 算法,G1 收集器支持了對象移動以消減長時間運行的內存碎片問題,劃分 region 的設計更容易把空閒內存歸還給 OS 等設計。
由於 Go 的內存管理的實現,很難實現分代,而移動對象也可能會導致 runtime 更龐大複雜,因此 Go 在關於內存碎片的處理方案和 Java 並不太一樣。
1. Go 語言 span 內存池的設計,減輕了很多內存碎片的問題。
Go 內存釋放的過程如下:當 mcache 中存在較多空閒 span 時,會歸還給 mcentral;而 mcentral 中存在較多空閒 span 時,會歸還給 mheap;mheap 再歸還給操作系統。這種設計主要有以下幾個優勢:
-
內存分配大多時候都是在用戶態完成的,不需要頻繁進入內核態。
-
每個 P 都有獨立的 span cache,多個 CPU 不會併發讀寫同一塊內存,進而減少 CPU L1 cache 的 cacheline 出現 dirty 情況,增大 cpu cache 命中率。
-
內存碎片的問題,Go 是自己在用戶態管理的,在 OS 層面看是沒有碎片的,使得操作系統層面對碎片的管理壓力也會降低。
-
mcache 的存在使得內存分配不需要加鎖。
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” 的對象:
-
虛擬機棧 (棧幀中的本地變量表) 中引用的對象
-
本地方法棧 (Native 方法) 中引用的對象
-
方法區中類靜態屬性引用的對象
-
方法區中常量引用的對象
-
Java 虛擬機內部引用
-
所有被同步鎖持有的對象
而在 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:
-
將棧上的對象全部掃描並標記爲黑色
-
GC 期間,任何在棧上創建的新對象,均爲黑色。
-
被刪除的對象標記爲灰色。
-
被添加的對象標記爲灰色。
由於要保證棧的運行效率,混合寫屏障是針對於堆區使用的。即棧區不會觸發寫屏障,只有堆區觸發,由於棧區初始標記的可達節點均爲黑色節點,因而也不需要第二次 STW 下的掃描。本質上是融合了插入屏障和刪除屏障的特點,解決了插入屏障需要二次掃描的問題。同時針對於堆區和棧區採用不同的策略,保證棧的運行效率不受損。
總結
從垃圾回收的角度來說,經過多代發展,Java 的垃圾回收機制較爲完善,Java 劃分新生代、老年代來存儲對象。對象通常會在新生代分配內存,多次存活的對象會被移到老年代,由於新生代存活率低,產生空間碎片的可能性高,通常選用 “標記 - 複製” 作爲回收算法,而老年代存活率高,通常選用 “標記 - 清除” 或“標記 - 整理”作爲回收算法,壓縮整理空間。
Go 是非分代的、併發的、基於三色標記和清除的垃圾回收器,它的優勢要結合它 tcmalloc 內存分配策略才能體現出來,因爲小微對象的分配均有自己的內存池,所有的碎片都能被完美複用,所以 GC 不用考慮空間碎片的問題。
參考文獻
-
Go 語言設計與實現
https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-garbage-collector/
-
一個專家眼中的 Go 與 Java 垃圾回收算法大對比
https://blog.csdn.net/u011277123/article/details/53991572
-
Go 語言問題集
https://www.bookstack.cn/read/qcrao-Go-Questions/spilt.19.GC-GC.md
-
CMS 垃圾收集器
https://juejin.cn/post/6844903782107578382
-
Golang v 1.16 版本源碼
https://github.com/golang/go
-
Golang--- 內存管理 (內存分配)
http://t.zoukankan.com/zpcoding-p-13259943.html
-
《深入理解 Java 虛擬機:JVM 高級特性與最佳實踐(第 3 版)》—機械工業出版社
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/X4eG_l4XvUmy09VrbApu7w