深入理解JVM(③)判斷物件是否還健在?

紀莫發表於2020-06-06

前言

因為Java物件主要存放在Java堆裡,所以垃圾收集器(Garbage Collection)在對Java堆進行回收前,第一件事情就是要確定這些物件之中哪些還“存活”著,哪些已經“死去”(不被引用了)。

判斷物件是否健在的演算法

1.引用計數演算法

引用計數演算法,很容易理解,在物件中新增一個引用計數器,每有一個地方引用它時,計數器值就加一;當引用失效是,計數器值就減一;任何時刻計數器為零的物件就是不可以能再被使用的物件
引用計數演算法的原理簡單,判定效率也很高。市面上也確實有一些技術使用的此類演算法來判定物件是否存活,像ActionScript 3 的FlashPlayer、Python語言等。但是在主流的Java虛擬機器裡面都沒有選用引用計演算法來管理記憶體,主要是使用此演算法時,必須要配合大量的額外處理才能保證正確的工作,例如要解決物件之間的相互迴圈引用的問題。

public class OneTest {

    public Object oneTest = null;

    private static final int _1MB = 1024 * 1024;

    private byte[] bigSize = new byte[256 * _1MB];


    /**
     * 這個成員屬性的唯一意義就是佔點記憶體,以便能在GC日誌中看清楚是否有回收過。
     */
    @Test
    public void testGC(){

        OneTest test1 = new OneTest();
        OneTest test2 = new OneTest();

        test1.oneTest = test2;
        test2.oneTest = test1;

        test1 = null;
        test2 = null;

        // 假設在這行發生GC,test1和test2是否能被回收?
        System.gc();

    }

}

分析程式碼,test1和test2物件都被設定成了null,在後面發生GC的時候,如果按照引用計數演算法,這兩個物件雖然都被設定成了null,但是test1引用了test2,test2又引用了test1,所以這兩個物件的引用計數值都不為0,所以都不會被回收,但是真正的實際執行結果是,這兩個物件都被回收了,這也說明HotSpot虛擬機器並不是用引用計數法來進行的記憶體管理。

2. 可達性分析演算法

當前主流的商用程式語言(Java、C#等),都是通過可達性分析(Reachability Analysis)演算法來判斷物件是否存活的。這個演算法的基本思路就是通過一一系列稱為“GC Roots” 的根物件作為起始節點集,從這些節點開始根據引用關係向下搜尋,搜尋走過的的路徑稱為“引用鏈”(Reference Chain),如果某個物件到GC Roots 間沒有任何引用鏈相連,或者從GC Roots 到這個物件不可達時,則證明此物件是不可能再被使用的。
如下圖,object10、object11、object12這三個物件,雖然互相有關聯,但是它們到GC Roots是不可達的,因此它們會被判定為可回收的物件。
可達性分析演算法
在Java程式中,固定可作為GC Roots 的物件包括以下幾種:

  • 在虛擬機器棧(棧幀中的本地變數表)中引用的物件,譬如各個現場被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。
  • 在方法區中類靜態屬性引用的物件,譬如Java類的引用型別靜態變數。
  • 在方法區中常量引用的物件,譬如字串常量池(String Table)裡的引用。
  • 在本地方法棧中JNI(即通常所說的Native方法)引用的物件。
  • Java虛擬機器內部的引用,如基本資料型別對應的Class物件,一些常駐的異常物件(NullPointException、OutOfMemoryError)等,還有系統類載入器。
  • 所有被同步鎖(synchronized關鍵字)持有的物件。
  • 反映Java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生程式碼快取等。
    除了這些固定的GC Roots集合以外,根據垃圾收集器以及當前回收的呢村區域不同,還會有其他物件“臨時性”的加入,如果只針對Java堆中某一塊兒區域發起垃圾收集時(例如只針對年輕代的垃圾收集),必須考慮到當前區域內的物件是否有被其他區域的物件所引用,這個時候就需要把這些關聯區域的物件一併加入GC Roots集合中,來保證可達性分析的正確性。

重申引用

無論是通過引用計數演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件是否引用鏈可達,判斷物件是否存活都和“引用”離不開關係。在JDK1.2之前,Java裡對引用的概念是:如果reference型別的資料中儲存的數值代表的是另外一塊兒記憶體的地址,就稱該reference資料是代表某塊記憶體、某個物件的引用。
在JDK1.2版之後,Java對引用的概念進行了擴充,將引用分為強引用(Strongly Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。

  • 強引用是最傳統的“引用”的定義,指引用複製,即類似
Object obj = new Object()

這種引用關係。無論在任何情況下,只要強引用關係還存在,垃圾收集器就不會回收掉被引用的物件。

  • 軟引用是用來描述一些還有用,但非必須的物件。在系統發生記憶體溢位前,會先對軟引用物件進行第二次回收,如果回收後還沒有足夠的記憶體,才會丟擲記憶體溢位的異常。
  • 弱引用也是用來描述那些非必須的物件,但是它的強度比軟引用更弱一些,弱引用的物件,只能生存到下一次垃圾收集發生為止。當垃圾收集器開始工作,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。
  • 虛引用也稱為“幽靈引用”或“幻影引用”,它是最弱的一種引用關係。為一個物件設定虛引用關聯的唯一目的只是為了能在這個物件被收集器回收時收到一個系統通知。

判斷物件是生是死的過程

即使在可達性分析演算法中,判斷為不可達的物件,也不是“非死不可”的,要真正宣告一個物件死亡,至少要經歷兩次標記過程:

  • 如果第一次物件在進行可達性分析後發現與GC Roots 不可達,將進行第一次標記。
  • 隨後對此物件進行一次是否有必要執行finalize()方法進行篩選,假如物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,都視為“沒有必要執行”。
    如果物件被判定有必要執行finalize()方法,會將物件放置在一個名為F-Queue的佇列中,並在由一條由虛擬機器自動建立的、低排程的執行緒區執行它們的finalize()方法。但並不承諾一定會等待它們執行結束。

需要注意的是:任何一個物件的finalize()方法都只會被系統自動呼叫一次,如果物件面臨第二次回收,它的finalize()方法不會被再次執行。
還有一點就是Java官方已經明確宣告不推薦手動呼叫finalize()方法了,因為它的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序,並且finanlize()能做的所有工作,使用try-finally或其他方式都可以做的更好、更及時。

回收方法區

方法區垃圾收集的“價效比”通常比較低,並且方法區回收也有過於苛刻的判定條件。
方法區的垃圾收集主要回收兩部分內容:廢棄的常量不再使用的型別,回收廢棄常量時,如果當前系統沒有一個常量的值是當前常量值,且虛擬機器中也沒有其他地方引用這個常量。如果這個時候發生垃圾回收,常量就會被系統清理出常量池。
判定一個型別是否屬於“不再使用的類”的條件就比較苛刻了,要同時滿足如下三個條件:

  • 該類所有的例項都已經被回收,也就是Java堆中不存在該類及其任何派生子類的例項。
  • 載入該類的類載入器已經被回收,這個條件除非是經過精心設計的可替換類載入器的場景,如OSGi、JSP的衝載入等,否則通常很難達成的。
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

同時滿足了上述的三個條件後,也只是被允許進行回收了,關於是否要對型別進行回收還要對虛擬機器進行一系列的引數設定,這裡就不贅述了,感興趣的可以自己去查詢。

相關文章