JVM學習筆記(二):垃圾收集

程序計數器、 虛擬機棧、 本地方法棧3個區域隨線程而生,隨線程而滅;棧中的棧幀隨着方法的進入和退出而有條不紊地執行着出棧和入棧操做。 每個棧幀中分配多少內存基本上是在類結構肯定下來時就已知的,所以這幾個區域的內存分配和回收都具有肯定性,在這幾個區域內就不須要過多考慮回收的問題,由於方法結束或者線程結束時,內存天然就跟隨着回收了。 而Java堆和方法區則不同,一個接口中的多個實現類須要的內存可能不同,一個方法中的多個分支須要的內存也可能不同,咱們只有在程序處於運行期間時才能知道會建立哪些對象,這部份內存的分配和回收都是動態的,垃圾收集器所關注的是這部份內存。html

自動垃圾回收機制就是尋找Java堆中的對象,並對對象進行分類判別,尋找出正在使用的對象和已經不會使用的對象,而後把那些不會使用的對象從堆上清除。java

1、哪些對象須要回收
1. 引用計數算法
引用計數算法:給對象中添加一個引用計數器,每當有一個對象引用它時,計數器值就加1;當引用失效時,計數器值就減1;任什麼時候刻計數器爲0的對象就是不可能再被使用的。
主流Java虛擬機中並無採用引用計數算法,緣由:它很難解決對象之間相互循環引用的問題。算法

例:數組

/**
 * 添加參數:-XX:+PrintGC
 * testGC()方法執行後,objA和objB會不會被GC呢?
 *
 */
public class ReferenceCountingGC {
    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 這個成員屬性的惟一意義就是佔點內存,以便在能在GC日誌中看清楚是否有回收過
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA; //互相引用對方

        objA = null;
        objB = null; 

        // 假設在這行發生GC,objA和objB是否能被回收?
        System.gc();
    }
    
    public static void main(String[] args) {
        ReferenceCountingGC referenceCountingGC = new ReferenceCountingGC();
        ReferenceCountingGC.testGC();
    }
}

objA和ojbB都被設置成了null,在GC時,應回收這樣的對象,由於這兩個對象已經不可能再被訪問(對象已經爲null)。但若是按照引用計數算法來看,雖然這兩個對象都被設置成了null,但它們還在互相引用,因此各自的計數器都還不是0,因此不能被回收。服務器

運行結果:多線程

能夠看出虛擬機並無由於這兩個對象互相引用就不回收它們,說明虛擬機不是經過引用計數算法判斷對象是否存活的。併發

2. 可達性分析算法
Java虛擬機主要是經過可達性分析來斷定對象是否存活的。
這個算法的基本思路就是經過一系列的稱爲「GC Roots」的對象做爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈,當一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來講,就是從GC Roots到這個對象不可達時,則證實此對象是不可用的。jvm

如圖中,對象object四、object五、object6雖然互相有關聯,但他們到GC Roots是不可達的,因此它們將會被斷定爲可回收的對象。編輯器

在Java語言中,可做爲GC Roots的對象包括下面幾種:
(1). 虛擬機棧(棧幀中的本地變量表)中引用的對象。
(2). 方法區中類靜態屬性引用的對象。
(3). 方法區中常量引用的對象。
(4). 本地方法棧中JNI(即通常說的Native方法)引用的對象。
這裏關於引用:
JDK1.2以後,引用分爲:強引用、軟引用、弱引用、虛引用,四種引用強度逐漸減弱。ide

3. 兩次標記過程
即便在可達性分析算法中不可達的對象,也並不是是「非死不可」的,這時候它們處於「緩刑」階段,要真正宣告一個對象死亡,至少要經歷兩次標記過程。

第一次標記
若是對象在進行可達性分析後發現沒有與GC Roots相鏈接的引用鏈,則該對象被第一次標記並進行一次篩選,篩選條件爲是否有必要執行該對象的finalize()方法
(1). 若對象沒有覆蓋finalize()方法,或該finalize()方法是否已經被虛擬機執行過了,虛擬機將這兩種狀況都視爲「沒必要要執行該對象的finalize()方法」。即該對象將會被回收。
(2). 對象有必要執行finalize()方法

第二次標記
若對象覆蓋了finalize()方法,而且該finalize()方法尚未被執行過,則說明該對象有必要執行finalize()方法。 (注:Finalize()方法是對象脫逃死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模標記。)
那麼,這個對象會被放置在一個叫F-Queue的隊列中,並在稍後由虛擬機自動創建的、優先級低的Finalizer線程去執行這個finalize()方法。這裏所謂的「執行」是指虛擬機會觸發這個方法,但並不承諾等待該線程執行結束。即虛擬機只負責創建線程,其餘的事情交給此線程去處理。
若是對象在finalize()方法中拯救了本身,即關聯上了GC Roots引用鏈,如把this關鍵字賦值給其餘變量。那麼在第二次標記的時候它將從「即將回收」的集合中移除,若是對象仍是沒有拯救本身,那基本上它就真的被回收了。

總結:
須要回收的對象:
從Roots搜索不到,並且通過第一次標記後,不必執行finalize()方法的對象將被回收;執行finalize()方法後,仍沒與Roots創建關聯的對象,將會被回收。

對象自我拯救的栗子:

/**
 * 1. 對象在被GC時,能夠自我拯救
 * 2. 這種自救的機會只有一次,由於一個對象的finalize()方法最多隻會被系統自動調用一次
 */
public class FinalizeEscapeGC {
    
    public String name;
    public static FinalizeEscapeGC SAVE_HOOK = null;
    
    public FinalizeEscapeGC(String name){
        this.name = name;
    }

    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!");
        System.out.println("this: " + this);
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
    
    @Override
    public String toString(){
        return name;
    }
    
    public static void main(String[] args) throws Throwable{
        // TODO Auto-generated method stub
        SAVE_HOOK = new FinalizeEscapeGC("first");
        
        System.out.println("SAVE_HOOK: " + SAVE_HOOK);

        //對象第一次成功拯救本身
        SAVE_HOOK = null; //SAVE_HOOK被設置成了null,所以,在GC時,應該被回收
        System.out.println("SAVE_HOOK: " + SAVE_HOOK); //SAVE_HOOK = null,雖然佔有內存,但值爲null
        System.gc();
        // 由於Finalizer方法優先級很低,暫停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();
        // 由於Finalizer方法優先級很低,暫停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

運行結果:

能夠看出SAVE_HOOK對象的finalize()方法確實被GC收集器觸發過,而且在收集前成功逃脫了。
另外,任何一個對象的finalize()方法都只會被系統自動調用一次,若是對象面臨下一次回收,它的finalize()方法不會被再次執行,所以第二段代碼的自救行動失敗。
此外,一個堆對象的this(放在局部變量表中的第一項)引用會永遠存在,在方法體內能夠將this引用賦值給其餘變量,這樣堆中對象就能夠被其餘變量所引用,即不會被回收

2、對象怎麼被回收
(一). 垃圾收集算法

1. 標記-清除算法
標記-清除算法是最基礎的收集算法,如同它的名字樣,分爲「標記」和「清除」兩個階段:首先標記出全部須要回收的對象,標記完成後統一回收全部被標記的對象(標記過程就是上面所述的對象標記過程)。
主要缺點:
(1). 效率問題:標記和清除兩個過程的效率都不高;
(2). 空間問題:標記清除後會產生大量不連續的內存碎片, 內存碎片太多可能會致使之後程序運行過程當中在須要分配較大對象時,沒法找到足夠的連續內存而不得不提早觸發一次垃圾收集動做。
標記-清除算法的執行過程:

2. 複製算法
複製算法是爲了解決效率問題而出現的。它將可用的內存分爲大小相等的兩塊,每次只用其中一塊,當這一塊內存用完了,就將還存活着的對象複製到另一塊上面,而後再把已經使用過的內存空間一次性清理掉。
主要缺點:內存縮小爲了原來的一半,算法代價過高
複製算法的執行過程:

應用於虛擬機:
如今的商用虛擬機中新生代都採用複製算法來回收。新生代的內存被劃分爲一塊較大的Eden空間和兩塊較小的Survivor空間(分別叫from和to),每次使用Eden和其中一塊Survivor。每次回收時,將Eden和Survivor中還存活着的對象一次性複製到另一塊Survivor空間上,最後清理掉Eden和剛纔用過的Survivor空間。HotSpot虛擬機默認Eden區和Survivor區的比例爲8:1,也就是每次新生代中可用內存空間爲整個新生代容量的90%,只有10%的內存會被「浪費」。
固然,咱們沒有辦法保證每次回收都只有很少於10%的對象存活,當Survivor空間不夠用時,須要依賴老年代進行分配擔保。

虛擬機給每一個對象定義了一個對象年齡計數器。通常狀況下,新建立的對象都會被分配到Eden區(一些大對象特殊處理),這些對象通過第一次Minor GC後,若是仍然存活,而且可以被Survivor容納的話,將會被移到Survivor區,並把對象年齡設爲1。對象在Survivor區中每「熬過」一次Minor GC,年齡就會增長1歲,當它的年齡增長到必定程度(默認是15歲),就會被晉升到年老代中。

具體而言,在GC開始的時候,對象只會存在於Eden區和名爲「From」的Survivor區,Survivor區「To」是空的。緊接着進行GC,Eden區中全部存活的對象都會被複制到「To」;而在「From」區中,仍存活的對象會根據他們的年齡值來決定去向。年齡達到必定值(年齡閾值,能夠經過-XX:MaxTenuringThreshold來設置)的對象會被移動到年老代中,沒有達到閾值的對象會被複制到「To」區域。

通過此次GC後,Eden區和From區已經被清空。這個時候,「From」和「To」會交換他們的角色,也就是新的「To」就是上次GC前的「From」,新的「From」就是上次GC前的「To」。無論怎樣,都會保證名爲To的Survivor區域是空的。Minor GC會一直重複這樣的過程,直到「To」區被填滿,「To」區被填滿以後,會將全部對象移動到年老代中。

參考:聊聊JVM的年輕代 

3. 標記-整理算法
標記-整理算法過程與標記-清除算法同樣,不過不是直接對可回收對象進行清理,而是讓全部存活對象都向一端移動,而後直接清理掉邊界之外的內存
標記-整理算法的執行過程:

應用於虛擬機:
虛擬機中老年代採用標記-整理算法。(若採用複製算法,就須要浪費50%的空間,若是不想浪費空間,就須要有額外的空間進行分配擔保,以應對被使用的內存中全部對象都100%存活的極端狀況。)

4. 分代收集算法
現代商用虛擬機基本都採用分代收集算法來進行垃圾回收。這種算法並無什麼新思想,只是根據對象存活週期的不一樣將內存劃分爲幾塊(通常Java堆分紅新生代和老年代),而後根據各個年代的特色採用最適當的收集算法。
在新生代中,每次垃圾收集時,都有大批對象死去,只有少許存活,採用複製算法(複製成本低);
在老年代中,對象存活率高,沒有額外空間對它進行分配擔保,採用標記-清除算法或標記-整理算法。

(二). 垃圾收集器
垃圾收集器是垃圾收集算法的具體實現。
不一樣虛擬機所提供的垃圾收集器可能會有很大差異,這裏討論的是HotSpot,HotSpot虛擬機所包含的全部收集器如圖:

上圖展現了7種做用於不一樣分代的收集器,若是兩個收集器之間存在連線,那說明它們能夠搭配使用。虛擬機所處的區域,表示它是屬於新生代收集器仍是老年代收集器。

1. Serial收集器
Serial收集器是最基本、發展歷史最久的收集器,是一個採用複製算法的單線程的新生代收集器。
特色:
(1). 針對新生代;
(2). 採用複製算法;
(3). 單線程。 它的「單線程」意義:
  a. 它只會使用一個CPU或一條線程去完成垃圾收集工做
  b. 它進行垃圾收集時必須暫停其餘線程的全部工做,直到它收集結束(STW,Stop The World)。
應用場景:
Serial收集器是虛擬機運行在Client模式下的默認新生代收集器
優勢:簡單高效。對於單個CPU環境而言,Serial收集器因爲沒有線程交互的開銷,專心作垃圾收集天然能夠得到最高的單線程收集效率。
Serial/Serial Old收集器運行示意圖:

 

2. ParNew收集器
ParNew收集器其實就是Serial收集器的多線程版本,在可用控制參數、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器徹底同樣。是一個採用複製算法的並行多線程收集器,是一個新生代收集器。
特色:
(1). 與Serial收集器相同的特色:
  a. 針對新生代;
  b. 採用複製算法;
  c. STW
(2). 主要特色:
多線程。使用多線程進行垃圾收集。
應用場景:
它是Server模式下的虛擬機首選的新生代收集器。但在單個CPU環境中,不會比Serail收集器有更好的效果,由於存在線程交互的開銷。
ParNew/Serial Old收集器運行示意圖:

3. Parallel Scavenge收集器
Parallel Scavenge收集器也是一個採用複製算法的並行多線程收集器,是一個新生代收集器。Parallel Scavenge收集器由於與吞吐量關係密切,也稱爲吞吐量收集器。
特色:
(1). 與ParNew收集器相同的特色:
  a. 新生代收集器;
  b. 採用複製算法;
  c. 多線程收集;
(2). 主要特色:
Parallel Scavenge收集器的目標是達一個可控制的吞吐量,而CMS等收集器的關注點是儘量地縮短垃圾收集時用戶線程的停頓時間。
所謂吞吐量的意思就是CPU用於運行用戶代碼時間與CPU總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總運行100分鐘,垃圾收集1分鐘,那吞吐量就是99%。
應用場景:
高吞吐量爲目標,即減小垃圾收集時間,讓用戶代碼得到更長的運行時間。
當應用程序運行在具備多個CPU上,對暫停時間沒有特別高的要求時,即程序主要在後臺進行計算,而不須要與用戶進行太多交互。例如,那些執行批量處理、訂單處理、工資支付、科學計算的應用程序

4. Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,它是一個單線程收集器,使用「標記-整理算法」。
特色:
特色
(1). 針對老年代;
(2). 採用"標記-整理"算法(還有壓縮,Mark-Sweep-Compact);
(3). 單線程。
應用場景:
(1). 用於給Client模式下的虛擬機使用;
(2). 在Server模式下使用有兩大用途:
  a. 在JDK1.5及以前,與Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配);
  b. 做爲CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用

5. Parallel Old收集器
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和「標記-整理」算法。在Parallel Old收集器出現後,「吞吐量優先收集器」終於有了比較名副其實的應用組合,在注重吞吐量以及CPU資源敏感的場合,均可以優先考慮Parallel Scavenge收集器+Parallel Old收集器的組合。
特色:
(1). 針對老年代;
(2). 採用"標記-整理"算法;
(3). 多線程收集;
(4). 吞吐量可控。
應用場景:
在注重吞吐量以及CPU資源敏感的場合,均可以優先考慮Parallel Scavenge收集器+Parallel Old收集器的組合。
Parallel Scavenge/Parallel Old收集器運行示意圖:

6. CMS收集器
CMS收集器,是以獲取最短回收停頓時間爲目標的收集器。使用標記-清除算法。
特色:
(1). 針對老年代;
(2). 採用"標記-清除"算法(產生內存碎片);
(3). 以獲取最短回收停頓時間爲目標;
(4). 併發收集、低停頓,所以也被稱爲併發低停頓收集器。
應用場景:
與用戶交互較多的場景;注重服務的響應速度,但願系統停頓時間最短,以給用戶帶來較好的體驗,如常見WEB、B/S系統的服務端上。

主要缺點:
(1). 對CPU資源很是敏感;
(2). 沒法處理浮動垃圾;
(3). 因爲採用的標記 - 清除算法,會產生大量的內存碎片,不利於大對象的分配,可能會提早觸發一次Full GC。
CMS收集器運行示意圖:

7. G1收集器
G1是目前技術發展的最前沿成果之一,HotSpot開發團隊賦予它的使命是將來能夠替換掉JDK1.5中發佈的CMS收集器。
特色:
(1). 並行和併發。能充分利用多CPU、多核環境下的硬件優點,使用並行來縮短Stop The World停頓時間;也能夠併發讓垃圾收集與用戶線程同時進行。
(2). 分代收集。獨立管理整個GC堆(新生代和老年代),可以採用不一樣的方式處理不一樣時期的對象。
(3). 空間整合。從總體看,是基於標記-整理算法;從局部(兩個Region間)看,是基於複製算法;不會產生內存空間碎片。
(4). 可預測的停頓。G1除了追求低停頓處,還能創建可預測的停頓時間模型,能夠明確指定M毫秒時間片內,垃圾收集消耗的時間不超過N毫秒。
應用場景:
面向服務端應用,針對具備大內存、多處理器的機器;最主要的應用是爲須要低GC延遲,並具備大堆的應用程序提供解決方案;

G1收集器運行示意圖:

3、對象何時被回收
1. 什麼時候發生Minor GC
大多數狀況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。

2. 什麼時候發生Full GC
(1). System.gc()方法被調用

(2). 老年代空間在新生代對象轉入,或在老年代建立大對象大數組,內存空間不足時,執行Full GC。
當執行Full GC後空間仍然不足,報錯:java.lang.OutOfMemoryError: Java heap space

(3). perm/metaspace 空間不足
JVM規範中運行時數據區域中的方法區,在HotSpot虛擬機中又被習慣稱爲永生代或者永生區,
Permanet Generation中存放的爲一些class的信息、常量、靜態變量等數據,當系統中要加載的類、反射的類和調用的方法較多時,
當Full GC後空間仍然不足,報錯:java.lang.OutOfMemoryError: PermGen space

(4). CMS GC時出現promotion failed和concurrent mode failure
promotion failed是在進行Minor GC時,survivor space放不下, 對象只能放入老年代,而此時老年代也放不下形成的;
concurrent mode failure是在執行CMS GC的過程當中同時有對象要放入老年代,而此時老年代空間不足形成的

(5). 判斷當前新生代的對象是否可以所有順利的晉升到老年代,若是不能,就提前觸發一次老年代的收集
參考:對象什麼時候進入老年代、什麼時候發生full gc 

幾個概念:
1. 併發垃圾收集和並行垃圾收集的區別
(1). 並行:指多條垃圾收集線程並行工做,但此時用戶線程仍然處於等待狀態。
(2). 併發:指用戶線程與垃圾收集線程同時執行(但不必定是並行的,可能會交替執行),用戶程序在繼續運行,而垃圾收集程序線程運行於另外一個CPU上。

2. Minor GC和Full GC的區別
(1). 新生代GC(Minor GC):指發生在新生代的垃圾收集動做。由於Java對象大多都具有朝生夕滅的特性,因此Minor GC很是頻繁,通常回收速度也比較快。
(2). 老年代GC(Major GC / Full GC):指發生在老年代的GC。出現了Full GC,常常會伴隨至少一次的Minor GC(但並不是絕對的,Parallel Sacvenge收集器的收集策略裏就有直接進行Full GC的設置)。Full GC速度通常比Minor GC慢10倍以上。

3. Client模式和Server模式的區別
部分商用虛擬機中,Java程序最初是經過解釋器對進行解釋執行的,當虛擬機發現某個方法或代碼塊運行特別頻繁的時,就會把這些代碼認定爲「熱點代碼」。爲了提升熱點代碼的執行效率,在運行時,虛擬機將會把這些代碼編譯成與本地平臺相關的機器碼,並進行各類層次的優化,完成這個任務的編譯器叫作即時編譯器(Just In Time Compiler,即JIT編譯器)。

JIT編譯器並非虛擬機必需的部分,Java虛擬機規範並無要求要有JIT編譯器的存在,更沒有限定或指導JIT編譯器應該如何去實現。可是,JIT編譯器性能的好壞、代碼優化程度的高低倒是衡量一款商用虛擬機優秀與否的最關鍵指標之一。

解釋器和編譯器其實和編譯器各有優點:
(1). 當程序須要迅速啓動和執行的時候,解釋器能夠先發揮做用,省去編譯的時間,當即執行
(1). 在程序運行後,隨着時間的推移,編譯器逐漸發揮做用,把愈來愈多的代碼編譯成本地代碼以後,能夠獲取更高的執行效率

HotSpot虛擬機中內置了兩個JIT編譯器,分別稱爲Client Complier和Server Complier(簡稱C1編譯器和C2編譯器)。HotSpot默認採用的是解釋器和一個編輯器配合的方式進行工做。HotSpot在啓動的時候會根據自身版本以及宿主機器的硬件性能自動選擇運行模式,好比會檢測宿主機器是否爲服務器、好比J2SE會檢測主機是否有至少2個CPU和至少2GB的內存。
(1). 若是是,則虛擬機會以Server模式運行,該模式與C2編譯器共同運行,更注重編譯的質量,啓動速度慢,可是運行效率高,適合用在服務器環境下,針對生產環境進行了優化
(2). 若是不是,則虛擬機會以Client模式運行,該模式與C1編譯器共同運行,更注重編譯的速度,啓動速度快,更適合用在客戶端的版本下,針對GUI進行了優化

查看虛擬機是運行在Client模式下仍是Server模式下:

 

主要來自:

 《深刻理解java虛擬機 JVM高級特性與最佳實踐》

相關文章
相關標籤/搜索