關於強引用、軟引用、弱引用、幻象引用,你該如何回答?

程式設計師cxuan發表於2020-04-25

我們說的不同的引用型別其實都是邏輯上的,而對於虛擬機器來說,主要體現的是物件的不同的可達性(reachable) 狀態和對垃圾收集(garbage collector)的影響。

初識引用

對於剛接觸 Java 的 C++ 程式設計師而言,理解棧和堆的關係可能很不習慣。在 C++ 中,可以使用 new 操作符在堆上建立物件,或者使用自動分配在棧上建立物件。下面的 C++ 語句是合法的,但是 Java 編譯器卻拒絕這麼寫程式碼,會出現 syntax error 編譯錯誤。

Integer foo = Integer(1);

Java 和 C 不一樣,Java 中會把物件都放在堆上,需要 new 操作符來建立物件。本地變數儲存在中,它們持有一個指向堆中物件的引用(指標)。下面是一個 Java 方法,該方法具有一個 Integer 變數,該變數從 String 解析值

public static void foo(String bar){
    Integer baz = new Integer(bar);
}

這段程式碼我們使用堆疊分配圖可以看一下它們的關係

首先先來看一下 foo() 方法,這一行程式碼分配了一個新的 Integer 物件,JVM 嘗試在堆空間中開闢一塊記憶體空間。如果允許分配的話,就會呼叫 Integer 的構造方法把 String 字串轉換為 Integer 物件。JVM 將指向該物件的指標儲存在變數 baz 中。

上面這種情況是我們樂意看到的情況,畢竟我們不想在編寫程式碼的時候遇到阻礙,但是這種情況是不可能出現的,當堆空間無法為 bar 和 baz 開闢記憶體空間時,就會出現 OutOfMemoryError,然後就會呼叫垃圾收集器(garbage collector) 來嘗試騰出記憶體空間。這中間涉及到一個問題,垃圾收集器會回收哪些物件?

垃圾收集器

Java 給你提供了一個 new 操作符來為堆中的物件開闢記憶體空間,但它沒有提供 delete 操作符來釋放物件空間。當 foo() 方法返回時,如果變數 baz 超過最大記憶體,但它所指向的物件仍然還在堆中。如果沒有垃圾回收器的話,那麼程式就會丟擲 OutOfMemoryError 錯誤。然而 Java 不會,它會提供垃圾收集器來釋放不再引用的物件。

當程式嘗試建立新物件並且堆中沒有足夠的空間時,垃圾收集器就開始工作。當收集器訪問堆時,請求執行緒被掛起,試圖查詢程式不再主動使用的物件,並回收它們的空間。如果垃圾收集器無法釋放足夠的記憶體空間,並且JVM 無法擴充套件堆,則會出現 OutOfMemoryError,你的應用程式通常在這之後崩潰。還有一種情況是 StackOverflowError ,它出現的原因是因為執行緒請求的棧深度要大於虛擬機器所允許的深度時出現的錯誤。

標記 - 清除演算法

Java 能永久不衰的一個原因就是因為垃圾收集器。許多人認為 JVM 會為每個物件保留一個引用計數,當每次引用物件的時候,引用計數器的值就 + 1,當引用失效的時候,引用計數器的值就 - 1。而垃圾收集器只會回收引用計數器的值為 0 的情況。這其實是 引用計數法(Reference Counting) 的收集方式。但是這種方式無法解決物件之間相會引用的問題,如下

class A{
  public B b;
   
}
class B{
  public A a;
}
public class Main{
    public static void main(String[] args){
    A a = new A();
    B b = new B();
    a.b=b;
    b.a=a;
    }
}

然而實際上,JVM 使用一種叫做 標記-清除(Mark-Sweep)的演算法,標記清除垃圾回收背後的想法很簡單:程式無法到達的每個物件都是垃圾,可以進行回收。

標記-清除收集具有如下幾個階段

  • 階段一:標記

垃圾收集器會從 根(root) 引用開始,標記它到達的所有物件。如果用老師給學生判斷卷子來比喻,這就相當於是給試卷上的全部答案判斷正確還是錯誤的過程。

  • 階段二:清理

在第一階段中所有可回收的的內容都能夠被垃圾收集器進行回收。如果一個物件被判定為是可以回收的物件,那麼這個物件就被放在一個 finalization queue(回收佇列)中,並在稍後會由一個虛擬機器自動建立的、低優先順序的 finalizer 執行緒去執行它。

  • 階段三:整理(可選)

一些收集器有第三個步驟,整理。在這個步驟中,GC 將物件移動到垃圾收集器回收完物件後所留下的自由空間中。這麼做可以防止堆碎片化,防止大物件在堆中由於堆空間的不連續性而無法分配的情況。

所以上面的過程中就涉及到一個根節點(GC Roots) 來判斷是否存在需要回收的物件。這個演算法的基本思想就是通過一系列的 GC Roots 作為起始點,從這些節點向下搜尋,搜尋所走過的路徑稱為 引用鏈(Reference Chain),當一個物件到 GC Roots 之間沒有任何引用鏈相連的話,則證明此物件不可用。引用鏈上的任何一個能夠被訪問的物件都是強引用 物件,垃圾收集器不會回收強引用物件。

因此,返回到 foo() 方法中,僅在執行方法時,引數 bar 和區域性變數 baz 才是強引用。一旦方法執行完成,它們都超過了作用域的時候,它們引用的物件都會進行垃圾回收。

下面來考慮一個例子

LinkedList foo = new LinkedList();
foo.add(new Integer(111));

變數 foo 是一個強引用,它指向一個 LinkedList 物件。LinkedList(JDK.18) 是一個連結串列的資料結構,每一個元素都會指向前驅元素,每個元素都有其後繼元素。

當我們呼叫add() 方法時,我們會增加一個新的連結串列元素,並且該連結串列元素指向值為 111 的 Integer 例項。這是一連串的強引用,也就是說,這個 Integer 的例項不符合垃圾收集條件。一旦 foo 物件超出了程式執行的作用域,LinkedList 和其中的引用內容都可以進行收集,收集的前提是沒有強引用關係。

Finalizers

C++ 允許物件定義解構函式方法:當物件超出作用範圍或被明確刪除時,會呼叫解構函式來清理使用的資源。對於大多數物件來說,解構函式能夠釋放使用 new 或者 malloc 函式分配的記憶體。 在Java中,垃圾收集器會為你自動清除物件,分配記憶體,因此不需要顯式解構函式即可執行此操作。這也是 Java 和 C++ 的一大區別。

然而,記憶體並不是唯一需要被釋放的資源。考慮 FileOutputStream:當你建立此物件的例項時,它從作業系統分配檔案控制程式碼。如果你讓流的引用在關閉前超過了其作用範圍,該檔案控制程式碼會怎麼樣?實際上,每個流都會有一個 finalizer 方法,這個方法是垃圾回收器在回收之前由 JVM 呼叫的方法。對於 FileOutputStream 來說,finalizer 方法會關閉流,釋放檔案控制程式碼給作業系統,然後清除緩衝區,確保資料能夠寫入磁碟

任何物件都具有 finalizer 方法,你要做的就是宣告 finalize() 方法。如下

protected void finalize() throws Throwable
{
    // 清除物件
}

雖然 finalizers 的 finalize() 方法是一種好的清除方式,但是這種方法產生的負面影響非常大,你不應該依靠這個方法來做任何垃圾回收工作。因為 finalize 方法的執行開銷比較大,不確定性強,無法保證各個物件的呼叫順序。finalize 能做的任何事情,可以使用 try-finally 或者其他方式來做,甚至做的更好。

物件的生命週期

綜上所述,可以通過下面的流程來對物件的生命週期做一個總結

物件被建立並初始化,物件在執行時被使用,然後離開物件的作用域,物件會變成不可達並會被垃圾收集器回收。圖中用紅色標明的區域表示物件處於強可達階段。

JDK1.2 介紹了 java.lang.ref 包,物件的生命週期有四個階段:?強可達?(Strongly Reachable?)軟可達(Soft Reachable?)弱可達(Weak Reachable?)幻象可達(Phantom Reachable?)

如果只討論符合垃圾回收條件的物件,那麼只有三種:軟可達、弱可達和幻象可達。

  • 軟可達:軟可達就是?我們只能通過軟引用?才能訪問的狀態,軟可達的物件是由 SoftReference 引用的物件,並且沒有強引用的物件。軟引用是用來描述一些還有用但是非必須的物件。垃圾收集器會盡可能長時間的保留軟引用的物件,但是會在發生 OutOfMemoryError 之前,回收軟引用的物件。如果回收完軟引用的物件,記憶體還是不夠分配的話,就會直接丟擲 OutOfMemoryError。

  • 弱可達:弱可達的物件是 WeakReference 引用的物件。垃圾收集器可以隨時收集弱引用的物件,不會嘗試保留軟引用的物件。

  • 幻象可達:幻象可達是由 PhantomReference 引用的物件,幻象可達就是沒有強、軟、弱引用進行關聯,並且已經被 finalize 過了,只有幻象引用指向這個物件的時候。

除此之外,還有強可達和不可達的兩種可達性判斷條件

  • 強可達:就是一個物件剛被建立、初始化、使用中的物件都是處於強可達的狀態
  • 不可達(unreachable):處於不可達的物件就意味著物件可以被清除了。

下面是一個不同可達性狀態的轉換圖

判斷可達性條件,也是 JVM 垃圾收集器決定如何處理物件的一部分考慮因素。

所有的物件可達性引用都是 java.lang.ref.Reference 的子類,它裡面有一個get() 方法,返回引用物件。 如果已通過程式或垃圾收集器清除了此引用物件,則此方法返回 null 。也就是說,除了幻象引用外,軟引用和弱引用都是可以得到物件的。而且這些物件可以人為拯救,變為強引用,例如把 this 關鍵字賦值給物件,只要重新和引用鏈上的任意一個物件建立關聯即可。

ReferenceQueue

引用佇列又稱為 ReferenceQueue,它位於 java.lang.ref 包下。我們在?建各種引用(軟引用,弱引用,幻象引用)並關聯到響應物件?時,可以選擇是否需要關聯引用佇列。JVM 會在特定的時機將引用入隊到佇列中,程式可以通過判斷引用佇列中是否已經加入引用,來了解被引用的物件是否被GC回收。

Reference

java.lang.ref.Reference 為軟(soft)引用、弱(weak)引用、虛(phantom)引用的父類。因為 Reference 物件和垃圾回收密切配合實現,該類可能不能被直接子類化。

文章參考:

https://www.jianshu.com/p/f86d3a43eec5

《深入理解Java虛擬機器》第二版

http://www.kdgregory.com/index.php?page=java.refobj

相關文章