一文看懂虛擬機器中Java物件的生死判別

愛終點站發表於2021-01-05
  • j3_liuliang
  • 通過上面兩篇的介紹,相信大家已經知道虛擬機器的記憶體佈局和物件建立的過程及在虛擬機器中的分佈的,那麼一個物件建立出來是不可能永生的總會有死亡的時候而虛擬機器是如何判定一個物件的生死那就要看本篇博主給你們帶來的內容了,虛心、耐心、好奇心的看下去肯定有收穫的。

在這裡插入圖片描述

一、概述

在虛擬機器中要回收物件(垃圾回收)需要知道三件事:

  1. 那些物件需要回收?
  2. 什麼時候回收?
  3. 如何回收?

我們來回憶一下虛擬機器的執行時資料區:程式計數器(私有)虛擬機器棧(私有)本地方法棧(私有)Java堆(共享)方法區(共享)

在前面的部落格裡,我介紹了Java記憶體執行時區域的各個部分,其中程式計數器虛擬機器棧本地方法棧3個區域隨執行緒而生,隨執行緒而滅,棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作。每一個棧幀中分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由即時編譯器進行一些優化,但在基於概念模 型的討論裡,大體上可以認為是編譯期可知的),因此這幾個區域的記憶體分配和回收都具備確定性,在這幾個區域內就不需要過多考慮如何回收的問題,當方法結束或者執行緒結束時,記憶體自然就跟隨著回收了。

Java堆方法區這兩個區域則有著很顯著的不確定性:一個介面的多個實現類需要的記憶體可能會不一樣,一個方法所執行的不同條件分支所需要的記憶體也可能不一樣,只有處於執行期間,我們才能知道程式究竟會建立哪些物件,建立多少個物件,這部分記憶體的分配和回收是動態的。

垃圾收集器所關注的正是這部分記憶體該如何管理,下面的內容我們指的也是這部分記憶體。

既然虛擬機器的物件回收主要是針對Java堆方法區,那麼物件回收判斷的依據是什麼?具體的判斷實現邏輯又是什麼?

依據:是否有引用指向該物件

具體實現:引用計數法、可達性分析法

二、引用計數法

在這裡插入圖片描述

引用計數法(Reference Counting)的邏輯較為簡單,就是在物件中新增一個引用計數器,每當有一個地方引用它時,計數器值就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的物件就是不可能再被使用的。

對於引用計數法來說雖然佔用了一些額外的記憶體空間來進行計數,但它的原理簡單,判定效率也很高,在大多數情況下它都是一個不錯的演算法。也有一些比較著名的應用案例,例如微軟COM(Component Object Model)技術、使用ActionScript 3的FlashPlayer、Python語言以及在遊戲指令碼領域得到許多應用的Squirrel中都使用了引用計數演算法進行記憶體管理。

但是,在Java中,至少主流的Java虛擬機器裡面都沒有選用引用計數演算法來管理記憶體,主要原因是,這個看似簡單的演算法有很多例外情況要考慮,必須要配合大量額外處理才能保證正確地工作,譬如單純的引用計數就很難解決物件之間相互迴圈引用的問題

優點:

  1. 原理簡單
  2. 判定效率很高

缺點:

  1. 佔用一些額外的記憶體空間來進行計數
  2. 難解決物件之間相互迴圈引用的問題

舉個簡單的例子,請看下面程式碼main方法:物件objectA和objectB都有欄位instance,賦值令objectA.instance=objectB及objectB.instance=objectA,除此之外,這兩個物件再無任何引用,實際上這兩個物件已經不可能再被訪問,但是它們因為互相引用著對方,導致它們的引用計數都不為零,引用計數演算法也就無法回收它們。

public class RefCountGC{
    private byte[] bigSize = new byte[2 * 1024 * 1024];//這個成員屬性唯一的作用就是佔用一點記憶體
    Object instance = null;

    public static void main(String[] args){
        RefCountGC objectA = new RefCountGC();
        RefCountGC objectB = new RefCountGC();
        objectA.instance = objectB;
        objectB.instance = objectA;
        objectA = null;
        objectB = null;

        System.gc();
    }
}

三、可達性分析法

Java判定物件是否可回收的演算法就是可達性分析法。

這個演算法的基本思路就是通過一系列稱為“GC Roots”的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過 程所走過的路徑為“引用鏈”(Reference Chain),如果某個物件到GC Roots間沒有任何引用鏈相連,或者用圖論的話來說就是從GC Roots到這個物件不可達時,則證明此物件是不可能再被使用的。

在這裡插入圖片描述

物件obj4,obj5雖然互有關聯,但是它們到GC Roots是不可達的,因此它們將會被判定為可回收的物件。

什麼是GCRoots?

所謂“GC roots”,或者說tracing GC的“根集合”,就是一組必須活躍的引用

那麼什麼物件引用可以作為GCRoot的物件?

  • 在虛擬機器棧(棧幀中的本地變數表)中引用的物件,譬如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。

  • 在方法區中類靜態屬性引用的物件,譬如Java類的引用型別靜態變數。

  • 在方法區中常量引用的物件,譬如字串常量池(String Table)裡的引用。

  • 在本地方法棧中JNI(即通常所說的Native方法)引用的物件。

  • Java虛擬機器內部的引用,如基本資料型別對應的Class物件,一些常駐的異常物件(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類載入器。

  • 所有被同步鎖(synchronized關鍵字)持有的物件。

  • 反映Java虛擬機器內部情況的JMXBean、JVMTI中註冊的回撥、原生程式碼快取等。

當然除了上面這些固定的GC Roots集合以外,根據使用者所選用的垃圾收集器以及當前回收的記憶體區域不同,還可以有其他物件“臨時性”地加入,共同構成完整GC Roots集合。

例如分代收集和區域性回收(Partial GC),如果只針對Java堆中某一塊區域發起垃圾收集時(如最典型的只針對新生代的垃圾收集),必須考慮到記憶體區域是虛擬機器自己的實現細節(在使用者視角里任何記憶體區域都是不可見的),更不是孤立封閉的,所以某個區域裡的物件完全有可能被位於堆中其他區域的物件所引用,這時候就需要將這些關聯區域的物件也一併加入GC Roots集合中去,才能保證可達性分析的正確性。

目前最新的幾款垃圾收集器無一例外都具備了區域性回收的特徵,為了避免GC Roots包含過多物件而過度膨脹,它們在實現上也做出了各種優化處理(如根節點列舉)。關於這些概念、優化技巧以及各種不同收集器實現等內容,將會在我的後面部落格中講解到。

四、四種引用

從上面我們可以知道無論是通過引用計數演算法判斷物件的引用數量,還是通過可達性分析演算法判斷物件是否引用鏈可達,判定物件是否存活都和“引用”離不開關係。

那麼引用是什麼?

在JDK 1.2版之前,Java裡面的引用是很傳統的定義:

如果reference型別的資料中儲存的數值代表的是另外一塊記憶體的起始地址,就稱該reference資料是代表某塊記憶體、某個物件的引用。

這種定義並沒有什麼不對,只是現在看來有些過於狹隘了,一個物件在這種定義下只有“被引用”或者“未被引用”兩種狀態,對於描述一些“食之無味,棄之可惜”的物件就顯得無能為力。譬如我們希望能描述一類物件:當記憶體空間還足夠時,能保留在記憶體之中,如果記憶體空間在進行垃圾收集後仍然非常緊張,那就可以拋棄這些物件——很多系統的快取功能都符合這樣的應用場景。

在JDK 1.2版之後,Java對引用的概念進行了擴充,將引用分為強引用(Strongly Re-ference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)4種,這4種引用強度依次逐漸減弱。

4.1 強引用

開發中用的最多的就是強引用了。

強引用宣告格式:

Object obj=new Object();

只要某個物件與強引用關聯,那麼JVM在記憶體不足的情況下,寧願丟擲outOfMemoryError錯誤,也不會回收此類物件。

如果我們想要JVM回收此類被強引用關聯的物件,我們可以顯示地將str置為null,那麼JVM就會在合適的時間回收此物件。

4.2 軟引用

軟引用是用來描述一些還有用,但非必須的物件。

如果某個 Java 物件只被軟引用所指向,那麼在 JVM 要新建一個物件的時候,如果當前虛擬機器所剩下的堆記憶體不足以儲存這個要新建的物件的時候(即虛擬機器將要丟擲 OutOfMemoryError 異常的時候),那麼 JVM 會發起一次垃圾回收(gc)動作,將堆中所 只被非強引用 指向的物件回收,以提供更多的可用記憶體來新建這個物件,如果經過垃圾回收動作之後虛擬機器的堆記憶體中仍然沒有足夠的可用空間來建立這個物件,那麼虛擬機器將丟擲一個 OutOfMemoryError 異常

在JDK 1.2版之後提供了SoftReference類來實現軟引用。

import java.lang.ref.SoftReference;

public class TestRef {
    public static void main(String args[]) {
        SoftReference<String> str = new SoftReference<String>(new String("abc"));
        System.out.println(str.get());
        //通知JVM進行記憶體回收
        System.gc();
        System.out.println(str.get());
    }
}

軟引用適合做快取,在記憶體足夠時,直接通過軟引用取值,無需從真實來源中查詢資料,可以顯著地提升網站效能。當記憶體不足時,能讓JVM進行記憶體回收,從而刪除快取,這時候只能從真實來源查詢資料。

4.3 弱引用

弱引用也是用來描述那些非必須物件,但是它的強度比軟引用更弱一些,被弱引用關聯的物件只 能生存到下一次垃圾收集發生為止。

當垃圾收集器開始工作,無論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。

在JDK 1.2版之後提供了WeakReference類來實現弱引用。

import java.lang.ref.WeakReference;

public class TestRef {
    public static void main(String args[]) {
        WeakReference<String> str = new WeakReference<String>(new String("abc"));
        System.out.println(str.get());
        //通知JVM進行記憶體回收
        System.gc();
        System.out.println(str.get());
    }
}

弱引用可以在回撥函式在防止記憶體洩露。因為回撥函式往往是匿名內部類,一個非靜態的內部類會隱式地持有外部類的一個強引用,當JVM在回收外部類的時候,此時回撥函式在某個執行緒裡面被回撥的時候,JVM就無法回收外部類,造成記憶體洩漏。在安卓activity內宣告一個非靜態的內部類時,如果考慮防止記憶體洩露的話,應當顯示地宣告此內部類持有外部類的一個弱引用。

4.4 虛引用

虛引用也稱為“幽靈引用”或者“幻影引用”,它是最弱的一種引用關係。

這種引用有點特殊:被虛引用完全不會影響其所指向的物件的生命週期,也就是說一個 Java 物件是否被回收和指向它的虛引用完全沒有任何關係。也不能通過虛引用來得到其指向的物件(其 get 方法直接返回 null)。

那麼虛引用有什麼作用呢?

虛引用一般會配合 引用佇列(ReferenceQueue)來使用。當某個被虛引用指向的物件被回收時,我們可以在其引用佇列中得到這個虛引用的物件作為其所指向的物件被回收的一個通知。我們將會在後面看到這種用法。

在JDK 1.2版之後提供了PhantomReference類來實現虛引用,但虛引用不能單獨使用,必須配合引用佇列一起使用。

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
 
 
public class TestRef {
    public static void main(String args[]) {
        ReferenceQueue<String> queue = new ReferenceQueue<>();
        PhantomReference<String> str = new PhantomReference<String>("abc", queue);
        System.out.println(str.get()); // 輸出null
 
    }
}

五、生存或是死亡

在這裡插入圖片描述

上面提到過判定物件的死亡用的是可達性分析法,然而即使在可達性分析演算法中判定為不可達的物件,也不是“非死不可”的,這時候它們暫時還處於“緩刑”階段,要真正宣告一個物件死亡,至少要經歷兩次標記過程:如果物件在進行可達性分析後發現沒有與GC Roots相連線的引用鏈,那它將會被第一次標記,隨後進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。

  • 假如物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,那麼虛擬機器將這兩種情況都視為“沒有必要執行”。

  • 如果這個物件被判定為確有必要執行finalize()方法,那麼該物件將會被放置在一個名為F-Queue的佇列之中,並在稍後由一條由虛擬機器自動建立的、低排程優先順序的Finalizer執行緒去執行它們的finalize()方法。

    這裡所說的“執行”是指虛擬機器會觸發這個方法開始執行,但並不承諾一定會等待它執行結束。這樣做的原因是,如果某個物件的finalize()方法執行緩慢,或者更極端地發生了死迴圈,將很可能導 致F-Queue佇列中的其他物件永久處於等待,甚至導致整個記憶體回收子系統的崩潰。

finalize()方法是物件逃脫死亡命運的最後一次機會,稍後收集器將對F-Queue中的物件進行第二次小規模的標記,如果物件要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個物件建立關聯即可,譬如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,那在第二次標記時它將被移出“即將回收”的集合;如果物件這時候還沒有逃脫,那基本上它就真的要被回收了。

從下面程式碼我們可以看到一個物件的finalize()被執行,但是它仍然可以存活。

/**
* 此程式碼演示了兩點: 
* 1.物件可以在被GC時自我拯救。 
* 2.這種自救的機會只有一次,因為一個物件的finalize()方法最多隻會被系統自動呼叫一次 
*/
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 executed!"); 
        FinalizeEscapeGC.SAVE_HOOK = this; 
    }
    public static void main(String[] args) throws Throwable { 
        SAVE_HOOK = new FinalizeEscapeGC(); 
        //物件第一次成功拯救自己
        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 = null; System.gc(); 
        // 因為Finalizer方法優先順序很低,暫停0.5秒,以等待它 
        Thread.sleep(500); 
        if (SAVE_HOOK != null) { 
            SAVE_HOOK.isAlive(); 
        } else { 
            System.out.println("no, i am dead :("); 
        } 
    }
}

執行結果

finalize method executed! 
yes, i am still alive :) 
no, i am dead :(

從程式碼的執行結果可以看到,SAVE_HOOK物件的finalize()方法確實被垃圾收集器觸發過,並且在被收集前成功逃脫了。

另外一個值得注意的地方就是,程式碼中有兩段完全一樣的程式碼片段,執行結果卻是一次逃脫成 功,一次失敗了。這是因為任何一個物件的finalize()方法都只會被系統自動呼叫一次,如果物件面臨下一次回收,它的finalize()方法不會被再次執行,因此第二段程式碼的自救行動失敗了。

需要特別說明,上面關於物件死亡時finalize()方法的描述可能帶點悲情的藝術加工,筆者並不鼓勵大家使用這個方法來拯救物件。相反,筆者建議大家儘量避免使用它,因為它並不能等同於C和C++語言中的解構函式,而是Java剛誕生時為了使傳統C、C++程式設計師更容易接受Java所做出的一項妥協。它的執行代價高昂,不確定性大,無法保證各個物件的呼叫順序,如今已被官方明確宣告為不推薦使用的語法。有些教材中描述它適合做“關閉外部資源”之類的清理性工作,這完全是對finalize() 方法用途的一種自我安慰。finalize()能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及時,所以建議大家完全可以忘掉Java語言裡面的這個方法。

六、方法區回收

看過我前面得部落格就知道,方法區是記憶體共享的,而且有些人認為方法區(如HotSpot虛擬機器中的元空間或者永久代)是沒有垃圾收集行為的。

《Java虛擬機器規範》中提到過可以不要求虛擬機器在方法區中實現垃圾收集

為什麼會如此?

  1. 方法區垃圾收集得價效比低
  2. 方法區垃圾回收條件苛刻

方法區的垃圾收集主要回收兩部分內容:廢棄的常量不再使用的型別

  • 廢棄得常量

    回收廢棄常量與回收Java堆中的物件非常類似。

    舉個常量池中字面量回收的例子,假如一個字串“java”曾經進入常量池中,但是當前系統又沒有任何一個字串物件的值是“java”,換句話說,已經沒有任何字串物件引用常量池中的“java”常量,且虛擬機器中也沒有其他地方引用這個字面量。如果在這時發生記憶體回收,而且 垃圾收集器判斷確有必要的話,這個“java”常量就將會被系統清理出常量池。常量池中其他類(介面)、方法、欄位的符號引用也與此類似。

  • 不再使用的型別

    判定一個常量是否“廢棄”還是相對簡單,而要判定一個型別是否屬於“不再被使用的類”的條件就比較苛刻了。需要同時滿足下面三個條件:

    1. 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。

    2. 載入類的ClassLoader已經被回收。(通常是很難達成,件除非是經過精心設計的可替換類載入器的場景,如OSGi、JSP的重載入等)

    3. 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過發射訪問該類的方法。

    Java虛擬機器被允許對滿足上述三個條件的無用類進行回收,這裡說的僅僅是“被允許”,而並不是和物件一樣,沒有引用了就必然會回收。

    關於是否要對型別進行回收,HotSpot虛擬機器提供了-Xnoclassgc引數進行控制

    在大量使用反射、動態代理、CGLib等位元組碼框架,動態生成JSP以及OSGi這類頻繁自定義類載入器的場景中,通常都需要Java虛擬機器具備型別解除安裝的能力,以保證不會對方法區造成過大的記憶體壓力

在這裡插入圖片描述

到此本文的內容就講解完畢了,我們主要概括一下:

  1. 確定一個物件是垃圾方法有兩種,引用計數法(主流虛擬機器不再使用)、可達性分析法(主流虛擬機器在使用)
  2. 擴充套件物件的四種引用(引用關聯著物件是否是垃圾)
  3. 接著介紹了物件如何逃避死亡被回收的命運(重寫finalize方法)
  4. 最後討論了一下方法區垃圾回收(有垃圾回收,但條件苛刻)

既然知道了哪些是垃圾物件,那麼下期就是介紹具體的垃圾回收方法了(先講垃圾回收演算法,後將演算法實現

我們下期見!

結束語

  • 由於博主才疏學淺,難免會有紕漏,假如你發現了錯誤或偏見的地方,還望留言給我指出來,我會對其加以修正。
  • 如果你覺得文章還不錯,你的轉發、分享、點贊、留言就是對我最大的鼓勵。
  • 感謝您的閱讀,十分歡迎並感謝您的關注。

在這裡插入圖片描述

相關文章