本文在個人技術部落格不同步釋出,詳情可用力戳 亦可掃描螢幕右側二維碼關注個人公衆号,公衆号内有個人聯系方式,等你來撩...
相關連結(注:文章講解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的組合進行垃圾收集。
前面介紹的幾種收集器都相對比較簡單,也很好了解,是以也沒做過多的介紹。接下來介紹的收集器相對前面幾種收集器就要複雜一些,并且使用較廣,是以介紹會較詳細!并發标記清理(Concurrent Mark Sweep)收集器也稱為并發低停頓收集器或低延遲收集器。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收集器的幾個明顯的缺點如下:
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收集器适合與使用者互動較多的場景,注重服務的響應速度,能給使用者帶來較好的體驗!是以我們在做WEB開發的時候,經常會使用CMS收集器作為老年代的收集器!