JVM(三)物件的生死判定和演算法詳解

老王_Java中文社群發表於2019-01-21

好的文章是能把各個知識點,通過邏輯關係串連起來,讓人豁然開朗的同時又記憶深刻。

導讀:物件除了生死之外,還有其他狀態嗎?物件真正的死亡,難道只經歷一次簡單的判定?如何在垂死的邊緣“拯救”一個將死物件?判斷物件的生死存活都有那些演算法?本文帶你一起找到這些答案。

在正式開始之前,我們先來了解一下垃圾回收。

GC介紹

GC: Garbage Collection,中文翻譯為垃圾回收。

GC的歷史

GC有著很長的歷史了,最初的GC演算法釋出於1960年(已經快有60年的歷史了),Lisp之父John McCarthy釋出的,他是一名非常有名的黑客,也是人工智慧之父,同時也是GC之父。

為什麼要學習GC?

1、排查記憶體溢位和記憶體洩露的問題。

2、系統調優,處理更高的併發瓶頸。

GC的作用

1、找到記憶體空間的垃圾。

2、回收垃圾。

物件生死判斷演算法

垃圾回收的第一步就是判斷物件是否存活,只有“死去”的物件,才會被垃圾回收器所收回。

引用計數器演算法

引用計算器判斷物件是否存活的演算法是這樣的:給每一個物件設定一個引用計數器,每當有一個地方引用這個物件的時候,計數器就加1,與之相反,每當引用失效的時候就減1。

優點: 實現簡單、效能高。

缺點: 增減處理頻繁消耗cpu計算、計數器佔用很多位浪費空間、最重要的缺點是無法解決迴圈引用的問題。

因為引用計數器演算法很難解決迴圈引用的問題,所以主流的Java虛擬機器都沒有使用引用計數器演算法來管理記憶體。

來看一段迴圈引用的程式碼:

public class ReferenceDemo {
    public Object instance = null;
    private static final int _1Mb = 1024 * 1024;
    private byte[] bigSize = new byte[10 * _1Mb]; // 申請記憶體
    public static void main(String[] args) {
        System.out.println(String.format("開始:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
        ReferenceDemo referenceDemo = new ReferenceDemo();
        ReferenceDemo referenceDemo2 = new ReferenceDemo();
        referenceDemo.instance = referenceDemo2;
        referenceDemo2.instance = referenceDemo;
        System.out.println(String.format("執行:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
        referenceDemo = null;
        referenceDemo2 = null;
        System.gc(); // 手動觸發垃圾回收
        System.out.println(String.format("結束:%d M",Runtime.getRuntime().freeMemory() / (1024 * 1024)));
    }
}
複製程式碼

執行的結果:

開始:117 M

執行中:96 M

結束:119 M

從結果可以看出,虛擬機器並沒有因為相互引用就不回收它們,也側面說明了虛擬機器並不是使用引用計數器實現的。

可達性分析演算法

在主流的語言的主流實現中,比如Java、C#、甚至是古老的Lisp都是使用的可達性分析演算法來判斷物件是否存活的。

這個演算法的核心思路就是通過一些列的“GC Roots”物件作為起始點,從這些物件開始往下搜尋,搜尋所經過的路徑稱之為“引用鏈”。

當一個物件到GC Roots沒有任何引用鏈相連的時候,證明此物件是可以被回收的。如下圖所示:

可達性分析演算法

在Java中,可作為GC Roots物件的列表:

  • Java虛擬機器棧中的引用物件。
  • 本地方法棧中JNI(既一般說的Native方法)引用的物件。
  • 方法區中類靜態常量的引用物件。
  • 方法區中常量的引用物件。

物件生死與引用的關係

從上面的兩種演算法來看,不管是引用計數法還是可達性分析演算法都與物件的“引用”有關,這說明:物件的引用決定了物件的生死。那物件的引用都有那些呢?

在JDK1.2之前,引用的定義很傳統:如果reference型別的資料中儲存的數值代表的是另一塊記憶體的起始地址,就稱這塊記憶體代表著一塊引用。

這樣的定義很純粹,但是也很狹隘,這種情況下一個物件要麼被引用,要麼沒引用,對於介於兩者之間的物件顯得無能為力。

JDK1.2之後對引用進行了擴充,將引用分為:

  • 強引用(Strong Reference)

  • 軟引用(Soft Reference)

  • 弱引用(Weak Reference)

  • 虛引用(Phantom Reference)

這也就是文章開頭第一個問題的答案,物件不是非生即死的,當空間還足夠時,還可以保留這些物件,如果空間不足時,再拋棄這些物件。很多快取功能的實現也符合這樣的場景。

強引用、軟引用、弱引用、虛引用,這4種引用的強度是依次遞減的。

強引用: 在程式碼中普遍存在的,類似“Object obj = new Object()”這類引用,只要強引用還在,垃圾收集器永遠不會回收掉被引用的物件。

軟引用: 是一種相對強引用弱化一些的引用,可以讓物件豁免一些垃圾收集,只有當jvm認為記憶體不足時,才會去試圖回收軟引用指向的物件。jvm會確保在丟擲OutOfMemoryError之前,清理軟引用指向的物件。

弱引用: 非必需物件,但它的強度比軟引用更弱,被弱引用關聯的物件只能生存到下一次垃圾收集發生之前。

虛引用: 也稱為幽靈引用或幻影引用,是最弱的一種引用關係,無法通過虛引用來獲取一個物件例項,為物件設定虛引用的目的只有一個,就是當著個物件被收集器回收時收到一條系統通知。

死亡標記與拯救

在可達性演算法中不可達的物件,並不是“非死不可”的,要真正宣告一個物件死亡,至少要經歷兩次標記的過程。

如果物件在進行可達性分析之後,沒有與GC Roots相連線的引用鏈,它會被第一次標記,並進行篩選,篩選的條件是此物件是否有必要執行finalize()方法。

執行finalize()方法的兩個條件:

1、重寫了finalize()方法。

2、finalize()方法之前沒被呼叫過,因為物件的finalize()方法只能被執行一次。

如果滿足以上兩個條件,這個物件將會放置在F-Queue的佇列之中,並在稍後由一個虛擬機器自建的、低優先順序Finalizer執行緒來執行它。

物件的“自我拯救”

finalize()方法是物件脫離死亡命運最後的機會,如果物件在finalize()方法中重新與引用鏈上的任何一個物件建立關聯即可,比如把自己(this關鍵字)賦值給某個類變數或物件的成員變數。

來看具體的實現程式碼:

public class FinalizeDemo {
    public static FinalizeDemo Hook = null;
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("執行finalize方法");
        FinalizeDemo.Hook = this;
    }
    public static void main(String[] args) throws InterruptedException {
        Hook = new FinalizeDemo();
        // 第一次拯救
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待finalize執行
        if (Hook != null) {
            System.out.println("我還活著");
        } else {
            System.out.println("我已經死了");
        }
        // 第二次,程式碼完全一樣
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待finalize執行
        if (Hook != null) {
            System.out.println("我還活著");
        } else {
            System.out.println("我已經死了");
        }
    }
}
複製程式碼

執行的結果:

執行finalize方法

我還活著

我已經死了

從結果可以看出,任何物件的finalize()方法都只會被系統呼叫一次。

不建議使用finalize()方法來拯救物件 ,原因如下:

1、物件的finalize()只能執行一次。

2、它的執行代價高昂。

3、不確定性大。

4、無法保證各個物件的呼叫順序。

參考

《深入理解Java虛擬機器》

《垃圾回收的演算法與實現》

※ 為寫好一篇技術文章,背後是讀了兩本書的“艱辛”。寫作不易,請多支援!!!

最後

關注公眾號,傳送“gc”關鍵字,領取《垃圾回收的演算法與實現》學習資料。

JVM(三)物件的生死判定和演算法詳解

JVM(三)物件的生死判定和演算法詳解

相關文章