1. 程式人生 > >詳解JVM垃圾收集演算法&垃圾收集器(圖解)

詳解JVM垃圾收集演算法&垃圾收集器(圖解)

垃圾收集演算法:記憶體回收的方法論

垃圾收集器:記憶體回收的具體實現

在正式討論垃圾回收演算法和垃圾收集器之前,我們應該瞭解一下JVM是如何判斷一個物件已經死亡的?
JVM主要使用了兩種方法判斷物件是否已經死亡:
  1. 引用計數法:這種方法實現起來簡單,而且也好理解,就是給物件維護了一個計數器,當有一個地方引用它的時候,它就加一,當引用失效的時候計數器就減一。當這個計數器的值為0的時候,那麼就可以判斷該物件可以被清理。
  2. 可達性分析演算法:該方法的基本思想就是通過一些”GC Roots” 物件作為起始點,從這些節點開始向下搜尋。就像一棵樹結構一樣,從它的根節點開始搜尋,若某些物件不屬於這些樹的子節點,那麼就可以判定這些物件為不可達。可以被清理。(在我們下面介紹的垃圾回收演算法中,主要是使用了這種方法判斷物件是否需要被回收)

    注:當然,實際的判斷方法沒有這麼簡單,這裡只是簡單的介紹一下。

OK,接下來先說垃圾回收演算法

1. 標記清除演算法(Mark-Sweep)

    該演算法就和他的名字一樣,分為標記和清除兩個階段。首先標記出所有需要回收的物件,在標記完成之後統一回收所有被標記的物件。

圖解:

回收前 (黑色:可回收 | 灰色:存活物件 | 白色:未使用 )
這裡寫圖片描述

回收後:
這裡寫圖片描述

從上面的圖解中我們就可以看出:
1. 這種演算法在收集結束後,記憶體是亂七八糟的(也就是產生了很多記憶體碎片),這種記憶體碎片太多的話,會導致我們在下一次分配較大物件的時候,無法找到足夠的連續記憶體,不得不觸發另外一次垃圾回收。
2. 還有就是其實,標記和清理的過程的效率都是不高的。

該演算法適合於那些物件存活率較高的記憶體區域的收集工作。
2. 複製演算法(Copying)

    該演算法的出現是為了解決效率問題。它將記憶體劃分為等大的兩份,每次只是使用其中的一塊。當一塊用完了,就將還活著的物件複製到另外一塊上,完後再將已經使用過的那一塊一次性清除。

圖解:

回收前 (黑色:可回收 | 淺灰色:存活物件 | 白色:未使用 | 深灰色:保留區域 )
這裡寫圖片描述
回收後:
這裡寫圖片描述

演算法優點:這種演算法不存在出現垃圾碎片的情況,再分配較大物件時候也不會有無法找到足夠記憶體的情況,實現簡單,執行高效。
演算法缺點:使用該演算法的代價就是直接將記憶體縮小一半,代價有點高。其次,再某些情況下需要進行很大規模的複製動作,會直接影響到效率。

該演算法適合於那些物件存活率較低的記憶體區域的收集工作。
3. 標記整理演算法(Mark Compact)

    標記整理和標記清除的非常相似,但是標記整理的過程是這樣的,首先是標記要清理的物件,然後將剩下所有存活的物件都移動到一端,然後直接清理端邊界以外的記憶體。其實也就是標記-整理-清除演算法,多了一個對記憶體的整理的過程。

圖解:

回收前回收前 (黑色:可回收 | 灰色:存活物件 | 白色:未使用 )
這裡寫圖片描述
回收後:
這裡寫圖片描述

大家可以在圖示中很清楚的看到,該種收集演算法的好處就是首先沒有像複製演算法那樣浪費掉一半的空間,然後收集後的記憶體也非常的整潔,沒有記憶體碎片。
相比標記清除,它能很好的整理記憶體,但同時也多了整理記憶體的代價。

該演算法適合於那些物件存活率較高的記憶體區域的收集工作。
4. 分代收集演算法(Generational Collection)

    這種演算法其實並沒有什麼新的思想,只是根據物件存活週期的不同將記憶體劃分為幾塊。就是將Java堆劃分為新生代和老年代,新生代中每次收集都會有大量的物件死去,所以建議採用複製演算法,只需要付出較少的複製成本就能完成收集。但是老年代中因為物件的存活率高且沒有額外的空間對它進行分配擔保。所以雖好,使用標記-清理或者標記整理演算法來進行收集。

接下來就是垃圾收集器了

這裡寫圖片描述

    上圖所示的就是接下來要介紹的集中垃圾收集器,上圖中用線連線起來的兩個垃圾收集器之間是可以搭配使用的。

1. Serial:基本、歷史悠久

    該收集器是一個單執行緒的收集器。這裡的單執行緒收集器並不是說明它只會使用一個CPU或者使用一條執行緒去完成垃圾的收集工作,更為重要的是在它進行垃圾收集的時候需要”Stop the World”直到它的工作結束。
優點:簡單高效、在單個CPU環境來說,Serial沒有執行緒互動的開銷,專心做自然高效率。
缺點:Stop the World 影響使用者體驗!

執行圖示

這裡寫圖片描述

2. ParNew:Serial的多執行緒版本

    該執行緒除了使用多條執行緒進行垃圾收集之外,其餘的東西和行為和Serial是一模一樣的。雖然說沒有多大的創新,但是卻是許多執行在Server模式下虛擬機器目前首選的新生代垃圾收集器,其主要原因是,它可以於另外一個吊炸天的老年代的垃圾收集器CMS配合使用。
優點:同樣具備Serial的優點。
缺點:Stop the World 影響使用者體驗。

執行圖示

這裡寫圖片描述

插入:在談論垃圾收集器色上下文語境中,併發並行的理解如下:

並行:多條垃圾收集執行緒並行工作,但此時的使用者執行緒仍處於等待狀態。
併發:使用者執行緒和垃圾收集執行緒同時處理,使用者執行緒在繼續執行、而垃圾收集程式執行在另一個CPU。

3. Parallel Scavenge:作用於新生代、使用複製演算法、多執行緒

    從表面看,該收集器和ParNew是非常相似的,但其實是有所區別的。他們主要的區別在於他們的關注點不同,CMS或者ParNew等收集器的關注點主要在於縮短垃圾收集時使用者執行緒的停頓時間(縮短 Stop The World的時間),但是Parallel Scavenge的關注點是去達到一個可以控制的吞吐量。(吞吐量=執行使用者程式碼的時間/(執行使用者程式碼的時間+垃圾收集時間))。
    分析:停頓的時間越短就越適合需要與使用者互動的程式,但是吞吐量高則可以高效的利用CPU時間,儘快完成程式的任務,主要適合在後臺運算而不需要太多互動的任務。

4. Serial Old:Serial的老年代版本、單執行緒、標記-整理演算法
    工作原理與Serial基本一致。主要作用於Client端。在server端主要有兩種用途:

    1. JDK1.5之前,與Parallel Scavenge搭配使用
    2. 作為CMS收集器的後備預案,併發收集發生Concurrent Mode Failure時使用。

執行圖示

這裡寫圖片描述

5. Parallel Old收集器:Parallel Scavenge的老年版、多執行緒、標記-整理、JDK1.6中開始加入
    主要是搭配Parallel Scavenge使用,可以使得整個系統的吞吐量達到最大化的優化,在注重吞吐量和CPU資源敏感的場合,可以優先考慮Parallel套件。
執行圖示

這裡寫圖片描述

6. CMS收集器(Concurrent Mark Sweap):併發收集、低停頓、標記-清除、JDK1.6中加入

該收集器意在獲取最短的停留時間,多應用在B/S結構的服務端上,該收集器的運作過程。

  1. 初始標記(CMS initial mark)
  2. 併發標記(CMS concurrent mark)
  3. 重新標記(CMS remark)
  4. 併發清除(CMS concurrent sweap)

    在上述的四個過程中:併發標記和重新標記兩個步驟還是需要”Stop The World”。初始標記只是標記一下 GCRoots 不能直接關聯到的物件,速度很快,併發標記階段就是進行 GC Roots Tracing 的過程。併發標記就相當於再次去判斷該物件是否已經死亡的過程,因為物件是非常可能”復活”的。
    其中的併發標記和併發清除步驟:從總體上來說是和使用者一起來工作的。所以,這也就是它最大的優點。

    但是它並不完美,它主要有以下三個缺點:
1.對CPU資源敏感

    CMS預設啟動的回收執行緒數是 (CPU數量+3)/4,也就是CPU在4個以上時,併發回收時垃圾執行緒不少於25%的CPU資源,並且隨著CPU數的上升而下降。但是,當CPU數不足4個時,比如2個,CMS對使用者程式的影響就比較大了,CPU需要分出一半以上的資源供給垃圾回收,使用者的程式執行速度會降低50%以上。為了應對這種情況發生,虛擬機器提供了一種稱為”增量式併發收集器”(Incremental Concurrent Mark Sweap / i-CMS)的變種CMS收集器,但在實際應用中效果不理想,現在已經不提倡使用。

2.無法處理浮動垃圾,可能會出現(Concurrent Mode Failure)失敗而導致的另一次Full GC產生。

    由於CMS在併發清理階段使用者的執行緒仍在執行。伴隨著程式的執行指定會產生新的垃圾,這種垃圾出現在標記過程之後,CMS無法在當此處理中處理掉他們,這些就是浮動垃圾。同樣因為,在清理過程中使用者執行緒也得跑,就需要預留有足夠的記憶體空間給使用者使用。如果執行的過程中的預留的空間不夠使用,那麼就會發生(Concurrent Mode Failure),觸發一次Full GC,臨時啟用 Serial Old收集器重新對老年代進行收集。這樣的停頓時間過長。

3.會產生大量的記憶體碎片。

    由於該收集器使用的標記-清除收集演算法,就會不可避免的產生大量的記憶體碎片。給較大的物件分配帶來了很大的麻煩。

執行圖示:

這裡寫圖片描述

7. G1收集器:當今收集器技術發展最前沿的成果之一
它具有如下特點:

    1.併發與並行:G1充分利用多CPU,多核環境下的硬體優勢,使用多個CPU來縮短 Stop-The-World停頓的時間,其它收集器需要停頓Java執行緒執行的GC動作,G1仍然可以通過併發的方式讓Java程式繼續執行。
    2.分代收集:分代的概念在G1中仍然得以保留。雖然G1可以不需要其它收集器的配合就能獨立管理整個GC堆。
    3.空間整合:與CMS的標記清除不同,G1從整體上看是基於“標記清理”演算法實現的收集器。從區域性(兩個Region)之間上來看是基於“複製”演算法實現的。無論如何,結論就是G1執行期間不會產生記憶體碎片。
    4.可預測的停頓:G1除了追求低停頓之外,還能建立可預測的停頓時間模型,可以讓使用者明確指定一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不能超過N毫秒。

記憶體佈局的變化:

    G1之前的其它收集器進行收集的範圍都是整個新生代或者老年代,但是G1不再這樣。—->他將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還有新生代和老年代的概念,但是新生代和老年代不再是物理隔離,他們都是一部分Region的集合。

G1做了什麼?讓它具有了能建立可預測的停頓時間模型?

    首先,G2會跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需的時間的經驗值),在後臺維護一個優先列表,每側根據允許的收集時間,優先收集經驗值較大的Region。這種具有一定的優先順序的回收策略,使得G1在有效的時間裡可以獲得儘可能大的回收效率。
    Java堆分為多個Region之後,那麼在處理物件間依賴這個問題就自然而然的出現了。在G1中,Region之間的物件引用還有其它版本收集器中的新生代和老年代之間的物件引用,虛擬機器都是使用Remembered Set來避免全堆掃描的。

具體使用 Remembered Set 怎麼實現避免全堆掃描?

    G1中每個Region中都有一個與之對應的Remembered Set ,虛擬機發現程式在對 Reference型別的資料進行寫操作時。會產生一個 Write Barrier 暫時中斷操作,檢查 Reference 引用的物件是否處於不同的Region中,如果是,便通過 CardTable 吧相關的引用資訊記錄到被引用物件的Region的 Remembered Set 中。當有GC操作時,在GC Roots 的列舉範圍中加入 Remembered Set 即可保證不對全堆掃描,且不會有遺漏。

G1收集器執行的大概過程(忽略維護 Remembered Set)
  1. 初始標記(Initial Marking)
  2. 併發標記(Concurrent Marking)
  3. 最終標記(Final Marking)
  4. 篩選回收(Live Data Counting and Evacuation)

· 初始標記僅僅只能標記一下 GC Roots節點能直接關聯到的物件。
· 併發標記是從 GC Roots 開始對堆中的物件進行可達性分析,找出存活的物件,這階段耗時較長,但是可以使用者程式併發執行。
· 最終標記是為了修正在併發標記過程中因為使用者程式繼續運作而導致標記產生變動的那一部分記錄。JVM將變動記錄在 Remembered Set Logs中,最終將 Remembered Set LogsRemembered Set 合併。雖需停頓,但是可並行執行。
· 篩選回收,首先對各個Rgion的回收價值和成本進行排序,根據使用者所期望的GC停頓時間制定回收計劃。

執行圖示

這裡寫圖片描述

終:上面就是我要說的垃圾收集器和垃圾回收演算法,如果有問題還需大神指出。