1. 程式人生 > >JVM(四):GC演算法

JVM(四):GC演算法

1、物件與引用

為了解決“哪些記憶體需要回收”的問題,需要確定哪些物件是“有用不可回收”的,而哪些物件是“無用可回收”的。通常存在以下兩種判斷演算法。

引用計數法

演算法原理:給物件新增一個引用計數器,每當一個地方引用它時,計數器值就加1;每當一個引用失效時,計數器值就減1;當引用計數為0時,表示該物件不再使用,可以回收。

應用:微軟COM/ActionScript3/Python

優勢:實現簡單,判定效率高,通常情況下是個不錯的演算法。

不足:很難解決迴圈引用的問題。A-->B-->C,ABC引用計數都是1,都不會被回收。

可達性分析演算法

演算法原理:以稱作“GC Roots”的物件作為起點向下搜尋,搜尋所走過的路徑成為引用鏈,當一個物件到GC Roots不可達時,表示該物件不再使用,可以回收。

應用:Java/C#/Lisp

優勢:可以解決迴圈引用的問題

不足:演算法略複雜

以下圖為例,無法通過已知的途徑獲取物件C和D,則有

  • 若使用引用計數演算法判定,但是由於它們相互引用,導致引用計數不為0,因此無法回收掉。
  • 若使用可達性分析演算法,C和D到GC Roots不可達,則可回收。

Java的4種引用型別

JDK1.2之後定義了4種引用,分別為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference),引用強度依次減弱。

  • 強引用

    我們經常使用的引用,形如Object o = new Object();或者String s = "Hello world!";等。只要強引用存在,被引用物件就不會被回收掉。

  • 軟引用

    描述一些還有用但是並非必須的物件。系統發生記憶體溢位之前,會把這類物件列入回收範圍,如果這次回收還沒有足夠記憶體,才丟擲記憶體溢位異常。使用java.lang.ref.SoftReference類來表示。

  • 弱引用

    描述非必須物件。被引用物件只能存活到下一次GC之前。使用java.lang.ref.WeakReference類來表示。

  • 虛引用

        稱為幽靈引用或者幻影引用,是最弱的一種引用關係。一個物件的虛引用根本不影響其生存時間。為一個物件設定虛引用的唯一目的就是這個物件被GC時收到一條系統通知。使用java.lang.ref.PhantomReference

類來表示。

關於四種引用的具體例項和應用場景,參考

  • http://www.cnblogs.com/dolphin0520/p/3784171.html
  • https://my.oschina.net/ydsakyclguozi/blog/404389
  • http://droidyue.com/blog/2014/10/12/understanding-weakreference-in-java/

物件回收流程

不可達物件並非立即被回收,還需要經過兩次標記過程後才被死亡:

  • 如果物件與GC Roots沒有連線的引用鏈,則它會被第一次標記。隨後進行一次是否執行finalize()方法的判定。
  • 如果有必要執行,則給物件被放置到一個叫做F-Queue的佇列中,稍後由虛擬機器建立低優先順序的Finalizer執行緒去執行,但並不承諾等待它執行結束。
  • 如果沒有必要執行(物件沒有覆蓋finalize()方法或者finalize()已經被執行過一次),則它會被第二次標記。

附上例項:

/**
 * 程式碼演示了兩點:
 * 1.物件在被GC時可以自我拯救
 * 2.這種自救的機會只有一次,因為一個物件的finalize()方法最多被系統執行1次
 */
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 method Invoked!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }
​
    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();
​
        // 第一次拯救,成功
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(1000);
​
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("Oh, i am dead!  :(");
        }
​
        // 第二次拯救,失敗
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(1000);
​
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("Oh, i am dead!  :(");
        }
    }
​
}

輸出結果為:

Finalize method Invoked!
yes, i am still alive :)
Oh, i am dead!  :(

另外,finalize()方法是誕生時使C/C++程式設計師接受它所做的妥協,不建議使用。

方法區回收

方法區GC主要回收兩部分內容:廢棄常量和無用類。判斷是否為“廢棄常量”與堆中物件類似,而判斷一個類為“無用類”必須滿足下面三個條件:

  • 該類所有例項都被回收
  • 載入該類的ClassLoader已被回收
  • 該類對應的java.lang.Class物件沒有被引用

2、GC演算法

a、標記-清除演算法(Mark-Sweep)

“標記-清除”演算法是最基礎的演算法,分為“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收掉所有被標記的物件。它主要有兩個缺點:一個是效率問題,標記和清除過程的效率都不高;另一個是空間問題,標記清除之後會產生大量不連續的記憶體碎片,空間碎片太多可能會導致當程式在以後的執行過程中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

2、複製演算法(Copying)(針對新生代)

      為了解決標記清除演算法的效率問題,出現了複製演算法,它將可用記憶體按容量劃分為大小相等的兩塊,每次使用其中的一塊。當這塊的記憶體用完了,就將還存活著的物件複製到另一塊上面,然後再把已使用過的記憶體空間一次清理掉。優點是每次都是對其中的一塊進行記憶體回收,記憶體分配時就不用考慮記憶體碎片等複雜情況,只要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。缺點是將記憶體縮小為原來的一半,代價太高了一點。

現在的商業虛擬機器都採用複製收集演算法來回收新生代,有研究表明,新生代中的物件98%是朝生夕死的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中的一塊Survivor。當回收時,將Eden和Survivor中還存活著的物件一次性地拷貝到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛擬機器預設Eden和Survivor的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),只有10%的記憶體是會被“浪費”的。當然,並不能保證每次回收都只有10%的物件存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行分配擔保(Handle Promotion)。即如果另外一塊Survivor空間沒有足夠的空間存放上一次新生代收集下來的存活物件,這些物件將直接通過分配擔保機制進入老年代。

3、標記-整理演算法(Mark-Compact)(針對老年代)

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

     根據老年代的特點提出了“標記-整理”演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

 標記-整理的步驟:

  1. 標記階段

  2. 整理階段:移動存活物件,同時更新存活物件中所有指向被移動物件的指標

整理的順序

    不同演算法中,堆遍歷的次數,整理的順序,物件的遷移方式都有所不同。而整理順序又會影響到程式的區域性性。主要有以下3種順序: 

     1. 任意順序:物件的移動方式和它們初始的物件排列及引用關係無關 

       任意順序整理實現簡單,且執行速度快,但任意順序可能會將原本相鄰的物件打亂到不同的快取記憶體行或者是虛擬記憶體頁中,會降低賦值器的區域性性。任意順序演算法只能處理單一大小的物件,或者針對大小不同的物件需要分批處理;

     2. 線性順序:將具有關聯關係的物件排列在一起 
     3. 滑動順序:將物件“滑動”到堆的一端,從而“擠出”垃圾,可以保持物件在堆中原有的順序 
    所有現代的標記-整理回收器均使用滑動整理,它不會改變物件的相對順序,也就不會影響賦值器的空間區域性性。複製式回收器甚至可以通過改變物件佈局的方式,將物件與其父節點或者兄弟節點排列的更近以提高賦值器的空間區域性性。 

整理演算法的限制,如整理過程需要2次或者3次遍歷堆空間;物件頭部可能需要一個額外的槽來儲存遷移的資訊。

部分整理演算法:

  1. 雙指標回收演算法:實現簡單且速度快,但會打亂物件的原有佈局

  2. Lisp2演算法(滑動回收演算法):需要在物件頭用一個額外的槽來儲存遷移完的地址

  3. 引線整理演算法:可以在不引入額外空間開銷的情況下實現滑動整理,但需要2次遍歷堆,且遍歷成本較高

  4. 單次遍歷演算法:滑動回收,實時計算出物件的轉發地址而不需要額外的開銷

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

      當前商業虛擬機器的垃圾收集都採用“分代收集”演算法,這種演算法並無新的方法,只是根據物件的存活週期的不同將記憶體劃分為幾塊,一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最適當的收集演算法。在新生代中,每次垃圾收集時都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成收集。而老年代中因為物件存活率高、沒有額外空間對它進行分配擔保,就必須使用“標記-清理”或“標記-整理”演算法來進行回收。

3、HotSpot的演算法實現

a.列舉根節點
        從可達性分析中從GC Roots節點找引用為例,可作為GC Roots的節點主要在全域性性的引用(例如常量或類靜態屬性)與執行上下文(例如棧幀中的本地變量表)中,(GC管理的主要區域是Java堆,一般情況下只針對堆進行垃圾回收。方法區、棧和本地方法區不被GC所管理,因而選擇這些區域內的物件作為GC roots,被GC roots引用的物件不被GC回收)。現在很多應用僅僅方法區就有數百兆,如果要逐個檢查引用,必然消耗時間。 另外可達性分析對執行時間的敏感還體現在GC停頓上,因為這項分析工作必須在一個能確保一致性的快照中進行——這裡的“一致性”的意思是指整個分析期間整個系統執行系統看起來就行被凍結在某個時間點,不可以出現分析過程中物件引用關係還在不斷變化的情況,該點不滿足的話分析結果的準確性就無法得到保證。這點是導致GC進行時必須暫停所有Java執行執行緒的一個重要原因。即使是在號稱(幾乎)不會發生停頓的CMS收集器中,列舉根節點時也是必須要停頓的。 
        由於目前主流的Java虛擬機器都是準確式GC,所以當執行系統停頓下來後,並不需要一個不漏的檢查執行上下文和全域性的引用位置,虛擬機器應當有辦法得知哪些地方存放的是物件的引用。在HotSpot的實現中,是使用一組OopMap的資料結構來達到這個目的的。 在類載入完成的時候,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和暫存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些資訊了。

        下面是HotSpot Client VM生成的一段String.hashCode()方法的原生代碼,可以看到在0x026eb7a9處的call指令有OopMap記錄,它指明瞭EBX暫存器和棧中偏移量為16的記憶體區域中各有一個普通物件指標(Ordinary Object Pointer)的引用,有效範圍為從call指令開始直到0x026eb730(指令流的起始位置)+142(OopMap記錄的偏移量)=0x026eb7be,即hlt指令為止。

[Verified Entry Point]
0x026eb730:mov%eax,-0x8000(%esp)
……
;ImplicitNullCheckStub slow case
0x026eb7a9:call 0x026e83e0
;OopMap{ebx=Oop[16]=Oop off=142}
;*caload
;-java.lang.String:[email protected](line 1489)
;{runtime_call}
0x026eb7ae:push$0x83c5c18
;{external_word}
0x026eb7b3:call 0x026eb7b8
0x026eb7b8:pusha
0x026eb7b9:call 0x0822bec0;{runtime_call}
0x026eb7be:hlt

b.安全點

        在OopMap的協助下,HotSpot可以快速且準確地完成GC Roots列舉,但一個很現實的問題隨之而來:可能導致引用關係變化,或者說OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。

實際上,HotSpot也的確沒有為每條指令都生成OopMap,只是在“特定的位置”記錄了這些資訊,這些位置稱為安全點(Safepoint),即程式執行時並非在所有地方都能停頓下來開始GC,只有在到達安全點時才能暫停。Safepoint的選定既不能太少以致於讓GC等待時間太長,也不能過於頻繁以致於過分增大執行時的負荷。所以,安全點的選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的——因為每條指令執行的時間都非常短暫,程式不太可能因為指令流長度太長這個原因而過長時間執行,“長時間執行”的最明顯特徵就是指令序列複用,例如方法呼叫、迴圈跳轉、異常跳轉等,所以具有這些功能的指令才會產生Safepoint。

對於Sefepoint,另一個需要考慮的問題是如何在GC發生時讓所有執行緒(這裡不包括執行JNI呼叫的執行緒)都“跑”到最近的安全點上再停頓下來。

這裡有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)。

搶先式中斷不需要執行緒的執行程式碼主動去配合,在GC發生時,首先把所有執行緒全部中斷,如果發現有執行緒中斷的地方不在安全點上,就恢復執行緒,讓它“跑”到安全點上。現在幾乎沒有虛擬機器實現採用搶先式中斷來暫停執行緒從而響應GC事件。

而主動式中斷的思想是當GC需要中斷執行緒的時候,不直接對執行緒操作,僅僅簡單地設定一個標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起。輪詢標誌的地方和安全點是重合的,另外再加上建立物件需要分配記憶體的地方。

下面的test指令是HotSpot生成的輪詢指令,當需要暫停執行緒時,虛擬機器把0x160100的記憶體頁設定為不可讀,執行緒執行到test指令時就會產生一個自陷異常訊號,在預先註冊的異常處理器中暫停執行緒實現等待,這樣一條彙編指令便完成安全點輪詢和觸發執行緒中斷。

0x01b6d627:call 0x01b2b210;OopMap{[60]=Oop off=460}
;*invokeinterface size
;-Client1:[email protected](line 23)
;{virtual_call}
0x01b6d62c:nop
;OopMap{[60]=Oop off=461}
;*if_icmplt
;-Client1:[email protected](line 23)
0x01b6d62d:test%eax,0x160100;{poll}
0x01b6d633:mov 0x50(%esp),%esi
0x01b6d637:cmp%eax,%esi

c.安全區域

使用Safepoint似乎已經完美地解決了如何進入GC的問題,但實際情況卻並不一定。Safepoint機制保證了程式執行時,在不太長的時間內就會遇到可進入GC的Safepoint。但是,程式“不執行”的時候呢?所謂的程式不執行就是沒有分配CPU時間,典型的例子就是執行緒處於Sleep狀態或者Blocked狀態,這時候執行緒無法響應JVM的中斷請求,“走”到安全的地方去中斷掛起,JVM也顯然不太可能等待執行緒重新被分配CPU時間。對於這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段程式碼片段之中,引用關係不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴充套件了的Safepoint。

線上程執行到Safe Region中的程式碼時,首先標識自己已經進入了Safe Region,那樣,當在這段時間裡JVM要發起GC時,就不用管標識自己為Safe Region狀態的執行緒了。線上程要離開Safe Region時,它要檢查系統是否已經完成了根節點列舉(或者是整個GC過程),如果完成了,那執行緒就繼續執行,否則它就必須等待直到收到可以安全離開Safe Region的訊號為止。