JVM-GC設計思路分析

     JAVA中將內存的控制交給JVM來實現,方便了JAVA程序猿,當然犧牲了一部分效率,不過總體來看是值得的。那麼JVM中是如何設計GC的呢,本文從幾個問題入手,然後分析了一下設計思路,如果有理解錯誤的地方,請批評指正!主要參考了《深入理解JAVA虛擬機》這本書,圖是盜來的,圖的內容和書上一樣。

    在JVM的內存模型中,堆內存是JAVA內存區域中最大的一部分,GC主要就是發生在堆中,用來回收那些無用的對象。這樣直接就引申出了第一個問題:什麼樣的對象需要被回收?判斷條件是什麼?如何判斷?


    先談談什麼對象需要被回收,OK,我們自己想一想,肯定是沒用的對象需要被回收,對吧?那麼如何判斷哪些對象還有用,哪些沒用了呢?一個對象被創建,如果被引用了,那這個對象肯定是有用的對吧,如果引用全失效了,那就是沒用的對象了,需要被回收。基於這個思想,引用計數法誕生了。

  • 引用計數算法:這個非常容易理解,給每個對象添加一個引用計數器,對象每被引用一次,引用計數器就+1,引用失效時就-1。那麼判斷一個對象是否有用的條件就變成了對這個計數器值得判斷了,如果爲0,那麼被回收,如果爲>0,那麼保留。但是這種方式會產生一個問題,就是對象之間的循環引用無法被識別,即使這兩個對象不能被訪問,但是它們之間互相引用着對方,故而計數器肯定>0,那麼就不能被回收。JVM中並沒有使用引用計數算法,而是使用了根搜索算法
  • 根搜索算法:這個算法也不難理解,通過條件,選擇一系列的對象成爲“GC Roots"對象,然後將”GC Roots"對象作爲起始點開始向下搜索,搜索所有走過的路徑成爲“引用鏈”。在這個引用鏈上的對象就保留,而如果一個或多個互相引用的對象不在這個引用鏈上,或者說對象到“GC Roots"不可達,那麼這些就是無用的對象,都需要被回收。


注:Java語言中,可作爲GC Roots的對象包括下面幾種:

1) 虛擬機棧(棧幀中的本地變量表)中引用的對象

2) 方法區中類靜態屬性引用的對象

3) 方法區中常量引用的對象

4) 本地方法棧中JNI(即一般說的Native方法)引用的對象


既然根搜索算法需要考慮到對象之間的引用,那麼就要說一下JAVA中對象的引用類型了:

從JDK1.2之後,Java對引用的概念進行了擴充,將引用分爲強引用,軟引用,弱引用,虛引用,這四種引用的強度依次減弱

1) 強引用就是指在程序代碼之中普遍存在的,類似 “Object obj = new Object()” 這類的引用,只要強引用還存在,垃圾回收器永遠不會回收被引用的對象。我們也正是利用這個原理來重現了OOM異常。

2) 軟引用(SoftReference類)是用來描述一些還有用但並非需要的對象,對於軟引用關聯着的對象,在系統將要發生內存異常之前,將會把這些對象列進回收範圍之中進行第二次回收,如果這次回收還沒有足夠的內存,纔會拋出內存異常

3) 弱引用(WeakReference類)也是用來描述非必需對象的,被弱引用關聯的對象只能生存到下一次GC發生之前,當垃圾收集器工作時,無論當前內存釋放足夠,都會回收掉只被弱引用關聯的對象

4) 虛引用(PhantomReference類)也稱爲幽靈引用或者幻影引用,它是最弱的一種引用關係,一個對象是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例,對一個對象設置虛引用關聯的唯一目的就是能在這個對象被收集器回收時收到一個系統通知


    那麼上述內容看完之後想必都知道了什麼樣的對象會被GC了吧,那麼JVM又是通過什麼方式來回收這些內存的呢?下面就需要了解一下垃圾的回收算法了。

標記-清除算法
    試着想一想,如果要你要設計一個算法清除滿足收集條件的對象來釋放內存的時候你該怎麼做呢?最簡單的是不是就是把需要回收的對象標記一下,然後直接全部回收就行了?照着這個思路就是”標記-清除算法”的思想了,算法分爲“標記”和“清除”兩個階段:首先標記出所有需要回收的對象,在標記完成後統一回收掉所有被標記的對象。想法很簡單,實際也就是這麼做的。但是呢,這種方式是不是最好的?有什麼缺陷?
    想到這裏,就需要分析一下了。一個個的標記然後清除,效率高嗎?當然不。看看下圖的標記-清除算法的示意圖,可以發現,標記-清除之後會產生大量的內存碎片,如果碎片太多,當程序運行沒有足夠連續的內存空間來存放大對象的時候,就會不得不提前觸發一次GC。概括來說就是有兩個缺點:效率不高;內存碎片可能導致提前發生GC。
    學習算法的童鞋應該都很清楚,效率是很重要的,有時候需要使用空間來換時間提高效率,那麼就需要了解一下第二種回收算法了——複製算法。

  • 複製算法

    複製算法呢?它的思想就是空間換時間,將內存容量劃分成相等的兩塊,當這一塊的內存用完了,就將還存活的內存複製到另一塊上,然後再把使用過的內存空間一次性清理乾淨。這樣每次都是對其中的一塊的內存進行回收,也就不需要考慮內存碎片等複雜情況了,只需要移動堆頂指針,然後按照順序分配即可,實現簡單,運行高效。但是缺點也很明顯:內存變成一半了.......下圖就是複製算法的示意圖:

    我們知道,在JVM中堆內存的新生代(new )中的對象存活率較低,採用複製算法每次需要複製的對象也不是很多,效率較高,空間換時間值得的。現在的商業虛擬機都是採用複製算法來回收新生代,IBM的專門研究表明:新生代中對象98%是朝生夕死,所以並不需要按照1:1的比例來劃分空間來實現複製算法,而是將內存分爲一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden空間和其中一個Survivor空間。當發生GC的時候,將Eden空間和Survivor空間中還存活的對象拷貝到另一個沒使用的Survivor空間中,然後再清理掉Eden和剛剛使用的Survivor空間。Hotspot虛擬機默認Eden和Survivor的大小比例是8:1,也就是新生代每次可以使用的內存空間是整個新生代的90%,只有10%的空間會被浪費。

    OK,通過上述的分析,我們知道了在JVM中對於新生帶的垃圾回收使用的複製算法(此時發生的GC成爲young gc),效率高,我們也就只犧牲了10%的內存空間,挺不錯的。請注意這裏提到的young gc,後面會提到full gc。但是雖然IBM研究表明一般情況下有98%的對象是朝生夕死,需要回收的,但是不能保證每次回收的時候對象的存活率都低於10%啊,是不是?一旦超過了10%,那麼空閒的survivor空間就不夠用了,此時就必須依賴老年代的空間來進行分配擔保(就相當於A找B借錢,C替A做擔保,保證如果A換不起就自己來還,C就是擔保人,映射到內存中老年代所佔內存就是擔保人)。如果空閒的Survivor空間無法存放上次GC之後的存活對象,那麼這些對象就會通過分配擔保機制進入老年代。

    老年代呢,裏面保存的都是生存週期較長的對象(老年代裏面的對象都是經過了新生代,然後多次存活下來的對象),而複製算法在應對這種存活率極高的內存區域的對象回收時,需要執行較多的複製操作,效率將會變低。關鍵的還是如果不想浪費50%的空間,那麼就需要分配擔保機制(參考新生代的設計),但是並沒有額外的空間來擔保了。所以對於老年代的特性,有人提出了一種“標記-整理算法”,看到這裏肯定就想到了前面提到的“標記-清除算法“了,OK,這兩個算法標記的過程都是一樣的,就在於”標記-整理算法”不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的內存,示意圖如下圖所示。

    很明顯,這種”標記-整理算法“的效率不高,所以如果老年代發生GC,那麼效率也就不高了,並且一旦老年代發生GC,那麼發生的必然是Full GC ,Full GC 會同時對老年代和新生代進行GC操作,順便也會回收一下perm gen中的內存,所以相比較young gc來說很慢,我們在JVM調優的時候需要避免JVM頻繁發生full gc。full gc的速度比young gc要慢10倍。

  • 分代收集算法
    通過上述的分析呢,就知道了對於堆中的新生代和老年代會採用不同的垃圾回收算法來回收“死亡”的對象,這種分代回收對象的方法稱爲“分代收集算法”。這個分代收集算法根據各個年代的特點採用適當的收集算法。在新生代中,每次GC的時候都發現大批的對象死去,只有少量存活,自然選用複製算法;而對於老年代這種存活率高、沒有額外擔保空間的,就必須使用“標記-清除算法”或者“標記-整理算法“了。

    GC設計的理論基礎就是這些了,其實原理還是比較容易理解的。GC的具體實現就是垃圾收集器,目前尚沒有一個垃圾收集器是完美的,需要配合使用。下面插上一副堆內存劃分圖。

注:本文寫的比較片面,如果想更深入瞭解,推薦這篇博文:http://jbutton.iteye.com/blog/1569746


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章