《深入理解java虛擬機器》讀書筆記3(垃圾收集器與記憶體分配策略)

狂奔的CD發表於2018-02-24

1.垃圾收集Garbage Collection GC

》考慮3個問題:

哪些記憶體需要回收?
什麼時候回收?
如何回收?

》哪些記憶體需要回收?(主要考慮java堆和方法區)

上一節瞭解到程式計數器,虛擬機器棧,本地方法棧三個區域是執行緒私有的,隨執行緒而生,隨執行緒而滅,記憶體自動釋放,且這些區域的記憶體分配和回收都具備確定性,不做多考慮。
而java堆和方法區不一樣,一個介面的多個實現類需要的記憶體可能不一樣,一個方法的多個分支需要的記憶體也可能不一樣。只有執行時才知道建立了哪些物件,這部分記憶體的分配和回收都是動態的。

》java堆什麼時候回收?

1)在對java堆進行回收前先判斷java物件是否死去
2)java中並不是採用引用計數法(物件中新增引用計數器,被引用+1,引用失效後-1)。證明如下:

/**
     * VM:-XX:+PrintGCDetails列印gc詳情
     * 迴圈引用記憶體釋放測試
     */
    static void loopGCTest(){
        MyClass c1 = new MyClass();
        MyClass c2 = new MyClass();
        //互相引用
        c1.ref = c2;
        c2.ref = c1;

        //釋放a1,a2
        c1=null;
        c2=null;

        //gc
        System.gc();
    }

    static class MyClass{
        //佔點記憶體
        private byte[] data = new byte[2*1024*1024];
        //指標
        private Object ref = null;
    }

這裡建立兩個物件,每個物件有2M大的變數,通過ref互相引用,執行結果如下:
這裡寫圖片描述
證明了迴圈引用時,引用不失效,也可以被java回收。所以java中沒有采用引用計數法

3)java中採用根搜尋演算法(GC Root Tracing)
通過一系列名為“GC Roots”的物件作為起始點,往下搜尋,走過的路徑稱為引用鏈(Reference Chain),當物件到GC Roots不可達,即判定該物件不可用(ps:注意這裡只是不可用而已,並不是死亡)。
這裡寫圖片描述

4)java中的引用
jdk1.2之前,引用的定義很窄,如果reference中的記憶體儲存了另一塊記憶體的起始地址就稱為引用。這種定義下,只有引用和沒有被引用兩種狀態。
jdk1.2之後,對引用的概念進行了擴充,被劃分成我們熟知的強引用,軟引用,弱引用,
虛引用。
強引用:比如Object a = new Object()這類,只要強引用存在,就不會被GC
軟引用:如果即將出現OOM,將這類物件列入回收名單,進行二次GC,提供了SoftReference類
弱引用:已列入GC名單,只要GC就會回收。提供了WeakReference類
虛引用:相當於未引用,無法通過該引用獲取物件例項,唯一目的是在其被回收時收到一個系統通知,提供了PhantomReference類

5)java物件死亡過程
GC Roots不可達的物件->判斷是否有必要執行finalize()方法(PS:物件沒有覆蓋該方法,或者該方法已執行過一次即視為沒有必要)->沒有必要則死亡,有必要則該物件被放置於F-Queue佇列中,並在稍後一條由虛擬機器自動建立的低優先順序的Finalizer執行緒中執行(只是觸發而已,不會等待其執行完畢,個人理解是非同步執行,避免卡住佇列),在finalize()方法中如果重新引用則可以不死。

書中寫了一段程式碼來驗證這個過程,這裡不寫了,一個物件的finalize()方法只會被系統執行一次,該方法不建議使用。

》方法區的垃圾回收

方法區的回收效率比較低下,永久代的垃圾回收主要回收:廢棄常量和無用的類。回收廢棄常量與回收物件類似。
這裡寫圖片描述

》如何回收?

各種平臺記憶體操作不一樣,介紹幾種演算法實現思想:
1)標記-清除演算法
原理:標記出所有需要回收的物件,標記完成後統一回收
缺點:標記和清除的效率不高;容易產生大量記憶體碎片,記憶體不夠用的時候可能導致頻繁GC
2)複製演算法
原理:考慮到記憶體碎片的問題,該演算法將記憶體分為兩塊,僅使用其中一塊,當GC發生時,將存活的物件依次複製到另一塊記憶體中,然後一次性清除當前記憶體。這樣就沒有記憶體碎片的問題了。
缺點:實現簡單,執行高效,但是代價是記憶體縮小為1/2。

現在的商業虛擬機器採用了這種演算法來回收新生代。但是經過了優化,考慮GC發生時絕大部份物件都是死去的,只要一小塊記憶體就可以複製完畢,將原來的2塊變為3塊記憶體,1塊較大的Eden空間,2塊較小的Survivor空間,當GC時將Eden和一塊已使用的Survivor空間的存活物件複製到另一塊未使用的Survivor空間上,這樣空間使用率大大增加。HotSpot虛擬機器Eden與Survivor的比例是8:1,這樣僅損耗10%的空間。

3)標記-整理演算法
複製演算法用於複製,如果物件多效率低,還考慮100%存活的這種極端情況,老年代一般不採用這種方式,對標記-清除進行優化,所有存活物件進行整理,往前移動,直接清除後面空出的部分。

4)分代收集演算法
綜上現在的商業虛擬機器形成了分代收集演算法,因地制宜。一般把java堆分為新生代和老年代,新生代中物件大批死去,採用加強版複製演算法,老年代中物件存活率高,一般採用標記-清除或者標記-整理演算法。

物件存活久了就由新生代變為老年代(ps:請看參考文章詳細瞭解這一點,這樣就能明白下文中垃圾回收器新生代和老年代為啥要配合使用)
參考:關於新生代和老年代https://www.cnblogs.com/E-star/p/5556188.html

2.常見垃圾回收器(HotSpot為例)

》Serial收集器

jdk1.3.1之前新生代的唯一選擇,單執行緒,必須停掉所有工作執行緒stop the world,(ps:還記得安卓gc時會造成卡頓嗎?),但它依然是Client模式下的預設新生代收集器,簡單高效

》ParNew收集器

多執行緒版的Serial,Server模式下首選新生代收集器,和Serial一樣能與CMS收集器(jdk1.5出的老年代收集器)配合工作,單CPU中,採用Serial更合適

》Parallel Scavenge收集器

與ParNew一樣新生代,多執行緒,不一樣的是它的目標是達到可控的吞吐量(執行使用者程式碼時間/(執行使用者程式碼時間+GC時間))。停頓時間短適合Client環境這種與使用者互動多的情況,響應時間短,例如安卓手機,而高吞吐量則可以最高效率利用CPU時間,主要適合後臺這樣互動少而運算量大的場景

》Serial Old收集器

看名字就知道是老年代收集器,也是單執行緒的,主要用於Client模式下,如果用於Server模式它主要用於與Parallel Scavenge配合使用以及CMS的後備方案。

》Parallel Old收集器

jdk1.6出的,多執行緒,老年代方案,基於標記-整理演算法。解決了Parallel Scavenge高吞吐量策略只能搭配Serial Old老年代單執行緒的問題。

》CMS收集器

以獲取最短GC停頓時間為目標的老年代收集器,併發收集,基於標記-清除演算法,jdk1.5版本出的,有3個著名的缺點:
一是佔用cpu資源(對cpu資源敏感),預設啟用執行緒數(cpu數量+3)/4,當cpu低於4時,佔用cpu較多比如4個佔了2個,且在初始標記和併發標記階段會stop the world,導致使用者程式執行速度變慢;
二是無法處理浮動垃圾,由於CMS在併發清理階段與使用者執行緒並行,會產生新的垃圾,這部分被稱為浮動垃圾,還要預留空間儲存,所以CMS不能像其他垃圾收集器一樣等待老年代幾乎滿了進行清理,預設到了68%就會啟用清理。如果記憶體不夠就會出一次Concurrent Mode Failure,這時虛擬機器會採用後備應急方案,啟用Serial Old清理老年代(也就是剛才說的是CMS的後備方案)。
三是標記-清除演算法的硬傷,空間碎片問題,前面提到過了。

》G1收集器

當前最好的收集器了,回收範圍是整個java堆,基於標記-整理,可以精準控制停頓,將java堆劃分成多個獨立區域,優先垃圾最多的區域回收。

3.記憶體分配與回收策略

》記憶體分配策略

策略不一,幾條普遍的記憶體分配規則:
1)優先在Eden分配,如果Eden空間不夠,則發起Minor GC(新生代垃圾回收)
2)大物件直接進入老年代,這樣避免在新生代發生大量拷貝
3)長期存活物件進入老年代
4)動態物件年齡判定,為了適應更多的記憶體情況,Survivor中相同年齡所有物件大小總和大於空間的一半,大於等於該年齡的物件直接進入老年代
5)空間分配擔保,當發生Minor GC時,虛擬機器會檢測之前每次晉升到老年代的平均大小是否大於老年代的剩餘空間大小,如果大於,則改為一次Full GC(老年代gc),如果小於,則檢視HandlePromotionFailure設定是否允許擔保失敗,如果允許,則只會進行Minor GC,如果不允許,則改為Full GC。

相關文章