本文在個人技術博客不同步發布,詳情可用力戳
亦可掃描屏幕右側二維碼關注個人公眾號,公眾號內有個人聯系方式,等你來撩...
相關鏈接(注:文章講解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收集器作為老年代的收集器!