JVM(三)----垃圾收集演算法及Safe Point介紹

weixin_33785972發表於2018-09-24

JVM(一)---- 總結與專題目錄
JVM(二)----Java執行時資料區域
JVM(三)----垃圾收集演算法及Safe Point介紹
JVM(四)----HotSpot的垃圾收集器與記憶體分配回收策略
JVM(五)----虛擬機器類載入機制

本文的內容如下:

  • 如何判斷物件是否存活
  • 強軟弱虛引用
  • 垃圾收集演算法
  • HotSpot的演算法實現
  • safe point 和safe region介紹

一、判斷物件是否存活(Which?)

垃圾收集器在對堆進行回收之前,第一件事情就是要確定這些物件之中哪些還存活著,哪些已經死去。

1.1.引用計數演算法

每個物件有一個引用計數屬性,新增一個引用時計數加1,引用釋放時計數減1,計數為0時可以回收。此方法簡單,無法解決物件相互迴圈引用的問題。

1.2 可達性分析演算法

此演算法可以解決迴圈引用問題。從GC Roots開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的,是不可達物件。

在Java語言中,GC Roots包括:
虛擬機器棧中引用的物件。
方法區中類靜態屬性實體引用的物件。
方法區中常量引用的物件。
本地方法棧中JNI引用的物件。

5679451-fdcb334baf745d77.png
image.png

1.3 強、軟、弱、虛引用

在JDK1.2以前的版本中,當一個物件不被任何變數引用,那麼程式就無法再使用這個物件。也就是說,只有物件處於可觸及狀態,程式才能使用它。這就像在日常生活中,從商店購買了某樣物品後,如果有用,就一直保留它,否則就把它扔到垃圾箱,由清潔工人收走。一般說來,如果物品已經被扔到垃圾箱,想再把它撿回來使用就不可能了。
但有時候情況並不這麼簡單,你可能會遇到類似雞肋一樣的物品,食之無味,棄之可惜。這種物品現在已經無用了,保留它會佔空間,但是立刻扔掉它也不划算,因 為也許將來還會派用場。對於這樣的可有可無的物品,一種折衷的處理辦法是:如果家裡空間足夠,就先把它保留在家裡,如果家裡空間不夠,即使把家裡所有的垃圾清除,還是無法容納那些必不可少的生活用品,那麼再扔掉這些可有可無的物品。
從JDK1.2版本開始,把物件的引用分為四種級別,從而使程式能更加靈活的控制物件的生命週期。這四種級別由高到低依次為:強引用、軟引用、弱引用和虛引用。

強引用:
平時我們程式設計的時候例如:Object object=new Object();那object就是一個強引用了。如果一個物件具有強引用,那就類似於必不可少的生活用品,垃圾回收器絕不會回收它。當記憶體空間不足,Java虛擬機器寧願丟擲OutOfMemoryError錯誤,使程式異常終止,也不會靠隨意回收具有強引用的物件來解決記憶體不足問題。

軟引用:
如果一個物件只具有軟引用,那就類似於可有可無的生活用品。如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些物件的記憶體。只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體。 軟引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收,Java虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。

弱引用:
如果一個物件只具有弱引用,那就類似於可有可無的生活用品。弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。在垃圾回收器執行緒掃描它 所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。不過,由於垃圾回收器是一個優先順序很低的執行緒, 因此不一定會很快發現那些只具有弱引用的物件。 弱引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果弱引用所引用的物件被垃圾回收,Java虛擬機器就會把這個弱引用加入到與之關聯的引用佇列中。

虛引用:
"虛引用"顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定物件的生命週期。如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。 虛引用主要用來跟蹤物件被垃圾回收的活動。虛引用與軟引用和弱引用的一個區別在於:虛引用必須和引用佇列(ReferenceQueue)聯合使用。當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。程式如果發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用的物件的記憶體被回收之前採取必要的行動。

二、垃圾收集演算法(How?)

知道了要收集那些垃圾物件後,怎麼收集呢?這就需要一些垃圾收集演算法了。

2.1 標記清除演算法

最基本的收集演算法“標記-清除”(Mark-Sweep)演算法,演算法分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件,之所以說它是最基本的收集演算法,是因為後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。
它的主要不足有兩個:一是效率問題,標記和清除效率都不高,二是空間問題,標記清除後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後程式在執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。執行過程如下:


5679451-f8fbc64ac6e14431.png
image.png

2.2 複製演算法

為了解決效率問題,一種稱為“複製”(Copying)的收集演算法出現了,他將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這塊的記憶體用完了,就將還存活這的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。只是這種演算法的代價是將記憶體縮小為了原來的一半,未免太高了一點。現代的商業虛擬機器都採用這種演算法進行垃圾回收,但是不是分為大小相等的兩塊,而是分為一塊較大的Eden和兩塊較小的Survivor區域,每次使用一塊Eden和Survivor區域,把存活的複製到另外一塊Survivor區域,然後清理剛才使用的Eden和Survivor。HotSpot虛擬機器預設Eden和Survivor的的大小比例是8:1.

5679451-a79490eaa0fe4fe3.png
image.png

2.3 標記整理演算法

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是如果不想浪費50%的空間就要使用額外的空間進行分配擔保(Handle Promotion當空間不夠時,需要依賴其他記憶體),以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。
對於“標記-整理”演算法,標記過程仍與“標記-清除”演算法一樣,但是後續步驟不是直接對可回收物件進行清理,而是讓所有的存活物件都向一端移動,然後直接清理掉端邊界以外的記憶體,”標記-整理“演算法示意圖如下:


5679451-59645f8628617ef6.png
image.png

2.4 分代收集演算法

當前的商業虛擬機器的垃圾收集都是採用“分代收集”(Generational Collection)演算法,這種演算法並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。一般是把堆劃分為新生代和老年代,這樣就可以根據各個年代的特點採用最適合的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就採用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或者“標記-整理”演算法來進行回收。

三、HotSpot的演算法實現

3.1 列舉根節點

通過前面的介紹,我們知道,在分析一個物件是否是存活的時候有兩種方法,一個是引用計數法,引用計數法雖然實現簡單並且效率較高,但是很難解決迴圈引用。所以目前主流的虛擬機器都是使用的是:可達性分析法。在可達性分析法中物件能被回收的條件是沒有引用來引用它,要做到這點就需要得到所有的GC Roots節點,來從GC Root來遍歷。可作為GC Root的主要是全域性性引用(例如常量和靜態變數),與執行上下文(棧幀中的本地變數表)中。那麼如何在這麼多的全域性變數和棧中的區域性變數表中找到棧上的根節點呢?

在棧中只有一部分資料是Reference(引用)型別,那些非Reference的型別的資料對於找到根節點沒有什麼用處,如果我們對棧全部掃描一遍這是相當浪費時間和資源的事情。

那怎麼做可以減少回收時間呢?我們很自然的想到可以用空間來換取時間,我們可以在某個位置把棧上代表引用的位置記錄下來,這樣在gc發生的時候就不用全部掃描了,在HotSpot中使用的是一種叫做OopMap的資料結構來記錄的。對於OopMap可以簡單的理解成:它記錄著物件內什麼偏移量上是什麼型別的資料。

3.2 安全點(Safe point)

在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots列舉,但我們也不能為每一條指令都生成OopMap,那樣一方面會需要更多的空間來存放這些物件,另一方面效率也會低。所以,只會在特定的位置記錄這些資訊,這些特定位置稱為安全點(Safe point),即程式執行時並非在所有地方都能停頓下來GC,只有在到達安全點時才能暫停。

從執行緒角度看,safepoint可以理解成是在程式碼執行過程中的一些特殊位置,當執行緒執行到這些位置的時候,說明虛擬機器當前的狀態是安全的,如果有需要,可以在這個位置暫停,比如發生GC時,需要暫停暫停所以活動執行緒,但是執行緒在這個時刻,還沒有執行到一個安全點,所以該執行緒應該繼續執行,到達下一個安全點的時候暫停,等待GC結束。

什麼地方可以放safepoint?

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

之所以選擇這些位置作為safepoint的插入點,主要的考慮是“避免程式長時間執行而不進入safepoint”,比如GC的時候必須要等到Java執行緒都進入到safepoint的時候VMThread才能開始執行GC,如果程式長時間執行而沒有進入safepoint,那麼GC也無法開始,JVM可能進入到Freezen假死狀態。

知道了safe point的概念後,怎麼使執行緒都“跑”到最近的安全點上停下來呢。這裡有兩種方式:搶先式中斷和主動式中斷。

  • 搶先式中斷
    在GC發生時先中斷所有執行緒,如果執行緒不在安全點上,則啟動該執行緒使其執行到安全點後掛起。幾乎已沒有虛擬機器只用此種方式

  • 主動式中斷
    不需要直接對執行緒進行操作,僅僅簡單設定一個標識,線上程執行時主動輪詢這個標識,若中斷標識為真,執行緒自己中斷掛起。這個標識和安全點是重合的。

3.3安全區域(safe region)

上面的安全點檢查彷彿完全解決了如何進入GC的問題,但只有安全點還是不夠的,安全點只解決了那些在執行的程式,保證了他們可以執行到安全點並掛起,但如果有些執行緒此時並未執行,例如處於sleep或blocked狀態的執行緒,就無法響應JVM的中斷請求,這是就用到了安全區域。

定義:
安全區域是指在此區域內,物件的引用關係不會發生變化(即不會影響列舉根節點)

原理:
當執行緒執行到安全區域時會將自己標識,在JVM準備進行GC時將視這些執行緒為安全的,不影響GC,當執行緒執行完畢要離開安全區域時,執行緒會檢查JVM是否在列舉根節點,若是,則等待完成後再離開安全區域繼續執行。

補充:引自佔小狼的文章

Safe Point對JVM效能有什麼影響?
通過設定JVM引數 -XX:+PrintGCApplicationStoppedTime, 可以打出系統停止的時間,大概如下:

Total time for which application threads were stopped: 0.0051000 seconds  
Total time for which application threads were stopped: 0.0041930 seconds  
Total time for which application threads were stopped: 0.0051210 seconds  
Total time for which application threads were stopped: 0.0050940 seconds  
Total time for which application threads were stopped: 0.0058720 seconds  
Total time for which application threads were stopped: 5.1298200 seconds
Total time for which application threads were stopped: 0.0197290 seconds  
Total time for which application threads were stopped: 0.0087590 seconds

從上面資料可以發現,有一次暫停時間特別長,達到了5秒多,這線上上環境肯定是無法忍受的,那麼是什麼原因導致的呢?

一個大概率的原因是當發生GC時,有執行緒遲遲進入不到safepoint進行阻塞,導致其他已經停止的執行緒也一直等待,VM Thread也在等待所有的Java執行緒掛起才能開始GC,這裡需要分析業務程式碼中是否存在有界的大迴圈邏輯,可能在JIT優化時,這些迴圈操作沒有插入safepoint檢查。

參考資料:https://www.jianshu.com/p/c79c5e02ebe6

相關文章