1. 程式人生 > >你必須瞭解的java記憶體管理機制(三)-垃圾標記

你必須瞭解的java記憶體管理機制(三)-垃圾標記

本文在個人技術部落格不同步釋出,詳情可用力戳
亦可掃描螢幕右側二維碼關注個人公眾號,公眾號內有個人聯絡方式,等你來撩...

相關連結(注:文章講解JVM以Hotspot虛擬機器為例,jdk版本為1.8)
1、 你必須瞭解的java記憶體管理機制-執行時資料區
2、 你必須瞭解的java記憶體管理機制-記憶體分配
3、 你必須瞭解的java記憶體管理機制-垃圾標記

前言

  前面花了兩篇文章對JVM的記憶體管理機制做了較多的介紹,通過第一篇文章先了解了JVM的執行時資料區,然後在第二篇文章中通過一個建立物件的例項介紹了JVM的記憶體分配的相關內容!那麼,萬眾矚目的JVM垃圾回收是時候登場了!JVM垃圾回收這塊的內容相對較多、較複雜。但是,想要做好JVM的效能調優,這塊的內容又必須瞭解和掌握!

正文

1、怎麼找到存活物件?

  通過上篇文章我們知道,JVM建立物件時會通過某種方式從記憶體中劃分一塊區域進行分配。那麼當我們伺服器源源不斷的接收請求的時候,就會頻繁的需要進行記憶體分配的操作,但是我們伺服器的記憶體確是非常有限的呢!所以對不再使用的記憶體進行回收再利用就成了JVM肩負的重任了! 那麼,擺在JVM面前的問題來了,怎麼判斷哪些記憶體不再使用了?怎麼合理、高效的進行回收操作?既然要回收,那第一步就是要找到需要回收的物件!

1.1、引用計數法

  實現思路:給物件新增一個引用計數器,每當有一個地方引用它,計數器加1。當引用失效,計數器值減1。任何時刻計數器值為0,則認為物件是不再被使用的。舉個小栗子,我們有一個People的類,People類有id和bestFriend的屬性。我們用People類來造兩個小人:

      People p1 = new People();
      People p2 = new People();

  通過上篇文章的知識我們知道,當方法執行的時候,方法的區域性變量表和堆的關係應該是如下圖的(注意堆中物件頭中紅色括號內的數字,就是引用計數器,這裡只是舉慄,實際實現可能會有差異):

  

  造出來的p1和p2兩個人,我想讓他們互為最好的朋友,於是程式碼如下:

    People p1 = new People();
    People p2 = new People();
    p1.setBestFriend(p2);
    p2.setBestFriend(p1);

  對應的引用關係圖應該如下(注意引用計數器值的變化):

  

  然後我們再做一些處理,去除變數和堆中物件的引用關係。

        People p1 = new People();
        People p2 = new People();
        
        p1.setBestFriend(p2);
        p2.setBestFriend(p1);
        
        p1 = null;
        p2 = null;

  這時候引用關係圖就變成如下了,由於p1和p2物件還相互引用著,所以引用計數器的值還為1。

  

  優點:實現簡單,效率高。
  缺點:很難解決物件之間的相互迴圈引用。且開銷較大,頻繁的引用變化會帶來大量的額外運算。在談實現思路的時候有這樣一句話“任何時刻計數器值為0,則認為物件是不再被使用的”。但是通過上面的例子我們可以看到,雖然物件已經不再使用了,但計數器的值仍然是1,所以這兩個物件不會被標記為垃圾。
  現狀:主流的JVM都沒有選用引用計數法來管理記憶體。

1.2、可達性分析

  實現思路:通過GC Roots的物件作為起始點,從這些節點向下搜尋,搜尋走過的路徑成為引用鏈,當一個物件到GC Root沒有任何引用鏈相連時,則證明物件是不可用的。如下圖,紅色的幾個物件由於沒有跟GC Root沒有任何引用鏈相連,所以會進行標記。

   

  優點:可以很好的解決物件相互迴圈引用的問題。
  缺點:實現比較複雜;需要分析大量資料,消耗大量時間;
  現狀:主流的JVM(如HotSpot)都選用可達性分析來管理記憶體。

2、標記死亡物件

  通過可達性分析可以對需要回收的物件進行標記,是否標記的物件一定會被回收呢?並不是呢!要真正宣告一個物件的死亡,至少要經歷兩次的標記過程!

2.1、第一次標記

  在可達性分析後發現到GC Roots沒有任何引用鏈相連時,被第一次標記。並且判斷此物件是否必要執行finalize()方法!如果物件沒有覆蓋finalize()方法或者finalize()已經被JVM呼叫過,則這個物件就會認為是垃圾,可以回收。對於覆蓋了finalize()方法,且finalize()方法沒有被JVM呼叫過時,物件會被放入一個成為F-Queue的佇列中,等待著被觸發呼叫物件的finalize()方法。

2.2、第二次標記

  執行完第一次的標記後,GC將對F-Queue佇列中的物件進行第二次小規模標記。也就是執行物件的finalize()方法!如果物件在其finalize()方法中重新與引用鏈上任何一個物件建立關聯,第二次標記時會將其移出"即將回收"的集合。如果物件沒有,也可以認為物件已死,可以回收了。

  finalize()方法是被第一次標記物件的逃脫死亡的最後一次機會。在jvm中,一個物件的finalize()方法只會被系統呼叫一次,經過finalize()方法逃脫死亡的物件,第二次不會再呼叫。由於該方法是在物件進行回收的時候呼叫,所以可以在該方法中實現資源關閉的操作。但是,由於該方法執行的時間是不確定的,甚至,在java程式不正常退出的情況下該方法都不一定會執行!所以在正常情況下,儘量避免使用!如果需要"釋放資源",可以定義顯式的終止方法,並在"try-catch-finally"的finally{}塊中保證及時呼叫,如File相關類的close()方法。下面我們看一個在finalize中逃脫死亡的栗子吧:

public class GCDemo {
    public static GCDemo gcDemo = null;

    public static void main(String[] args) throws InterruptedException {

      gcDemo = new GCDemo();
        System.out.println("------------物件剛建立------------");
        if (gcDemo != null) {
            System.out.println("我還活得好好的!");
        } else {
            System.out.println("我死了!");
        }

        gcDemo = null;
        System.gc();
        System.out.println("------------物件第一次被回收後------------");
        Thread.sleep(500);// 由於finalize方法的呼叫時間不確定(F-Queue執行緒呼叫),所以休眠一會兒確保方法完成呼叫
        if (gcDemo != null) {
            System.out.println("我還活得好好的!");
        } else {
            System.out.println("我死了!");
        }

        gcDemo = null;
        System.gc();
        System.out.println("------------物件第二次被回收後------------");
        Thread.sleep(500);
        if (gcDemo != null) {
            System.out.println("我還活得好好的!");
        } else {
            System.out.println("我死了!");
        }

        // 後面無論多少次GC都不會再執行物件的finalize方法
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("execute method finalize()");
        gcDemo = this;
    }
}

  執行結果如下,具體就不多說啦,不明白的就自己動手去試試吧!

  

3、列舉根節點

  通過上面可達性分析我們瞭解了有哪些GC Root,瞭解了通過這些GC Root去搜尋並標記物件是生存還是死亡的思路。但是具體的實現就是那張圖顯示的那麼簡單嗎?當然不是,因為我們的堆是分代收集的,那GC Root連線的物件可能在新生代,也可能在老年代,新生代的物件可能會引用老年代的物件,老年代的物件也可能引用新生代。如果直接通過GC Root去搜尋,則每次都會遍歷整個堆,那分代收集就沒法實現了呢!並且,列舉整個根節點的時候是需要執行緒停頓的(保證一致性,不能出現正在列舉 GC Roots,而程式還在跑的情況,這會導致 GC Roots 不斷變化,產生資料不一致導致統計不準確的情況),而列舉根節點又比較耗時,這在大併發高訪問量情況下,分分鐘就會導致系統癱瘓!啥意思呢,下面一張圖感受一下:

  

  如果是進行根節點列舉,我們先要全棧掃描,找到變量表中存放為reference型別的變數,然後找到堆中對應的物件,最後遍歷物件的資料(如屬性等),找到物件資料中存放為指向其他reference的物件……這樣的開銷無疑是非常大的!

  為解決上述問題,HotSpot 採用了一種 “準確式GC” 的技術,該技術主要功能就是讓虛擬機器可以準確的知道記憶體中某個位置的資料型別是什麼,比如某個記憶體位置到底是一個整型的變數,還是對某個物件的reference,這樣在進行 GC Roots列舉時,只需要列舉reference型別的即可。那怎麼讓虛擬機器準確的知道哪些位置存在的是reference型別資料呢?OopMap+RememberedSet!

  OopMap記錄了棧上本地變數到堆上物件的引用關係,在GC發生時,執行緒會執行到最近的一個安全點停下來,然後更新自己的OopMap,記下棧上哪些位置代表著引用。列舉根節點時,遞迴遍歷每個棧幀的OopMap,通過棧中記錄的被引用物件的記憶體地址,即可找到這些物件( GC Roots )。這樣,OopMap就避免了全棧掃描,加快列舉根節點的速度。

  OopMap解決了列舉根節點耗時的問題,但是分代收集的問題依然存在!這時候就需要另一利器了- RememberedSet。對於位於不同年代物件之間的引用關係,會在引用關係發生時,在新生代邊上專門開闢一塊空間記錄下來,這就是RememberedSet!所以“新生代的 GC Roots ” + “ RememberedSet儲存的內容”,才是新生代收集時真正的GC Roots(G1 收集器也使用了 RememberedSet 這種技術)。

3.1、安全點

  HotSpot在OopMap的幫助下可以快速且準確的完成GC Roots列舉,但是在執行過程中,非常多的指令都會導致引用關係變化,如果為這些指令都生成對應的OopMap,需要的空間成本太高。所以只在特定的位置記錄OopMap引用關係,這些位置稱為安全點(Safepoint)。如何在GC發生時讓所有執行緒(不包括JNI執行緒)執行到其所在最近的安全點上再停頓下來?這裡有兩種方案:

  1、搶先式中斷:不需要執行緒的執行程式碼去主動配合,當發生GC時,先強制中斷所有執行緒,然後如果發現某些執行緒未處於安全點,那麼將其喚醒,直至其到達安全點再次將其中斷。這樣一直等待所有執行緒都在安全點後開始GC。

  2、主動式中斷:不強制中斷執行緒,只是簡單地設定一箇中斷標記,各個執行緒在執行時主動輪詢這個標記,一旦發現標記被改變(出現中斷標記)時,就將自己中斷掛起。目前所有商用虛擬機器全部採用主動式中斷。

  安全點既不能太少,以至於 GC 過程等待程式到達安全點的時間過長,也不能太多,以至於 GC 過程帶來的成本過高。安全點的選定基本上是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定的,例如方法呼叫、迴圈跳轉、異常跳轉等,所以具有這些功能的指令才會產生安全點(在主動式中斷中,輪詢標誌的地方和安全點是重合的,所以執行緒在遇到這些指令時都會去輪詢中斷標誌!)。

3.2、安全區域

  使用安全點似乎已經完美解決如何進入GC的問題了,但是GC發生的時候,某個執行緒正在睡覺(sleep),無法響應JVM的中斷請求,這時候執行緒一旦醒來就會繼續執行了,這會導致引用關係發生變化呢!所以需要安全區域的思路來解決這個問題。執行緒執行進入安全區域,首先標識自己已經進入安全區域。執行緒被喚醒離開安全區域時,其需要檢查系統是否已經完成根節點列舉(或整個GC)。如果已經完成,就繼續執行,否則必須等待,直到收到可以安全離開Safe Region的訊號通知