1. 程式人生 > >深入理解Java垃圾回收

深入理解Java垃圾回收

       在Java程式中物件的建立是非常頻繁的,而記憶體的大小又是有限的,為了記憶體的重複利用,就需要對記憶體中的物件進行垃圾收集。其實,這也是Java和C++的一個區別,在Java中可以進行自動的垃圾收集,而C和C++中需要程式設計師手動回收不再使用的物件。

Java中的垃圾收集是虛擬機器要考慮的問題。那麼以虛擬機器的角度考慮,如果要收集虛擬機器記憶體中的垃圾,需要考慮哪些問題呢?

  • Java虛擬機器中的記憶體分為程式計數器、虛擬機器棧、本地方法棧、Java堆和方法區等幾部分,在哪些部分回收記憶體呢?
  • 確定了要回收的記憶體,記憶體中必然存在著很多內容,如何判定這些內容就是不需要的垃圾了呢?
  • 程式不斷執行,垃圾收集不可能也隨著程式一直執行,那什麼時候進行垃圾收集操作呢?
  • 最重要的問題是,怎麼回收?

回收區域

在前面幾篇中可以知道,Java記憶體中的程式計數器、虛擬機器棧和本地方法棧是執行緒私有的,執行緒結束也就沒了。其中程式計數器負責指示下一條指令,棧中的棧幀隨著方法的進入和退出不停的入棧出棧。每一個棧幀的大小在編譯時就基本已經確定。所以這幾個區域就不需要考慮記憶體回收,因為方法結束或執行緒停止,記憶體就回收了。

和上述三個區域不同的是,Java堆和方法區是執行緒共享的。在Java堆中存放著所有執行緒在執行時建立的物件,在方法區中存放著關於類的元資料資訊。我們在程式執行時才能確定需要載入哪些類的元資料資訊到方法區,建立哪些物件到堆中,也就是說,這部分的記憶體分配和回收都是動態的。也因為這樣,這兩個部分是垃圾收集器所關注的地方。

首先要搞清一個最基本的問題:如果確定某個物件是“垃圾”?既然垃圾收集器的任務是回收垃圾物件所佔的空間供新的物件使用,那麼垃圾收集器如何確定某個物件是“垃圾”?—即通過什麼方法判斷一個物件可以被回收了。

  在java中是通過引用來和物件進行關聯的,也就是說如果要操作物件,必須通過引用來進行。那麼很顯然一個簡單的辦法就是通過引用計數來判斷一個物件是否可以被回收。不失一般性,如果一個物件沒有任何引用與之關聯,則說明該物件基本不太可能在其他地方被使用到,那麼這個物件就成為可被回收的物件了。這種方式成為引用計數法。

  這種方式的特點是實現簡單,而且效率較高,但是它無法解決迴圈引用的問題,因此在Java中並沒有採用這種方式(Python採用的是引用計數法)。看下面這段程式碼:

public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();

        object1.object = object2;
        object2.object = object1;

        object1 = null;
        object2 = null;
    }
}

class MyObject{
    public Object object = null;
}
最後面兩句將object1和object2賦值為null,也就是說object1和object2指向的物件已經不可能再被訪問,但是由於它們互相引用對方,導致它們的引用計數都不為0,那麼垃圾收集器就永遠不會回收它們。

為了解決這個問題,在Java中採取了 可達性分析法

      該方法的基本思想是通過一系列的“GC Roots”物件作為起點進行搜尋,如果在“GC Roots”和一個物件之間沒有可達路徑,則稱該物件是不可達的,不過要注意的是被判定為不可達的物件不一定就會成為可回收物件。被判定為不可達的物件要成為可回收物件必須至少經歷兩次標記過程,如果在這兩次標記過程中仍然沒有逃脫成為可回收物件的可能性,則基本上就真的成為可回收物件了。

在Java中,下面幾種物件可以作為GC Roots:

  • 虛擬機器棧(棧幀中的本地變量表)中引用的物件;
  • 方法區中類靜態屬性引用的物件;
  • 方法區中常量引用的物件;
  • 本地方法棧中JNI(即Native方法)引用的物件;

即使在可達性分析演算法中不可達的物件,也並非是“非死不可”的,要真正認為一個物件是垃圾要收集,至少要經過兩次標記過程:如果物件在進行可達性分析後發現不可達,那麼就將它進行第一標記並進行一次篩選,篩選的條件是這個物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize方法,或finalize方法已經被虛擬機器執行過了,虛擬機器任何沒有必要執行finalize方法。

如果這個物件被判定為有必要執行finalize方法,那麼這個物件會放置在一個叫做F-Queue的佇列中,並在稍後由一個由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行。不過虛擬機器只是會觸發這個方法,但不承諾會等待執行完畢,這是因為,如果一個物件的finalize方法執行緩慢,或發生了死迴圈,就會導致F-Queue物件中的其他物件處於等待,甚至整個垃圾收集系統崩潰。稍後GC會在F-Queue中的物件進行第二次小規模的標記,如果這時標記為可達,就可以不被收集;如果仍然不可達,那麼就被標記為垃圾了。具體的流程圖如下:


下面的程式碼演示了上面所說的內容。

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK=null;
    public void isAlive(){
        System.out.println("yes,i am still alive.");
    }
    protected void finalize()throws Throwable{
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK=this;
    }
    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK=new FinalizeEscapeGC();
        SAVE_HOOK=null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no,i am dead.");
        }

        SAVE_HOOK=null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK!=null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no,i am dead.");
        }
    }
}  
結果如下:


FinalizeEscapeGC類覆蓋了finalize方法,所以在GC將SAVE_HOOK第一次標記為垃圾後的篩選中認為finalize有必要執行。在覆蓋的finalize方法中,將自己賦值給了類的變數SAVE_HOOK,成功拯救自己,第一次沒有被收集。但是第二次雖然程式碼相同,但是由於虛擬機器已經執行過finalize方法了,GC不認為有必要執行,在第二次標記中也標記為垃圾,所以沒有能拯救自己,被當做垃圾收集了。

java中的引用

其實Java中的引用一共有四種。這是JDK 1.2 之後對引用概念的擴充,分別是強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference),這四種引用的強度依次逐漸減弱。

(1)強引用

強引用就是程式中普遍存在的,類似“Object obj=new Object()”這類的引用,只要強引用還存在,垃圾回收器就不會回收被引用的物件。

(2)軟引用

軟引用用來描述一些還有用但不是必須的物件。對於軟引用關聯的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件列入回收範圍進行第二次回收。如果這次回收還沒有足夠的記憶體,才會丟擲記憶體溢位異常。SoftReference類來實現軟引用。

(3)弱引用

弱引用也用來描述非必須的物件,但是強度比軟引用還弱,被引用的物件只能存活到下一次垃圾收集之前。當下一次垃圾收集器工作時,不論記憶體是否足夠,都會回收這些物件。WeakReference類實現了弱引用。

(4)虛引用

虛引用是最弱的一種引用,也叫幽靈引用或幻影引用。一個物件是否有虛引用存在不會對其生存時間產生影響,也無法通過虛引用來取得一個物件例項。虛引用的唯一目的就是當被虛引用關聯的物件被收集器收集時收到一個系統通知。PhantomReference類實現了虛引用。

總結一下平常遇到的比較常見的將物件判定為可回收物件的情況:

1)顯示地將某個引用賦值為null或者將已經指向某個物件的引用指向新的物件,比如下面的程式碼:

Object obj = new Object();
obj = null;
Object obj1 = new Object();
Object obj2 = new Object();
obj1 = obj2;

2)區域性引用所指向的物件,比如下面這段程式碼:

void fun() {
.....
    for(int i=0;i<10;i++) {
        Object obj = new Object();
        System.out.println(obj.getClass());
    }
}
迴圈每執行完一次,生成的Object物件都會成為可回收的物件。

3)只有弱引用與其關聯的物件,比如:

WeakReference<String> wr = new WeakReference<String>(new String("world"));

二.典型的垃圾收集演算法

在確定了哪些垃圾可以被回收後,垃圾收集器要做的事情就是開始進行垃圾回收,但是這裡面涉及到一個問題是:如何高效地進行垃圾回收。由於Java虛擬機器規範並沒有對如何實現垃圾收集器做出明確的規定,因此各個廠商的虛擬機器可以採用不同的方式來實現垃圾收集器,所以在此只討論幾種常見的垃圾收集演算法的核心思想。

1.Mark-Sweep(標記-清除)演算法

  這是最基礎的垃圾回收演算法,之所以說它是最基礎的是因為它最容易實現,思想也是最簡單的。標記-清除演算法分為兩個階段:標記階段和清除階段。標記階段的任務是標記出所有需要被回收的物件,清除階段就是回收被標記的物件所佔用的空間。具體過程如下圖所示:

  從圖中可以很容易看出標記-清除演算法實現起來比較容易,但是有一個比較嚴重的問題就是容易產生記憶體碎片,碎片太多可能會導致後續過程中需要為大物件分配空間時無法找到足夠的空間而提前觸發新的一次垃圾收集動作。

2.Copying(複製)演算法

  為了解決Mark-Sweep演算法的缺陷,Copying演算法就被提了出來。它將可用記憶體按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的記憶體用完了,就將還存活著的物件複製到另外一塊上面,然後再把已使用的記憶體空間一次清理掉,這樣一來就不容易出現記憶體碎片的問題。具體過程如下圖所示:

  這種演算法雖然實現簡單,執行高效且不容易產生記憶體碎片,但是卻對記憶體空間的使用做出了高昂的代價,因為能夠使用的記憶體縮減到原來的一半。

  很顯然,Copying演算法的效率跟存活物件的數目多少有很大的關係,如果存活物件很多,那麼Copying演算法的效率將會大大降低。

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

  為了解決Copying演算法的缺陷,充分利用記憶體空間,提出了Mark-Compact演算法。該演算法標記階段和Mark-Sweep一樣,但是在完成標記之後,它不是直接清理可回收物件,而是將存活物件都向一端移動,然後清理掉端邊界以外的記憶體。具體過程如下圖所示:

4.Generational Collection(分代收集)演算法

  分代收集演算法是目前大部分JVM的垃圾收集器採用的演算法。它的核心思想是根據物件存活的生命週期將記憶體劃分為若干個不同的區域。

  Java 中的堆是 JVM 所管理的最大的一塊記憶體空間,主要用於存放各種類的例項物件。在 Java 中,堆被劃分成兩個不同的區域:新生代 ( Young )、老年代 ( Old )。老年代的特點是每次垃圾收集時只有少量物件需要被回收,而新生代的特點是每次垃圾回收時都有大量的物件需要被回收,那麼就可以根據不同代的特點採取最適合的收集演算法。新生代 ( Young ) 又被劃分為三個區域:Eden、From Survivor、To Survivor。這樣劃分的目的是為了使 JVM 能夠更好的管理堆記憶體中的物件,包括記憶體的分配以及回收。

  JVM 每次只會使用 Eden 和其中的一塊 Survivor 區域來為物件服務,所以無論什麼時候,總是有一塊 Survivor 區域是空閒著的。因此,新生代實際可用的記憶體空間為 9/10 ( 即90% )的新生代空間。新生代垃圾回收採用複製演算法,清理的頻率比較高。如果新生代在若干次清理(可以進行設定)中依然存活,則移入老年代,有的記憶體佔用比較大的直接進入老年代。老年代使用標記整理演算法,清理的頻率比較低。

  目前大部分垃圾收集器對於新生代都採取Copying演算法,因為新生代中每次垃圾回收都要回收大部分物件,也就是說需要複製的操作次數較少,但是實際中並不是按照1:1的比例來劃分新生代的空間的,一般來說是將新生代劃分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden空間和其中的一塊Survivor空間,當進行回收時,將Eden和Survivor中還存活的物件複製到另一塊Survivor空間中,然後清理掉Eden和剛才使用過的Survivor空間。

  而由於老年代的特點是每次回收都只回收少量物件,一般使用的是Mark-Compact演算法。

  注意,在堆區之外還有一個代就是永久代(Permanet Generation),它用來儲存class類、常量、方法描述等。對永久代的回收主要回收兩部分內容:廢棄常量和無用的類。

  這種回收方式用了程式的一種特性:大部分物件會從產生開始在很短的時間內變成垃圾,而存在的很長時間的物件往往都有較長的生命週期。高頻對新生成的物件進行回收,稱為「小回收」,低頻對所有物件回收,稱為「大回收」。每一次「小回收」過後,就把存活下來的物件歸為「老生代」,「小回收」的時候,遇到老生代直接跳過。大多數分代回收演算法都採用的「複製收集」方法,因為小回收中垃圾的比例較大。

  這種方式存在一個問題:如果在某個新生代的物件中,存在「老生代」的物件對它的引用,它就不是垃圾了,那麼怎麼制止「小回收」對其回收呢?這裡用到了一中叫做寫屏障的方式。

三.典型的垃圾收集器

垃圾收集演算法是 記憶體回收的理論基礎,而垃圾收集器就是記憶體回收的具體實現。下面介紹一下HotSpot(JDK 7)虛擬機器提供的幾種垃圾收集器,使用者可以根據自己的需求組合出各個年代使用的收集器。

  (1)Serial收集器

Serial收集器是最基本的、歷史最悠久的收集器,曾經是JDK 1.3.1之前虛擬機器的新生代收集的唯一選擇。Serial這個名字揭示了這是一個單執行緒的垃圾收集器,特點如下:

  • 僅僅使用一個執行緒完成垃圾收集工作;
  • 在垃圾收集時必須暫停其他所有的工作執行緒,知道垃圾收集結束;
  • Stop the World是在使用者不可見的情況下執行的,會造成某些應用響應變慢;
  • 使用複製演算法;

Serial收集器的工作流程如下圖:


雖然如此,Serial收集器依然是虛擬機器執行在Client模式下的預設新生代收集器。它的優點同樣明顯:簡單而高效(單個執行緒相比),並且由於沒有執行緒互動的開銷,專心做垃圾收集自然課獲得最高的單執行緒效率。在一般情況下,垃圾收集造成的停頓時間可以控制在幾十毫秒甚至一百多毫秒以內,還是可以接受的。

(2)ParNew收集器

ParNew收集器其實是Serial收集器的多執行緒版本,與Serial不同的地方就是在垃圾收集過程中使用多個執行緒,剩下的所有行為包括控制引數、收集演算法、Stop the World、物件分配規則和回收策略等都一樣。ParNew收集器也使用複製演算法。ParNew收集器的工作流程如下圖:


ParNew收集器看似沒有多大的創新之處,但卻是許多執行在Server模式下的虛擬機器中首選的新生代收集器,因為,除了Serial收集器外,目前只有ParNew收集器能夠與CMS收集器配合工作,而CMS收集器是HotSpot在JDK 1.5時期推出的具有劃時代意義的垃圾收集器(後面會介紹到)。

ParNew收集器在單個執行緒的情況下由於執行緒互動的開銷沒有Serial收集器的效果好。不過,隨著CPU個數的增加,它對於GC時系統資源的有效利用還是很有好處的。它預設開啟的收集執行緒數與CPU的數量相同。可以使用-XX:ParallelGCThreads引數來限制垃圾收集的執行緒數。

(3)Parallel Scavenge收集器

Parallel Scavenge收集器和ParNew類似,是一個新生代收集器,使用複製演算法,又是並行的多項成收集器。不過和ParNew不同的是,Parallel Scavenge收集器的關注點不同。

CMS等收集器的關注點是儘可能縮短垃圾收集時使用者執行緒的停頓時間,而Parallel Scavenge收集器的目的則是達到一個可控制的吞吐量。吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+執行垃圾收集時間)。如果虛擬機器一共執行100分鐘,垃圾收集運行了1分鐘,那麼吞吐量就是99%。

停頓時間越短就越適合與使用者互動的程式,良好的響應速度能提升使用者體驗,而高吞吐量則可以高效的利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

Parallel Scavenge收集器提供了兩個引數來精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis引數以及直接設定吞吐量大小的-XX:GCTimeRatio引數。

MaxGCPauseMillis引數允許的值是一個大於0的毫秒數,收集器將盡可能在給定時間內完成垃圾收集。不過垃圾收集時間的縮短是以犧牲吞吐量和新生代空間為代價的,短的垃圾收集時間會導致更加頻繁的垃圾收集行為,從而導致吞吐量的降低。

GCTimeRatio引數的值是一個大於0且小於100的整數,也就是垃圾收集時間佔總時間的比率,相當於吞吐量的倒數。如果設定為19,那允許的最大GC時間就是總時間的5%(1/(1+19))。預設是99,也就是允許最大1%的垃圾收集時間。

Parallel Scavenge收集器也叫吞吐量優先收集器,它還有一個引數-XX:UseAdaptiveSizePolicy,這是一個開關引數,當這個引數開啟後,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor的比例(-XX:SurvivorRatio)、晉升老年代物件年齡(-XX:PretenureSizeThreshold)等細節了,虛擬機器會根據當前系統的執行情況收集效能監控資訊,動態調整這些引數以提供最適合的停頓時間或最大的吞吐量,這叫GC自適應的調節策略。這也是Parallel Scavenge收集器和ParNew收集器的一個重要區別。

(4)Serial Old收集器

Serial Old是Serial的老年版本,在Serial的工作流程圖中可以看到,Serial Old收集器也是一個單執行緒收集器,使用“標記-整理”演算法。這個收集器主要給Client模式下的虛擬機器使用。如果在Serve模式下,它有兩個用途:一個是在JDK 1.5之前的版本中與Parallel Scavenge收集器搭配使用;另一個就是作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用。這個收集器的工作流程在Serial的後半部分有所體現。

(5)Parallel Old收集器

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

在注重吞吐量以及CPU資源敏感的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器的組合。Parallel Old收集器的工作流程如下:


(6)CMS收集器

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。在重視響應速度和使用者體驗的應用中,CMS應用很多。

CMS收集器使用“標記-清除”演算法,運作過程比較複雜,分為4個步驟:

  • 初始標記(CMS initial mark)
  • 併發標記(CMS Concurrent mark)
  • 重新標記(CMS remark)
  • 併發清除(CMS Concurrent Sweep)

其中,初始標記和併發標記仍然需要Stop the World、初始標記僅僅標記一下GC Roots能直接關聯到的物件,速度很快,併發標記就是進行GC RootsTracing的過程,而重新標記階段則是為了修正併發標記期間因使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段長,但遠比並發標記的時間短。

由於整個過程中耗時最長的併發標記和併發清除過程收集器執行緒都可以與使用者執行緒一起工作,所以整體上說,CMS收集器的記憶體回收過程是與使用者執行緒一共併發執行的。下圖是流程圖:


CMS的優點就是併發收集、低停頓,是一款優秀的收集器。不過,CMS也有缺點,如下:

  • CMS收集器對CPU資源非常敏感。CMS預設啟動的回收執行緒數是(CPU數量+3)/4,當CPU個數大於4時,垃圾收集執行緒使用不少於25%的CPU資源,當CPU個數不足時,CMS對使用者程式的影響很大;
  • CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC;
  • CMS使用標記-清除演算法,會產生記憶體碎片;

(7)G1收集器

G1(Garbage first)收集器是最先進的收集器之一,是面向服務端的垃圾收集器。與其他收集器相比,G1收集器有如下優點:

  • 並行與併發:有些收集器需要停頓的過程G1仍然可以通過併發的方式讓使用者程式繼續執行;
  • 分代收集:可以不使用其他收集器配合管理整個Java堆;
  • 空間整合:使用標記-整理演算法,不產生記憶體碎片;
  • 可預測的停頓:G1除了降低停頓外,還能建立可預測的停頓時間模型;

G1中也有分代的概念,不過使用G1收集器時,Java堆的記憶體佈局與其他收集器有很大的差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃的避免在整個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裡垃圾堆積的價值大小(回收所獲得的空間大小以及回收所需要的時間的經驗值),在後臺維護一個優先列表,每次優先收集價值最大的那個Region。這樣就保證了在有限的時間內儘可能提高效率。

G1收集器的大致步驟如下:

  • 初始標記(Initial mark)
  • 併發標記(Concurrent mark)
  • 最終標記(Final mark)
  • 篩選回收(Live Data Counting and Evacuation)

收集器的流程如下圖:

就目前而言、CMS還是預設首選的GC策略、可能在以下場景下G1更適合:

  • 服務端多核CPU、JVM記憶體佔用較大的應用(至少大於4G)
  • 應用在執行過程中會產生大量記憶體碎片、需要經常壓縮空間
  • 想要更可控、可預期的GC停頓週期;防止高併發下應用雪崩現象

下面補充一下關於記憶體分配方面的東西:

  物件的記憶體分配,往大方向上講就是在堆上分配,物件主要分配在新生代的Eden Space和From Space,少數情況下會直接分配在老年代。如果新生代的Eden Space和From Space的空間不足,則會發起一次GC,如果進行了GC之後,Eden Space和From Space能夠容納該物件就放在Eden Space和From Space。在GC的過程中,會將Eden Space和From Space中的存活物件移動到To Space,然後將Eden Space和From Space進行清理。如果在清理的過程中,To Space無法足夠來儲存某個物件,就會將該物件移動到老年代中。在進行了GC之後,使用的便是Eden space和To Space了,下次GC時會將存活物件複製到From Space,如此反覆迴圈。當物件在Survivor區躲過一次GC的話,其物件年齡便會加1,預設情況下,如果物件年齡達到15歲,就會移動到老年代中。

  一般來說,大物件會被直接分配到老年代,所謂的大物件是指需要大量連續儲存空間的物件,最常見的一種大物件就是大陣列,比如:

  byte[] data = new byte[4*1024*1024]

  這種一般會直接在老年代分配儲存空間。

  當然分配的規則並不是百分之百固定的,這要取決於當前使用的是哪種垃圾收集器組合和JVM的相關引數。


參考:

Java垃圾回收小結 Java垃圾收集機制 深入理解 Java 垃圾回收機制