JVM學習(二)——GC垃圾回收機制

Hiway發表於2019-01-15

其他更多java基礎文章:
java基礎學習(目錄)


通過前一篇JVM學習(一)——記憶體結構對JVM記憶體結構的講解。我們知道程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅,在這幾個區域內就不需要過多考慮回收的問題,因為方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。棧中的棧幀隨著方法的進入和退出就有條不紊的執行者出棧和入棧的操作,每一個棧分配多少個記憶體基本都是在類結構確定下來的時候就已經確定了,這幾個區域記憶體分配和回收都具有確定性

而堆和方法區則不同,一個介面的實現是多種多樣的,多個實現類需要的記憶體可能不一樣,一個方法中多個分支需要的記憶體也不一樣,我們只能在程式執行的期間知道需要建立那些物件,分配多少記憶體,這部分的記憶體分配和回收都是動態的。這篇所講的GC垃圾回收機制就是回收堆和方法區資料的機制。

1.判斷物件存活

1.1 引用計數器法

給物件新增一個引用計數器,每當由一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的。
引用計數法有一個重大的漏洞,那便是無法處理迴圈引用物件。舉個例子,假設物件 a 與 b 相互引用,除此之外沒有其他引用指向 a 或者 b。在這種情況下,a 和 b 實際上已經死了,但由於它們的引用計數器皆不為 0,在引用計數法的心中,這兩個物件還活 著。因此,這些迴圈引用物件所佔據的空間將不可回收,從而造成了記憶體洩露。

JVM學習(二)——GC垃圾回收機制

1.2 可達性分析演算法

通過一系列的成為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑成為引用鏈,當一個物件到GC ROOTS沒有任何引用鏈相連時,則證明此物件時不可用的 Java語言中GC Roots的物件包括(包括但不限於)下面幾種:

  1. 虛擬機器棧(棧幀中的本地變數表)中引用的物件
  2. 方法區中類靜態屬性引用的物件
  3. 方法區中常量引用的物件
  4. 本地方法棧JNI(Native方法)引用的物件

雖然可達性分析的演算法本身很簡明,但是在實踐中還是有不少其他問題需要解決的。
比如說,在多執行緒環境下,其他執行緒可能會更新已經訪問過的物件中的引用,從而造成誤報(將引 用設定為 null)或者漏報(將引用設定為未被訪問過的物件)。

誤報並沒有什麼傷害,Java 虛擬機器至多損失了部分垃圾回收的機會。漏報則比較麻煩,因為垃圾回 收器可能回收事實上仍被引用的物件記憶體。一旦從原引用訪問已經被回收了的物件,則很有可能會 直接導致 Java 虛擬機器崩潰。

1.3 Stop-the-world 以及安全點

怎麼解決這個問題呢?在 Java 虛擬機器裡,傳統的垃圾回收演算法採用的是一種簡單粗暴的方式,那便 是 Stop-the-world,停止其他非垃圾回收執行緒的工作,直到完成垃圾回收。這也就造成了垃圾回收 所謂的暫停時間(GC pause)。

Java 虛擬機器中的 Stop-the-world 是通過安全點(safepoint)機制來實現的。當 Java 虛擬機器收到 Stop-the-world 請求,它便會等待所有的執行緒都到達安全點,才允許請求 Stop-the-world 的執行緒 進行獨佔的工作。

safepoint 安全點顧名思義是指一些特定的位置,當執行緒執行到這些位置時,執行緒的一些狀態可以被確定(the thread's representation of it's Java machine state is well described),比如記錄OopMap的狀態,從而確定GC Root的資訊,使JVM可以安全的進行一些操作,比如開始GC。

safepoint指的特定位置主要有:

  1. 迴圈的末尾 (防止大迴圈的時候一直不進入safepoint,而其他執行緒在等待它進入safepoint)
  2. 方法返回前
  3. 呼叫方法的call之後
  4. 丟擲異常的位置

之所以選擇這些位置作為safepoint的插入點,主要的考慮是“避免程式長時間執行而不進入safepoint”,比如GC的時候必須要等到Java執行緒都進入到safepoint的時候VMThread才能開始執行GC。

參考資料:
聊聊JVM(六)理解JVM的safepoint
JVM原始碼分析之安全點safepoint

2. JVM垃圾回收演算法

常見的垃圾回收演算法包括:標記-清除演算法,複製演算法,標記-整理演算法,分代收集演算法。

2.1 標記—清除演算法(Mark-Sweep)

之所以說標記/清除演算法是幾種GC演算法中最基礎的演算法,是因為後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。標記/清除演算法的基本思想就跟它的名字一樣,分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。

標記階段:標記的過程其實就是前面介紹的可達性分析演算法的過程,遍歷所有的GC Roots物件,對從GC Roots物件可達的物件都打上一個標識,一般是在物件的header中,將其記錄為可達物件;

清除階段:清除的過程是對堆記憶體進行遍歷,如果發現某個物件沒有被標記為可達物件(通過讀取物件header資訊),則將其回收。

不足:

  • 標記和清除過程效率都不高
  • 會產生大量碎片,記憶體碎片過多可能導致無法給大物件分配記憶體。

JVM學習(二)——GC垃圾回收機制

2.2 複製演算法(Copying)

將記憶體劃分為大小相等的兩塊,每次只使用其中一塊,當這一塊記憶體用完了就將還存活的物件複製到另一塊上面,然後再把使用過的記憶體空間進行一次清理。

現在的商業虛擬機器都採用這種收集演算法來回收新生代,但是並不是將記憶體劃分為大小相等的兩塊,而是分為一塊較大的 Eden 空間和兩塊較小的 Survior 空間,每次使用 Eden 空間和其中一塊 Survivor。在回收時,將 Eden 和 Survivor 中還存活著的物件一次性複製到另一塊 Survivor 空間上,最後清理 Eden 和 使用過的那一塊 Survivor。HotSpot 虛擬機器的 Eden 和 Survivor 的大小比例預設為 8:1,保證了記憶體的利用率達到 90 %。如果每次回收有多於 10% 的物件存活,那麼一塊 Survivor 空間就不夠用了,此時需要依賴於老年代進行分配擔保,也就是借用老年代的空間。

不足:

  • 將記憶體縮小為原來的一半,浪費了一半的記憶體空間,代價太高;如果不想浪費一半的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。
  • 複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。

JVM學習(二)——GC垃圾回收機制

2.3 標記—整理演算法(Mark-Compact)

標記—整理演算法和標記—清除演算法一樣,但是標記—整理演算法不是把存活物件複製到另一塊記憶體,而是把存活物件往記憶體的一端移動,然後直接回收邊界以外的記憶體,因此其不會產生記憶體碎片。標記—整理演算法提高了記憶體的利用率,並且它適合在收集物件存活時間較長的老年代。

不足:

效率不高,不僅要標記存活物件,還要整理所有存活物件的引用地址,在效率上不如複製演算法。

JVM學習(二)——GC垃圾回收機制

2.4 分代收集演算法(Generational Collection)

分代回收演算法實際上是把複製演算法和標記整理法的結合,並不是真正一個新的演算法,一般分為:老年代(Old Generation)和新生代(Young Generation),老年代就是很少垃圾需要進行回收的,新生代就是有很多的記憶體空間需要回收,所以不同代就採用不同的回收演算法,以此來達到高效的回收演算法。

新生代:由於新生代產生很多臨時物件,大量物件需要進行回收,所以採用複製演算法是最高效的。

老年代:回收的物件很少,都是經過幾次標記後都不是可回收的狀態轉移到老年代的,所以僅有少量物件需要回收,故採用標記清除或者標記整理演算法。

3. JVM中的GC過程

3.1 JVM中的堆分割槽

在 Java 中,堆被劃分成兩個不同的區域:新生代 ( Young )、老年代 ( Old )。 預設的,新生代 ( Young ) 與老年代 ( Old ) 的比例的值為 1:2( 該值可以通過引數 –XX:NewRatio 來指定 )
新生代 ( Young ) 又被劃分為 三個區域:Eden、From Survivor、To Survivor。這樣劃分的目的是為了使 JVM 能夠更好的管理堆記憶體中的物件,包括記憶體的分配以及回收。

3.1.1 新生代

主要是用來存放新生的物件。一般佔據堆的1/3空間。由於頻繁建立物件,所以新生代會頻繁觸發MinorGC進行垃圾回收。 新生代又分為 Eden區、ServivorFrom、ServivorTo三個區,預設比例8:1:1

  • Eden區:Java新物件的出生地(如果新建立的物件佔用記憶體很大,則直接分配到老年代)。當Eden區記憶體不夠的時候就會觸發MinorGC,對新生代區進行一次垃圾回收。
  • ServivorTo:保留了一次MinorGC過程中的倖存者。
  • ServivorFrom:上一次GC的倖存者,作為這一次GC的被掃描者。

MinorGC的過程:MinorGC採用複製演算法。首先,把Eden和ServivorFrom區域中存活的物件複製到ServicorTo區域(如果有物件的年齡以及達到了老年的標準,則賦值到老年代區),同時把這些物件的年齡+1(如果ServicorTo不夠位置了就放到老年區);然後,清空Eden和ServicorFrom中的物件;最後,ServicorTo和ServicorFrom互換,原ServicorTo成為下一次GC時的ServicorFrom區。

JVM學習(二)——GC垃圾回收機制

3.1.2 老年代

老年代的物件比較穩定,所以fullGC不會頻繁執行。在進行fullGC前一般都先進行了一次MinorGC,使得有新生代的物件晉身入老年代,導致空間不夠用時才觸發。當無法找到足夠大的連續空間分配給新建立的較大物件時也會提前觸發一次fullGC進行垃圾回收騰出空間。
fullGC根據不同垃圾回收器採用標記—清除演算法或標記-整理演算法:首先掃描一次所有老年代,標記出存活的物件,然後回收沒有標記的物件。fullGC的耗時比較長,因為要掃描再回收。fullGC會產生記憶體碎片,為了減少記憶體損耗,我們一般需要進行整理或者標記出來方便下次直接分配。
當老年代也滿了裝不下的時候,就會丟擲OOM(Out of Memory)異常。

3.2 JVM物件分配策略

3.2.1 物件優先在Eden分配

大多數情況下,物件在新生代Eden區中分配,當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC。

3.2.2 大物件直接進入年老代

大物件即需要大量連續記憶體空間的Java物件,如長字串及陣列。經常出現大物件導致記憶體還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來安置他們。 虛擬機器提供了一個-XX:PretenureSizeThreshold引數,令大於這個設定值的物件直接在老年代分配。 這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的記憶體複製(新生代採用複製演算法收集記憶體)。

3.2.3 長期存活的物件將進入年老代

虛擬機器給每個物件定義了一個物件年齡計數器,在物件在Eden建立並經過第一次Minor GC後仍然存活,並能被Suivivor容納的話,將會被移動到Survivor空間,並物件年齡設定為1。每經歷過Minor GC,年齡就增加1歲,當到一定程度(預設15歲,可以通過引數-XXMaxTenuringThreshold設定),就將會晉升年老代。

3.2.4 動態物件年齡判定

為了更好地適應不同程式記憶體狀況,虛擬機器並不硬性要求物件年齡達到MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入年老代。

3.2.5 空間分配擔保

在發生Minor GC之前,虛擬機器會先檢查年老代最大可用的連續空間是否大於新生代所有物件的總空間。

  • 如果條件成立,那麼Minor GC可以確保是安全的。
  • 如果不成立,則虛擬機器會檢視HandlePromotionFailure設定值是否允許擔保失敗。
    • 如果允許,那麼會繼續檢查年老代最大可用連續空間是否大於歷次晉升到年老代物件的平均大小,如果大於,將嘗試進行一次Minor GC,儘管這次Minor GC是有風險的。
    • 如果小於,或者HandlePromotionFailure設定不允許冒險,那這時候改為進行一次Full GC。

下面解釋一下“冒險”是冒了什麼風險,新生代使用複製收集演算法,但為了記憶體利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量物件在MinorGC後仍然存活的情況(最極端的情況就是記憶體回收後新生代中所有物件都存活),就需要老年代進行分配擔保,把Survivor無法容納的物件直接進入老年代。

與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些物件的剩餘空間,一共有多少物件會活下來在實際完成記憶體回收之前是無法明確知道的,所以只好取之前每一次回收晉升到老年代物件容量的平均大小值作為經驗值,與老年代的剩餘空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。

取平均值進行比較其實仍然是一種動態概率的手段,也就是說,如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然會導致擔保失敗(Handle Promotion Failure)。

如果出現了HandlePromotionFailure失敗,那就只好在失敗後重新發起一次Full GC。 雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會將HandlePromotionFailure開關開啟,避免Full GC過於頻繁。

3.3 圖解JVM GC過程

這是我在學習過程中,發現的一個簡單易懂的GC過程學習文章,通過圖的方式,清晰明瞭。圖解JVM GC過程

4. JVM中的垃圾回收器

4.1 七種垃圾回收器

JVM學習(二)——GC垃圾回收機制
在瞭解垃圾回收器之前先講兩個容易混淆的概念:

  • 並行:指多條垃圾收集執行緒並行工作,但此時使用者執行緒仍然處於等待狀態
  • 併發:指使用者執行緒與垃圾收集執行緒同時執行(不一定是並行的,可能會交替執行),使用者程式在繼續執行,而垃圾收集程式執行於另一個CPU上

這幾篇是我在學習過程中,覺得講得不錯的文章。

CMS和G1詳解
JVM之幾種垃圾收集器概括介紹
JVM 垃圾回收演算法及回收器詳解

我將學習資料中的內容簡單概括為下表:

名字 特點 執行緒 回收區域 回收演算法
Serial收集器 最高的單執行緒收集效率 單執行緒 新生代 複製
ParNew收集器 可以認為是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現。 多執行緒 新生代 複製
Parallel Scavenge收集器 關注系統吞吐量,目標是達到一個可控制的吞吐量,也經常稱為“吞吐量優先”收集器 多執行緒 新生代 複製
Serial Old收集器 年老代收集器,可以和所有的年輕代收集器組合使用(Serial收集器的年老代版本) 單執行緒 老年代 標記-整理
Parallel Old收集器 Parallel Scavenge收集器的老年代版本,關注吞吐量,這個收集器是在JDK 1.6中才開始提供的。 多執行緒 老年代 標記-整理
CMS收集器 一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在網際網路站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS收集器存在3個缺點:1.對CPU資源敏感。一般併發執行的程式對CPU數量都是比較敏感的。2.無法處理浮動垃圾。在併發清理階段使用者執行緒還在執行,這時產生的垃圾無法清理。3.由於標記-清除演算法產生大量的空間碎片此外除了CMS的GC,其實其他針對old gen的回收器都會在對old gen回收的同時回收young gen。 多執行緒併發收集 老年代 標記-清除
G1收集器 1.可以像CMS收集器一樣,GC操作與應用的執行緒一起併發執行。2.緊湊的空閒記憶體區間且沒有很長的GC停頓時間(標記整理演算法,複製演算法)。3.需要可預測的GC暫停耗時。4.不想犧牲太多吞吐量效能。5.啟動後不需要請求更大的Java堆。 多執行緒併發收集 整個Java堆 標記-整理

4.2 各垃圾收集引數設定

  • -Xmx: 設定堆記憶體的最大值。
  • -Xms: 設定堆記憶體的初始值。
  • -Xmn: 設定新生代的大小。
  • -Xss: 設定棧的大小。
  • -PretenureSizeThreshold: 直接晉升到老年代的物件大小,設定這個引數後,大於這個引數的物件將直接在老年代分配。
  • -MaxTenuringThrehold: 晉升到老年代的物件年齡。每個物件在堅持過一次Minor GC之後,年齡就會加1,當超過這個引數值時就進入老年代。
  • -UseAdaptiveSizePolicy: 在這種模式下,新生代的大小、eden 和 survivor 的比例、晉升老年代的物件年齡等引數會被自動調整,以達到在堆大小、吞吐量和停頓時間之間的平衡點。在手工調優比較困難的場合,可以直接使用這種自適應的方式,僅指定虛擬機器的最大堆、目標的吞吐量 (GCTimeRatio) 和停頓時間 (MaxGCPauseMills),讓虛擬機器自己完成調優工作。
  • -SurvivorRattio: 新生代Eden區域與Survivor區域的容量比值,預設為8,代表Eden: Suvivor= 8: 1。
  • -XX:ParallelGCThreads:設定用於垃圾回收的執行緒數。通常情況下可以和 CPU 數量相等。但在 CPU 數量比較多的情況下,設定相對較小的數值也是合理的。
  • -XX:MaxGCPauseMills:設定最大垃圾收集停頓時間。它的值是一個大於 0 的整數。收集器在工作時,會調整 Java 堆大小或者其他一些引數,儘可能地把停頓時間控制在 MaxGCPauseMills 以內。
  • -XX:GCTimeRatio:設定吞吐量大小,它的值是一個 0-100 之間的整數。假設 GCTimeRatio 的值為 n,那麼系統將花費不超過 1/(1+n) 的時間用於垃圾收集。
    JVM學習(二)——GC垃圾回收機制

JVM學習(二)——GC垃圾回收機制

相關文章