1. 程式人生 > >【深入理解Java虛擬機器】垃圾回收機制

【深入理解Java虛擬機器】垃圾回收機制

本文內容來源於《深入理解Java虛擬機器》一書,非常推薦大家去看一下這本書。本系列其他文章:

1、垃圾回收要解決的問題

垃圾收集(Garbage Collection,GC),要設計一個GC,需要考慮解決下面三件事情:(1)哪些記憶體需要回收?(2)什麼時候回收?(3)如何回收?哪些記憶體需要回收?根據《》中介紹的java記憶體模型,其中,程式計數器、虛擬機器棧、本地方法棧3個區域隨執行緒而生,隨執行緒而滅;棧中的棧幀隨著方法的進入和退出有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的,因此這幾個區域的記憶體分配和回收都具備確定性,故這幾個區域就不需要過多考慮回收的問題
,因為方法結束或者執行緒結束時,記憶體自然就跟著回收了。
對於java堆和方法區則不一樣,java堆是存放例項物件的地方,我們只有在程式執行期間才能知道會建立哪些物件,這部分記憶體的分配和回收是動態的,因此,垃圾收集器所關注的就是這一部分。對於方法區(或者說HotSpot虛擬機器中的永久代),垃圾回收主要是回收這兩部分內容:廢棄常量無用的類。對於廢棄常量,主要是判斷當前系統中有沒有物件引用這個常量;對於無用類則比較嚴格,需要滿足下面三個條件:(1)該類的所有例項都已經被回收,即堆中不存在該類任何勢力;(2)載入該類的ClassLoader已經被回收;(3)對類對應的java.lang.Class物件沒有在任何地方被引用,無法再任何地方通過反射訪問該類的方法;
滿足了上面三個條件也僅僅是“可以”進行回收了,還要根據HotSpot的一些配置引數綜合考慮。什麼時候回收?垃圾收集器在對堆進行回收前,第一件事就是要確定這些物件之中哪些還“存活”著,哪些已經“死去”,對於這些已經“死去”的物件我們需要進行回收。判斷物件是否存活的演算法(1)引用計數演算法演算法過程如下:【給物件新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的】。引用計數演算法實現簡單,判定效率也很高,大部分情況下是一個不錯的演算法。但有一個比較重要的缺點:很難解決物件之間相互迴圈引用的問題。比如:j假設變數objA、objB為某個類的物件例項,objA中持有一個指向objB的成員,此時objB的引用計數為1;在objB中持有一個指向objA的成員,此時objA的引用計數值也為1;此時,即使把objA、objB都置為null,此時兩個物件都不能被回收,因為這兩個物件雖然為null了,但是它們的引用計數值都還為1。(2)可達性分析演算法
目前主流的虛擬機器,如java預設虛擬機器HotSpot就是用的這種方式。演算法基本思路為:【通過一系列的稱為“GC Roots”的物件作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連時(或者說從GC Roots到這個物件不可達),則證明此物件是不可用的】。可作為GC Roots的物件包括:1)虛擬機器棧(棧幀中的本地變量表)中引用的物件;2)方法區中類靜態static屬性引用的物件;3)方法區中常量final引用的物件;4)本地方法棧中JNI(即一般說的Native方法)引用的物件;    需要注意的是,即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的,要真正宣告一個物件死亡,至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過(也就是說物件的finalize()方法只能被呼叫一次),虛擬機器將這兩種情況都視為“沒有必要執行”。    如果這個物件被判定為有必要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它(即去執行物件的finalize()方法,這裡所謂的“執行”是值虛擬機器會觸發這個方法,但並不承若會等待它執行結束,主要是為了防止物件的finalize方法執行緩慢或發生死迴圈,導致其他物件不能被執行的,從而引起記憶體回收系統崩潰)。    finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己——只需要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。因此對於不可達物件判定真正死亡的過程小結如下:(1)GC進行第一次標記並進行一次篩選(篩選那些覆蓋了finalize方法並且finalize方法是第一次呼叫的物件);--> (2)另一個低優先順序的執行緒去呼叫那些被篩選出來的物件的finalize方法;--> (3)GC進行第二次標記,如果在前一步中那些篩選出來的物件沒有在finalize拯救自己,此時,那些未被篩選到的和這些這些篩選到的但是沒有拯救自己的物件都將會回收。

2、垃圾回收演算法

2.1 標記-清除

是最基礎的一種收集演算法。分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。標記過程就是上面可達性分析演算法中所講的二次標記過程。標記-清除演算法的執行過程如下圖所示:回收前狀態:回收後狀態:
缺點:(1)效率問題:標記和清除的兩個過程效率都不高;(2)空間問題:標記清除後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前出發另一次垃圾收集動作;

2.2 複製演算法

為了解決上面演算法的效率問題,複製演算法出現。它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體使用完了,就將還存活的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。複製演算法的優點:(1)每次都是對整個半區進行記憶體回收,實現簡單、執行也高效;(2)在那塊使用記憶體上進行記憶體分配時,不用考慮記憶體碎片的問題,只要移動堆頂指標,按順序分配記憶體即可;缺點:將記憶體縮小為原來的一半,代價較高。複製演算法的執行過程如下:回收前的狀態:
回收後的狀態:

按照新生代的特點,新生代中的物件98%是“朝生夕死”的,因此,可以改進上面的複製演算法,目前商業虛擬機器正是用這種改進的收集演算法來回收新生代改進的收集演算法根據新生代的特點,我們並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體劃分為一塊較大的Eden空間和兩塊較小的Survivor空間每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活的物件一次性地複製到另外一塊Survivor空間,最後清理掉Eden和剛才用過的Survivor空間,清理完成後,剛剛被清理的Eden和另一塊在回收時放入存活物件的Survivor空間作為使用記憶體,剛被清理的Survivor作為保留空間,以便後面用來回收之用。這種改進的收集演算法也有一個問題,就是在回收時,那塊空的Survivor空間能否放得下Eden和使用的Survivor空間中還存活的物件,如果Survivor空間不夠存放上一次新生代收集下來的存活物件,此時就需要向老年代“借”記憶體,那些剩餘未放下的物件就通過分配擔保機制進入老年代。

2.3 標記-整理演算法

複製演算法如果在物件存活率較高時,就需要進行較多次的複製操作,效率也會變低。而對於老年代中的物件,一般存活率都較高,因此需要選用其他收集演算法:標記 - 整理演算法。標記過程仍然與“標記-清除”演算法中一樣,但是在標記完成後並不直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。演算法示意圖如下:回收前狀態;
回收後狀態:

2.4 分代收集演算法

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

3 垃圾收集器

如果說上面介紹的收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現,按照上面的介紹,目前垃圾收集器基本都採用分代收集,因此一個垃圾收集器中一般都存在多種垃圾回收演算法。不同的虛擬機器提供的垃圾收集器也有很大差異,如下是HotSpot虛擬機器基於JDK1.7版本所包含的所有垃圾收集器:
HotSpot中共有7中不同的垃圾收集器,如果兩個收集器之間存在連線,說明它們之間可以搭配使用,其中,Serial、ParNew、Parallel Scavenge屬於新生代收集器,CMS、Serial Old、Parallel Old屬於老年代收集器,G1是最新的一種收集器,在新生代和老年代中都可使用。

3.1 Serial(序列)收集器

最基本、發展歷史最悠久的一種收集器。看名字就知道,這個收集器是一個單執行緒的收集器,只使用一個CPU或一條收集執行緒去完成垃圾收集工作,最重要的是,在它進行垃圾收集的時候,必須暫停其他所有的工作執行緒,知道它收集結束。雖然有這個缺點,但是依然是虛擬機器執行在Client模式下的預設新生代收集器。優點是:簡單而高效,沒有執行緒互動的開銷。執行過程如圖:
新生代採用的是“複製演算法”,老年代採用的是“標記-整理”演算法。

3.2 ParNew收集器

ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其他行為和Serial收集器一樣。ParNew是許多執行在Server模式下的虛擬機器中首選的新生代收集器,其中有一個與效能無關的重要原因,除了Serial收集器外,目前只有ParNew能與老年代的CMS收集器配合使用。ParNew是一種並行的收集器。在垃圾回收中,並行是指:多條垃圾收集執行緒並行工作,使用者執行緒處於等待狀態;併發是指:使用者執行緒和垃圾收集執行緒同時執行(不一定並行,可能交替執行)。

3.3 Parallel Scavenge收集器

Parallel Scavenge收集器使用的是複製演算法,也是一個並行的多執行緒收集器。和ParNew相似,但是Parallel Scavenge的關注點不同,CMS收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量,吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間)。上面三種都是新生代收集器,下面介紹老年代收集器。

3.4 Serial Old收集器

Serial Old收集器是新生代Serial收集器的老年代版本,同樣是一個單執行緒收集器,使用“標記-整理”演算法,Serial Old的主要意義也是在於給Client模式下的虛擬機器使用。

3.5 Parallel Old收集器

Parallel Old是新生代收集器Prarllel Scavenge的老年代版本,使用多執行緒和“標記-整理”演算法。執行流程如下:

3.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。對於網際網路站或者B/S系統的這種注重響應速度的服務端來說,CMS是很好的選擇。從名字Mark Sweep可以看出,CMS是基於“標記-清除”演算法實現的,分為四個步驟:(1)初始標記(CMS initial mark):僅僅標記一GC Roots能直接關聯到的物件,這個步驟需要“stop the world”;(2)併發標記(CMS concurrent mark):就是GC Roots進行可達性分析階段,可併發執行;(3)重新標記(CMS remark):修正併發標記期間發生變動的那一部分物件,這個步驟需要“stop the world”;(4)併發清除(CMS concurrent sweep):執行清除階段。執行過程如下:
可以看到,初始標記和重新標記階段都是並行的,需要暫停使用者執行緒(過程比較短);在併發標記和併發清除階段是併發的,可以和使用者執行緒一起工作。CMS的優點:併發收集、低停頓。CMS的缺點:(1)對CPU資源非常敏感,面向併發設計程式的通病,雖然不至於導致使用者執行緒停頓,但是會降低吞吐率;(2)無法清理“浮動垃圾”,由於CMS併發清理階段使用者執行緒還在執行著,伴隨程式執行自然就還會有新的垃圾不斷出現,這一部分垃圾出現在標記過程之後,CMS無法在當次收集中處理掉它們,只好留待下一次的GC;(3)會產生大量空間碎片,因為CMS是基於“標記-清除”演算法,這種演算法的最大缺點就是會產生大量空間碎片,給分配大物件帶來麻煩,不得不提前觸發Full GC。為了解決這個問題,CMS提供了一個“-XX:+UseCMSCompaceAtFullCollection”的開關引數(預設開啟),用於在CMS收集器頂不住要進行Full GC時開啟記憶體碎片的合併整理過程

3.7 G1收集器

G1收集器是最新的一款收集器,JDK1.7才釋出,是一種面向服務端應用的垃圾收集器,有如下特點:(1)並行與併發:G1能充分利用多CPU、多核環境下的硬體優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop-The-World停頓的時間;(2)分代收集:分代概念在G1中依然得以保留。雖然G1可以不需其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新建立的物件和已經存活了一段時間、熬過多次GC的舊物件以獲取更好的收集效果;(3)空間整合:與CMS的“標記-清理”演算法不同,G1從整體看來是基於“標記-整理”演算法實現的收集器,從區域性(兩個Region之間)上看是基於“複製”演算法實現,無論如何,這兩種演算法都意味著G1運作期間不會產生記憶體空間碎片,收集後能提供規整的可用記憶體;(4)可預測的停頓時間;使用G1收集器時,Java堆的記憶體佈局與就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。
G1的收集過程分為以下幾個步驟:(1)初始標記(Initial Marking)(2)併發標記(Concurrent Marking)(3)最終標記(Final Marking)(4)篩選回收(Live Data Counting and Evacuation)前幾個步驟和CMS有很多相似之處。執行示意圖如下:
(以上圖片來源於:)