1. 程式人生 > >你必須瞭解的java記憶體管理機制(四)-垃圾回收

你必須瞭解的java記憶體管理機制(四)-垃圾回收

本文在個人技術部落格不同步釋出,詳情可用力戳
亦可掃描螢幕右側二維碼關注個人公眾號,公眾號內有個人聯絡方式,等你來撩...

相關連結(注:文章講解JVM以Hotspot虛擬機器為例,jdk版本為1.8)
1、 你必須瞭解的java記憶體管理機制-執行時資料區
2、 你必須瞭解的java記憶體管理機制-記憶體分配
3、 你必須瞭解的java記憶體管理機制-垃圾標記
4、 你必須瞭解的java記憶體管理機制-垃圾回收

前言

  在前面三篇文章中,對JVM的記憶體佈局、記憶體分配、垃圾標記做了較多的介紹,垃圾都已經標記出來了,那剩下的就是如何高效的去回收啦!這篇文章將重點介紹如何回收舊手機、電腦、彩電、冰箱~啊呸(⊙o⊙)…將重點介紹幾種垃圾回收演算法、HotSpot中常用的垃圾收集器的主要特點和應用場景。同時,這篇文章也是這個系列中的最後一篇文章啦!

正文

  上一篇文章中,我們詳細介紹了兩種標記演算法,並且對可達性分析演算法做了較多的介紹。我們也知道了HotSpot在具體實現中怎麼利用OopMap+RememberedSet的技術做到“準確式GC”。不管使用什麼優化的技術,目標都是準確高效的標記回收物件!那麼,為了高效的回收垃圾,虛擬機器又經歷了哪些技術及演算法的演變和優化呢?(注:G1收集器及回收演算法本文不涉及,因為我覺得後面可以單獨寫一篇文章來談!)

回收演算法

  在這裡,我們會先介紹幾種常用的回收演算法,然後瞭解在JVM中式如何對這幾種演算法進行選擇和優化的。

標記-清除

  "標記-清除"演算法分為兩個階段,“標記”和“清除”。標記還是那個標記,在上一篇文章中已經做了較多的介紹了,JVM在執行完標記動作後,還在"即將回收"集合的物件將被統一回收。執行過程如下圖:

  

  優點:
    1、基於最基礎的可達性分析演算法,它是最基礎的收集演算法。
    2、後續的收集演算法都是基於這種思路並對其不足進行改進而得到的。
  缺點:
    1、 執行效率不高。
    2、 由上圖能看到這種回收演算法會產生大量不連續記憶體碎片,如果這時候需要建立一個大物件,則無法進行分配。

複製演算法

  “複製”演算法將記憶體按容量劃分為大小相等的兩塊,每次使用其中的一塊。當一塊的記憶體用完了,就將還存活的物件複製到另一塊上面,然後將已經使用過的儲存空間一次性清理掉,這樣每次都是針對整個半區的記憶體進行回收,不用考慮碎片問題。執行過程如下圖:

  

  優點:

    1、每次針對半個區域進行回收,實現簡單,執行高效。
    2、不會產生記憶體碎片問題。
  缺點:
    1、 記憶體會縮小為原來的一般,代價高。
    2、 當物件存活率較高時,需要進行較多複製操作,效率將會變低。

複製演算法改良版

  “複製演算法改良版”替代原來將記憶體一分為二的方案,將記憶體分為一塊較大的記憶體(稱為Eden空間)和兩塊較小的記憶體(稱為Survivor空間),每次使用Eden空間和其中一塊Survivor空間。當回收時,將Eden和其中一塊Survivor中還存活的物件一次性複製到另外一塊Survivor空間上,最後清理掉Eden和剛才使用過的Survivor空間。執行過程如下圖:

  

  優點:
    1、改善了普通複製演算法的缺點,提高了空間利用率。

標記-整理演算法

  “標記-整理”演算法的標記過程與“標記-清除”演算法是一樣一樣的,但後續步驟不是直接對可回收物件進行清理,而是讓所有的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。執行過程如下圖:

  

  優點:
    1、改善了“標記-清除”演算法會產生記憶體碎片的缺點。
    2、不會像“複製”演算法那樣效率隨物件存活率升高而變低。
  缺點:
    1、 依然沒有解決 “標記-清除”演算法存在的缺點,那就是回收效率問題。還多了需要整理的過程,效率更低。

分代收集演算法

  我們都知道,在主流的虛擬機器中都是採用分代收集演算法來進行堆記憶體的回收,在第一篇文章中我們也用了一張圖展示了JVM堆記憶體的劃分。如下:

  

  分代回收根據物件存活週期的不同將記憶體劃分為幾塊,這樣就可以根據各個年代的特點採用最適當的收集演算法。一般把Java堆分為新生代和老年代。

  新生代

  在Hotspot虛擬機器中,新生代的收集器都是採用的改良版的複製演算法進行垃圾回收。將新生代一分為三,一塊Eden區和兩塊Survivor區。Eden區與兩塊Survivor區的比例為8:1:1。這樣劃分的依據是什麼呢?基於弱代理論,IBM研究表明新生代中98%的物件都是"朝生夕死",大多數分配了記憶體的物件並不會存活太長時間,在處於年輕代時就會死掉。

  在原始的複製演算法中,空間一分為二,空間利用率為50%,也就是說有新生代中50%的空間會被浪費,無法分配記憶體。Hotspot虛擬機器使用改良的複製演算法,並且設定合理的空間比例,新生代中可用的記憶體空間為整個新生代容量的90%,只有10%的空間會被浪費,大大的提高的新生代的空間利用率。如果存活物件佔用的記憶體大於新生代容量的10%怎麼辦?這就需要依賴其他記憶體(老年代)進行分配擔保了。新生代回收動圖如下:

  

  老年代

  由於老年代的物件存活週期一般相對較長,不會像新生代物件那樣“朝生夕死”,所以物件存活率高是老年代的特點,並且老年代也沒有額外的空間可以分配擔保,所以不適合採用複製演算法進行回收。根據老年代的特點,一般會使用"標記-清理"或"標記-整理"演算法來進行垃圾回收。

收集器

  上面我們介紹了在JVM中常用的垃圾回收演算法及每一種演算法的優缺點。接下里會介紹在HotSpot虛擬機器中常用的幾種垃圾收集器,垃圾收集器是垃圾回收演算法的具體實現,不同的商家、不同版本的JVM所提供的垃圾收集器可能會存在差異。這幾種收集器分別是Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS、G1。在瞭解垃圾收集器之前,我們先來區分幾個概念:

  併發收集器VS並行收集器
  並行:指多條收集執行緒同時進行收集工作,但此時使用者執行緒處於等待狀態。如ParNew、Parallel Scavenge、Parallel Old。
  併發:指使用者執行緒與垃圾收集執行緒同時執行(並不一定是並行,可能會交替執行)。如CMS、G1。

  YoungGC VS OldGC VS MinorGC VS MajorGC VS FullGC
  Minor GC、YoungGC:Minor GC又稱為新生代GC,所以等價於Young GC,在新生代的Eden區分配滿的時候觸發。在Young GC後新生代中有部分存活物件會晉升到老年代,有可能是年齡達到閾值(預設為15歲,在JVM裡面15歲就步入老年生活了,O(∩_∩)O哈哈~)了,也可能是Survivor區域滿了,如果是Survivor區域被填滿,會將所有新生代中存活的物件移動到老年代中!

  Major GC、Old GC、Full GC:Old GC從字面能理解是老年代的GC,但是對Major GC和Full GC存在多種說法,有的認為Major GC等價於Old GC只是針對老年代的GC,有的認為Major GC和Full GC是等價的。但是我個人認為Major是指老年代GC,而Full GC針對新生代、老年代、永久代整個的回收。由於老年代的GC都會伴隨一次新生代的GC,所以習慣性的把Major GC和Full GC劃上了等號。前面Young GC時候說到“在Young GC後新生代中有部分存活物件會晉升到老年代”,萬一老年代的空間不夠存放新生代晉升的物件怎麼辦呢?所以當準備要觸發一次Young GC時,如果發現統計資料之前Young GC的平均晉升大小比目前老年代剩餘的空間大,則不會單獨觸發Young GC,而是轉為觸發Full GC,也就是整堆的收集!

序列收集器

  序列垃圾收集器是最基本、發展歷史最悠久的收集器。主要包含Serial和Serrial Old兩種收集器,分別用來收集新生代和老年代。序列收集器由於是單執行緒收集,在進行垃圾收集時,必須暫停(Stop The World)所有的工作執行緒,直到GC執行緒工作完成。執行示意圖如下:

  

  Serial 收集器:主要針對新生代回收,採用複製演算法,單執行緒收集。
  Serial Old收集器:主要針對老年代回收,採用“標記-整理”演算法,單執行緒收集。

  序列收集器在單CPU的環境下,沒有執行緒切換的開銷,可以獲得最高的單執行緒收集效率,但是由於現在普遍都是多CPU(或者多核)環境,所以除了在桌面應用中仍然將序列收集器作為預設的收集器,其他場景已經很少(很少不代表沒有,後面CMS會講到)使用。

  在上面我們談到一個詞,需要暫停(Stop The World)所有的工作執行緒,這個概念在後面也會多次提到,為什麼需要暫停呢?一是為了方便GC動作,不然在GC過程中又會額外產生新的垃圾,或者分配新的物件。二是因為GC過程中物件的地址會發生變化,如果不暫停執行緒,可能會導致引用出現問題。

並行收集器

  並行收集器是序列收集器的多執行緒版本,除了多執行緒外,其餘的行為、特點和序列收集器一樣。主要包含ParNew收集器、Parallel Scavenge收集器、Parallel Old收集器。執行示意圖如下:

  

  ParNew收集器:主要針對新生代回收,採用複製演算法,多執行緒收集。一般老年代如果使用CMS收集器,則預設會使用ParNew作為新生代收集器。
  Parallel Scavenge收集器:該收集器與ParNew收集器類似,也是新生代收集器,採用複製演算法,多執行緒收集。其他收集器關注點是儘可能地縮短垃圾收集時使用者執行緒停頓的時間,但是Parallel Scavenge收集器的目標則是達到一個可控的吞吐量(吞吐量=CPU執行使用者程式碼時間/(CPU執行使用者程式碼時間+CPU垃圾收集時間)),所以該收集器也成為吞吐量收集器。由於該收集器沒有使用傳統的GC收集器程式碼框架,是另外獨立實現的,所以無法和CMS收集器配合工作。
  Parallel Old收集器:主要針對老年代回收,採用“標記-整理”演算法,多執行緒收集。該收集器是Parallel Scavenge收集器的老年代版本。在JDK1.6之後用來替代老年的Serial Old收集器。在注重吞吐量以及CPU資源敏感的場景,一般會選擇Parallel Scavenge+Parallel Old的組合進行垃圾收集。

CMS收集器

  前面介紹的幾種收集器都相對比較簡單,也很好理解,所以也沒做過多的介紹。接下來介紹的收集器相對前面幾種收集器就要複雜一些,並且使用較廣,所以介紹會較詳細!併發標記清理(Concurrent Mark Sweep)收集器也稱為併發低停頓收集器或低延遲收集器。CMS收集器採用的是“標記-清理”演算法,所以不會進行壓縮操作。我們先來了解一下CMS收集器的運作過程:

  

  CMS收集器運作過程

  1、初始標記(CMS initial mark)
  僅標記GC Roots能直接關聯的物件,這個階段為速度較快,但是仍然需要“Stop The World”,但是停頓時間較短!

  2、併發標記(CMS Concurrent mark)
  進行GC Roots Tracing的過程,也就是查詢GC Roots能直接關聯的物件所引用的記憶體。在這個階段,GC執行緒與使用者執行緒是同時執行的,所以並不能保證能標記出所有存活的物件。

  3、重新標記(CMS remark)
  由於併發標記階段,使用者執行緒在併發執行,所以可能在併發標記階段產生新的物件,所以在重新標記階段也會需要“Stop The World”來標記新產生的物件,且停頓時間比初始標記時間稍長,但遠比並發標記短。

  4、併發清除(CMS Concurrent sweep)
  在併發清除階段使用者執行緒與清理執行緒也是同時工作,清理執行緒回收所有的垃圾物件!

  CMS收集器缺點

  上面瞭解了CMS收集器的運作過程,不知道在瞭解過程中你有沒有發現一些問題,比如CMS收集器採用的是“標記-清除”演算法,那會不會產生很多的記憶體碎片?比如在併發清理階段,使用者執行緒還在執行,會不會在清理的過程中又產生了垃圾?總結CMS收集器的幾個明顯的缺點如下:

  1、 對CPU資源非常敏感
  併發收集雖然不會暫停使用者執行緒,但是因為會佔用一部分CPU資源,還是會導致應用程式變慢,總吞吐量下降。CMS的預設收集執行緒的數量=(CPU數量+3)/4。所以,當CPU數量大於4個時,會有超過25%的資源用於垃圾收集。當CPU數量小於或等於4個時,預設一個收集執行緒。

  2、 產生大量記憶體碎片
  CMS收集器採用“標記-清除”演算法,在清除後不會進行壓縮操作,這樣會導致產生大量不連續的記憶體碎片,在分配大物件時,無法找到足夠的連續記憶體,從而需要提前觸發一次FullGC的動作。針對該問題,提供了兩個引數來設定是否開啟碎片整理。
  1)、“-XX:+UseCMSCompactAtFullCollection”引數
  從名字能看出來,在收集的時候是否開啟壓縮。這個引數預設是開啟的,但是是否開啟壓縮還需要結合下面的引數!
  2)、“-XX:+CMSFullGCsBeforeCompaction”引數
  該引數設定執行多少次不壓縮的Full GC後,來一次壓縮整理。這個引數預設為0,也就是說每次都執行Full GC,不會進行壓縮整理。
  如果開啟了壓縮,則在清理階段需要“Stop the world”,不能進行併發!

  3、 產生浮動垃圾
  上面說到過在併發清理階段,使用者執行緒還在執行,這時候可能就會又有新的垃圾產生,而無法在此次GC過程中被回收,這成為浮動垃圾。

  4、 “Concurrent Mode Failure”失敗
  不知道大家在開發過程中有沒有遇到過“Concurrent Mode Failure”失敗的資訊,不管你有沒有遇到過,反正我是遇到過!這個異常是什麼原因導致的呢。在併發標記和併發清除階段,使用者執行緒與GC執行緒併發工作,這會導致在清理的時候又會有使用者的執行緒在拼命的建立物件,本身垃圾回收時候肯定是可用記憶體不夠了,可萬一這時候使用者執行緒建立了大量的物件怎麼辦呢?所以一般CMS收集器的垃圾回收的動作不會在完全無法分配記憶體的時候進行,可以通過“-XX:CMSInitiatingOccupancyFraction”引數來設定CMS預留的記憶體空間!如果預留的空間無法滿足程式的需要,就會出現 “Concurrent Mode Failure”失敗。這時候JVM會啟用後備方案,也就是前面介紹過的Serial Old收集器,這樣會導致另一次的Full GC的產生,這樣的代價是很大的,所以CMSInitiatingOccupancyFraction這個引數設定需要根據程式合理設定!

  CMS收集器應用場景

  上面介紹了CMS收集器的缺點,那它當然也有它的優點啦,比如併發收集、低停頓等等……所以CMS收集器適合與使用者互動較多的場景,注重服務的響應速度,能給使用者帶來較好的體驗!所以我們在做WEB開發的時候,經常會使用CMS收集器作為老年代的收集器!