1. 程式人生 > >深入理解Java虛擬機器——JVM垃圾回收機制和垃圾收集器詳解

深入理解Java虛擬機器——JVM垃圾回收機制和垃圾收集器詳解

一:概述

說起垃圾回收(Garbage Collection,GC),很多人就會自然而然地把它和Java聯絡起來。在Java中,程式設計師不需要去關心記憶體動態分配和垃圾回收的問題,顧名思義,垃圾回收就是釋放垃圾佔用的空間,這一切都交給了JVM來處理。本文主要解答三個問題:

1、哪些記憶體需要回收?(物件是否可以被回收的兩種經典演算法: 引用計數法 和 可達性分析演算法) 
2、如何回收?(三種經典垃圾回收演算法(標記清除演算法、複製演算法、標記整理演算法)及分代收集演算法) 
3、使用什麼工具回收?(垃圾收集器)

在探討Java垃圾回收機制之前,我們首先應該記住一個單詞:Stop-the-World。Stop-the-world意味著 JVM由於要執行GC而停止了應用程式的執行,並且這種情形會在任何一種GC演算法中發生。當Stop-the-world發生時,除了GC所需的執行緒以外,所有執行緒都處於等待狀態直到GC任務完成。事實上,GC優化很多時候就是指減少Stop-the-world發生的時間,從而使系統具有高吞吐 、低停頓的特點。

二、JVM垃圾判定演算法

常用的垃圾判定演算法包括:引用計數演算法,可達性分析演算法。

1、引用計數演算法

引用計數演算法是通過判斷物件的引用數量來決定物件是否可以被回收。

java中是通過引用來和物件進行關聯的,也就是說如果要操作物件,必須通過引用來進行。給物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加1;當引用失效時,計數器值就減1;任何時刻計數器為0的物件就是不可能再被使用的,即表示該物件可視為”垃圾“被回收。

引用計數器演算法實現簡單,效率高;但是不能解決迴圈引用問問題(A 物件引用B 物件,B 物件又引用A 物件,但是A,B 物件已不被任何其他物件引用),同時每次計數器的增加和減少都帶來了很多額外的開銷,所以在JDK1.1 之後,這個演算法已經不再使用了。程式碼

public class Main {
    public static void main(String[] args) {
        MyTest test1 = new MyTest();
        MyTest test2 = new MyTest();

        test1.obj  = test2;
        test2.obj  = test1;//test1與test2存在相互引用 

        test1 = null;
        test2 = null;

        System.gc();//回收
    }
}

class MyTest{
    public Object obj = null;
}


雖然最後將test1和test2賦值為null,也就是說test1和test2指向的物件已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數都不為0,那麼垃圾收集器就永遠不會回收它們。執行程式,從記憶體分析看到,事實上這兩個物件的記憶體被回收,這也說明了當前主流的JVM都不是採用的引用計數器演算法作為垃圾判定演算法的。

2、可達性分析演算法(根可達演算法)

可達性分析演算法是通過判斷物件的引用鏈是否可達來決定物件是否可以被回收。

根搜尋演算法是通過一些“GC Roots”物件作為起點,從這些節點開始往下搜尋,搜尋通過的路徑成為引用鏈 
(Reference Chain),當一個物件沒有被GC Roots 的引用鏈連線的時候,說明這個物件是不可用的。


GC Roots 物件包括: 
a) 虛擬機器棧(棧幀中的本地變量表)中的引用的物件。 
b) 方法區域中的類靜態屬性引用的物件。 
c) 方法區域中常量引用的物件。 
d) 本地方法棧中JNI(即一般說的Native方法)的引用的物件。

在可達性分析演算法中,不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記並且進行一次篩選,篩選的條件是此物件是否需要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“不需要要執行”。注意任何物件的finalize()方法只會被系統自動執行1次。

如果這個物件被判定為需要執行finalize()方法,那麼這個物件將會放置在一個叫做F-Queue的佇列之中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行它。這裡所謂的“執行”是指虛擬機器會觸發這個方法,但並不承諾會等待它執行結束,這樣做的原因是,如果一個物件在finalize()方法中執行緩慢,或者發生了死迴圈,將很可能會導致F-Queue佇列中其他物件永久處於等待,甚至導致整個記憶體回收系統崩潰。因此呼叫finalize()方法不代表該方法中程式碼能夠完全被執行。

finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移除出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的被回收了。從如下程式碼中我們可以看到一個物件的finalize()被執行,但是它仍然可以存活。

/**   
 * 此程式碼演示了兩點:   
 * 1.物件可以在被GC時自我拯救。   
 * 2.這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次   
 */    
public class FinalizeEscapeGC {    

  public static FinalizeEscapeGC SAVE_HOOK = null;    

  public void isAlive() {    
   System.out.println("yes, i am still alive :)");    
  }    

  @Override    
  protected void finalize() throws Throwable {    
   super.finalize();    
   System.out.println("finalize mehtod executed!");    
   FinalizeEscapeGC.SAVE_HOOK = this;    
  }    

  public static void main(String[] args) throws Throwable {    
   SAVE_HOOK = new FinalizeEscapeGC();    

   //物件第一次成功拯救自己    
   SAVE_HOOK = null;    
   System.gc();    
   //因為finalize方法優先順序很低,所以暫停0.5秒以等待它    
   Thread.sleep(500);    
   if (SAVE_HOOK != null) {    
    SAVE_HOOK.isAlive();    
   } else {    
    System.out.println("no, i am dead :(");    
   }    

   //下面這段程式碼與上面的完全相同,但是這次自救卻失敗了    
   SAVE_HOOK = null;    
   System.gc();    
   //因為finalize方法優先順序很低,所以暫停0.5秒以等待它    
   Thread.sleep(500);    
   if (SAVE_HOOK != null) {    
    SAVE_HOOK.isAlive();    
   } else {    
    System.out.println("no, i am dead :(");    
   }    
  }    
}

執行結果:

finalize mehtod executed!    
yes, i am still alive :)    
no, i am dead :(

從執行結果可以看出,SAVE_HOOK物件的finalize()方法確實被GC收集器呼叫過,且在被收集前成功逃脫了。 
另外一個值得注意的地方是,程式碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成功,一次失敗,這是因為任何一個物件的finalize()方法都只會被系統自動呼叫一次,如果物件面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段程式碼的自救行動失敗了。

三、JVM垃圾回收演算法

常用的垃圾回收演算法包括:標記-清除演算法,複製演算法,標記-整理演算法,分代收集演算法

1、標記—清除演算法(Mark-Sweep)(DVM 使用的演算法)

標記—清除演算法包括兩個階段:“標記”和“清除”。在標記階段,確定所有要回收的物件,並做標記。清除階段緊隨標記階段,將標記階段確定不可用的物件清除。標記—清除演算法是基礎的收集演算法。

這裡寫圖片描述

標記-清除演算法的缺點有兩個:

效率問題:標記和清除兩個過程的效率都不高;

空間問題:標記-清除演算法不需要進行物件的移動,並且僅對不存活的物件進行處理,因此標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致以後在程式執行過程中需要分配較大物件時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

2、複製演算法(Copying)

複製演算法將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這種演算法適用於物件存活率低的場景,比如新生代。這樣使得每次都是對整個半區進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。

這裡寫圖片描述

事實上,現在商用的虛擬機器都採用這種演算法來回收新生代。因為研究發現,新生代中的物件每次回收都基本上只有10%左右的物件存活,所以需要複製的物件很少,效率還不錯。

實踐中會將新生代記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間 (如下圖所示),每次使用Eden和其中一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次地複製到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是 8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90% ( 80%+10% ),只有10% 的記憶體會被“浪費”。

複製收集演算法在物件存活率較高時就要進行較多的複製操作,效率將會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行分配擔保,以應對被使用的記憶體中所有物件都100%存活的極端情況,所以在老年代一般不能直接選用這種演算法。

3、標記—整理演算法(Mark-Compact)

標記—整理演算法和標記—清除演算法一樣,但是標記—整理演算法不是把存活物件複製到另一塊記憶體,而是把存活物件往記憶體的一端移動,然後直接回收邊界以外的記憶體。標記—整理演算法提高了記憶體的利用率,並且它適合在收集物件存活時間較長的老年代。

這裡寫圖片描述

標記整理演算法與標記清除演算法最顯著的區別是:標記清除演算法不進行物件的移動,並且僅對不存活的物件進行處理;而標記整理演算法會將所有的存活物件移動到一端,並對不存活物件進行處理,因此其不會產生記憶體碎片。

4、分代收集(Generational Collection)

分代收集是基於這樣一種事實:不同的物件的生命週期(存活情況)是不一樣的,而不同生命週期的物件位於堆中不同的區域,因此對堆記憶體不同區域採用不同的策略進行回收可以提高 JVM 的執行效率。

當代商用虛擬機器使用的都是分代收集演算法:新生代物件存活率低,就採用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集;老年代存活率高,沒有額外空間對它進行分配擔保,就用“標記—清理”或者“標記—整理”演算法。Java堆記憶體一般可以分為新生代、老年代和永久代三個模組。

(1). 新生代(Young Generation)

  新生代的目標就是儘可能快速的收集掉那些生命週期短的物件,一般情況下,所有新生成的物件首先都是放在新生代的。新生代記憶體按照 8:1:1 的比例分為一個eden區和兩個survivor(survivor0,survivor1)區,大部分物件在Eden區中生成。在進行垃圾回收時,先將eden區存活物件複製到survivor0區,然後清空eden區,當這個survivor0區也滿了時,則將eden區和survivor0區存活物件複製到survivor1區,然後清空eden和這個survivor0區,此時survivor0區是空的,然後交換survivor0區和survivor1區的角色(即下次垃圾回收時會掃描Eden區和survivor1區),即保持survivor0區為空,如此往復。特別地,當survivor1區也不足以存放eden區和survivor0區的存活物件時,就將存活物件直接存放到老年代。如果老年代也滿了,就會觸發一次FullGC,也就是新生代、老年代都進行回收。注意,新生代發生的GC也叫做MinorGC,MinorGC發生頻率比較高,不一定等 Eden區滿了才觸發。

(2). 老年代(Old Generation)

  老年代存放的都是一些生命週期較長的物件,就像上面所敘述的那樣,在新生代中經歷了N次垃圾回收後仍然存活的物件就會被放到老年代中。此外,老年代的記憶體也比新生代大很多(大概比例是1:2),當老年代滿時會觸發Major GC(Full GC),老年代物件存活時間比較長,因此FullGC發生的頻率比較低。

(3). 永久代(Permanent Generation)

  永久代主要用於存放靜態檔案,如Java類、方法等。永久代對垃圾回收沒有顯著影響,但是有些應用可能動態生成或者呼叫一些class,例如使用反射、動態代理、CGLib等bytecode框架時,在這種時候需要設定一個比較大的永久代空間來存放這些執行過程中新增的類。

垃圾回收有兩種型別,Minor GC 和 Full GC。

Minor GC:對新生代進行回收,不會影響到年老代。因為新生代的 Java 物件大多死亡頻繁,所以 Minor GC 非常頻繁,一般在這裡使用速度快、效率高的演算法,使垃圾回收能儘快完成。

Full GC:也叫 Major GC,對整個堆進行回收,包括新生代和老年代。由於Full GC需要對整個堆進行回收,所以比Minor GC要慢,因此應該儘可能減少Full GC的次數,導致Full GC的原因包括:老年代被寫滿、永久代(Perm)被寫滿和System.gc()被顯式呼叫等。

四、垃圾收集器

如果說垃圾收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。上面說過,各個平臺虛擬機器對記憶體的操作各不相同,因此本章所講的收集器是基於JDK1.7Update14之後的HotSpot虛擬機器。這個虛擬機器包含的所有收集器如圖:

這裡寫圖片描述

其中用於回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,回收老年代的收集器包括Serial Old、Parallel Old、CMS,還有用於回收整個Java堆的G1收集器。

1、Serial收集器(複製演算法)

新生代單執行緒收集器,優點是簡單高效。Serial收集器是最基本、發展歷史最悠久的收集器,曾經(在JDK 1.3.1之前)是虛擬機器新生代收集的唯一選擇。大家看名字就會知道,這個收集器是一個單執行緒的收集器,但它的“單執行緒”的意義並不僅僅說明它只會使用一個CPU或一條收集執行緒去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作執行緒,直到它收集結束。

這裡寫圖片描述

2、ParNew收集器 (複製演算法)

新生代並行收集器,ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其餘行為包括Serial收集器可用的所有控制引數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集演算法、Stop The World、物件分配規則、回收策略等都與Serial收集器完全一樣,在實現上,這兩種收集器也共用了相當多的程式碼。

這裡寫圖片描述

3、Parallel Scavenge收集器(複製演算法)

新生代並行收集器,追求高吞吐量,高效利用 CPU。

這裡寫圖片描述

Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,Parallel Scavenge收集器的目標是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間)。由於與吞吐量關係密切,Parallel Scavenge收集器也經常稱為“吞吐量優先”收集器。

4、Serial Old收集器(標記-整理演算法)

老年代單執行緒收集器,Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用“標記-整理”演算法。這個收集器的主要意義也是在於給Client模式下的虛擬機器使用。如果在Server模式下,那麼它主要還有兩大用途:一種用途是在JDK 1.5以及之前的版本中與Parallel Scavenge收集器搭配使用[1],另一種用途就是作為CMS收集器的後備預案,在併發收集發生ConcurrentMode Failure時使用。

5、Parallel Old收集器 (標記-整理演算法)

老年代並行收集器,吞吐量優先

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。這個收集器是在JDK 1.6中才開始提供的。

6、CMS收集器(標記-清除演算法)

老年代並行收集器,以獲取最短回收停頓時間為目標的收集器,具有高併發、低停頓的特點,追求最短GC回收停頓時間。

這裡寫圖片描述

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在網際網路站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。 
運作過程分為4個步驟,包括: 
a)初始標記(CMS initial mark) 
b)併發標記(CMS concurrent mark) 
c)重新標記(CMS remark) 
d)併發清除(CMS concurrent sweep)

CMS收集器存在3個缺點: 
1 對CPU資源敏感。一般併發執行的程式對CPU數量都是比較敏感的 
2 無法處理浮動垃圾。在併發清理階段使用者執行緒還在執行,這時產生的垃圾無法清理。 
3 由於標記-清除演算法產生大量的空間碎片。

7、G1收集器 (標記-整理演算法)

Java堆並行收集器,G1收集器是JDK1.7提供的一個新收集器,G1收集器基於“標記-整理”演算法實現,也就是說不會產生記憶體碎片。此外,G1收集器不同於之前的收集器的一個重要特點是:G1回收的範圍是整個Java堆(包括新生代,老年代),而前六種收集器回收的範圍僅限於新生代或老年代。

這裡寫圖片描述

G1是一款面向服務端應用的垃圾收集器。 
G1收集器的運作大致可劃分為以下幾個步驟:

a)初始標記(Initial Marking) 
b)併發標記(Concurrent Marking) 
c)最終標記(Final Marking) 
d)篩選回收(Live Data Counting and Evacuation)