JVM 系列(6)吊打面試官:為什麼 finalize() 方法只會執行一次?

語言: CN / TW / HK

theme: jzman

攜手創作,共同成長!這是我參與「掘金日新計劃 · 8 月更文挑戰」的第7天,點選檢視活動詳情 

請點贊關注,你的支援對我意義重大。

🔥 Hi,我是小彭。本文已收錄到 GitHub · AndroidFamily 中。這裡有 Android 進階成長知識體系,有志同道合的朋友,關注公眾號 [彭旭銳] 帶你建立核心競爭力。

前言

Java Finalizer 機制提供了一個在物件被回收之前釋放佔用資源的時機,但是都說 Finalizer 機制是不穩定且危險的,不推薦使用,這是為什麼呢?今天我們來深入理解這個問題。


這篇文章是 JVM 系列文章第 6 篇,專欄文章列表:

一、記憶體管理:

二、編譯連結過程

  • 1、Java 編譯過程
  • 2、Class 檔案格式
  • 3、註解處理器
  • 4、註解機制
  • 5、類載入機制
  • 6、泛型機制

三、執行系統

提示:很多內容都已經發表過了,最近會整理出來


學習路線圖:


1. 認識 Finalizer 機制

1.1 為什麼要使用 Finalizer 機制?

Java 的 Finalizer 機制的作用在一定程度上是跟 C/C++ 解構函式類似的機制。當一個物件的生命週期即將終結時,也就是即將被垃圾收集器回收之前,虛擬機器就會呼叫物件的 finalize() 方法,從而提供了一個釋放資源的時機。

1.2 Finalizer 存在的問題

雖然 Java Finalizer 機制是起到與 C/C++ 解構函式類似的作用,但兩者的定位是有差異的。C/C++ 解構函式是回收物件資源的正常方式,與建構函式是一一對應的,而 Java Finalizer 機制是不穩定且危險的,不被推薦使用的,因為 Finalizer 機制存在以下 3 個問題:

  • 問題 1 - Finalizer 機制執行時機不及時: 由於執行 Finalizer 機制的執行緒是一個守護執行緒,它的執行優先順序是比使用者執行緒低的,所以當一個物件變為不可達物件後,不能保證一定及時執行它的 finalize() 方法。因此,當大量不可達物件的 Finalizer 機制沒有及時執行時,就有可能造成大量資源來不及釋放,最終耗盡資源;
  • 問題 2 - Finalizer 機制不保證執行: 除了執行時機不穩定,甚至不能保證 Finalizer 機制一定會執行。當程式結束後,不可達物件上的 Finalizer 機制有可能還沒有執行。假設程式依賴於 Finalizer 機制來更新持久化狀態,例如釋放資料庫的鎖,就有可能使得整個分散式系統陷入死鎖;
  • 問題 3 - Finalizer 機制只會執行一次: 如果不可達物件在 finalize() 方法中被重新啟用為可達物件,那麼在它下次變為不可達物件後,不會再次執行 finalize() 方法。這與 Finalizer 機制的實現原理有關,後文我們將深入虛擬機器原始碼,從原始碼層面深入理解。

1.3 什麼時候使用 Finalizer 機制?

由於 Finalizer 機制存在不穩定性,因此不應該將 Finalizer 機制作為釋放資源的主要策略,而應該作為釋放資源的兜底策略。程式應該在不使用資源時主動釋放資源,或者實現 AutoCloseable 介面並通過 try-with-resources 語法確保在有異常的情況下依然會釋放資源。而 Finalizer 機制作為兜底策略,雖然不穩定但也好過忘記釋放資源。

不過,Finalizer 機制已經被標記為過時,使用 Cleaner 機制作為釋放資源的兜底策略(本質上是 PhantomReference 虛引用)是相對更好的選擇。雖然 Cleaner 機制也存在相同的不穩定性,但總體上比 Finalizer 機制更好。


2. Finalizer 機制原理分析

從這一節開始,我們來深入分析 Java Finalizer 機制的實現原理,相關原始碼基於 Android 9.0 ART 虛擬機器。

2.1 引用實現原理回顧

在上一篇文章中,我們分析過 Java 引用型別的實現原理,Java Finalizer 機制也是其中的一個環節,我們先對整個過程做一次簡單回顧。

2.2 建立 FinalizerReference 引用物件

我們都知道 Java 有四大引用型別,除此之外,虛擬機器內部還設計了 @hideFinalizerReference 型別來支援 Finalizer 機制。Reference 引用物件是用來實現更加靈活的物件生命週期管理而設計的物件包裝類,Finalizer 機制也與物件的生命週期有關,因此存在這樣 “第 5 種引用型別” 也能理解。

在虛擬機器執行類載入的過程中,會將重寫了 Object#finalize() 方法的型別標記為 finalizable 型別。每次在建立標記為 finalizable 的物件時,虛擬機器內部會同時建立一個關聯的 FinalizerReference 引用物件,並將其暫存到一個全域性的連結串列中 (如果不存在全域性的變數中,沒有強引用持有的 FinalizerReference 本身在下次 GC 直接就被回收了)。

heap.cc

cpp void Heap::AddFinalizerReference(Thread* self, ObjPtr<mirror::Object>* object) { ScopedObjectAccess soa(self); ScopedLocalRef<jobject> arg(self->GetJniEnv(), soa.AddLocalReference<jobject>(*object)); jvalue args[1]; args[0].l = arg.get(); // 呼叫 Java 層靜態方法 FinalizerReference#add InvokeWithJValues(soa, nullptr, WellKnownClasses::java_lang_ref_FinalizerReference_add, args); *object = soa.Decode<mirror::Object>(arg.get()); }

FinalizerReference.java

```java // 關聯的引用佇列 public static final ReferenceQueue queue = new ReferenceQueue(); // 全域性連結串列頭指標(使用一個雙向連結串列持有 FinalizerReference,否則沒有強引用的話引用物件本身直接就被回收了) private static FinalizerReference<?> head = null;

private FinalizerReference<?> prev; private FinalizerReference<?> next;

// 從 Native 層呼叫 public static void add(Object referent) { // 建立 FinalizerReference 引用物件,並關聯引用佇列 FinalizerReference<?> reference = new FinalizerReference(referent, queue); synchronized (LIST_LOCK) { // 頭插法加入全域性單鏈表 reference.prev = null; reference.next = head; if (head != null) { head.prev = reference; } head = reference; } }

public static void remove(FinalizerReference<?> reference) { // 從雙向連結串列中移除,程式碼略 } ```

2.3 在哪裡執行 finalize() 方法?

根據我們對引用佇列的理解,當我們在建立引用物件時關聯引用佇列,可以實現感知物件回收時機的作用。當引用指向的實際物件被垃圾回收後,引用物件會被加入引用佇列。那麼,是誰在消費這個引用佇列呢?

在虛擬機器啟動時,會啟動一系列守護執行緒,其中除了處理引用入隊的 ReferenceQueueDaemon 執行緒,還包括執行 Finalizer 機制的 FinalizerDaemon 執行緒。FinalizerDaemon 執行緒會輪詢觀察引用佇列,並執行實際物件上的 finalize() 方法。

提示: FinalizerDaemon 是一個守護執行緒,因此 finalize() 的執行優先順序低。

Daemons.java

```java public static void start() { // 啟動四個守護執行緒 ReferenceQueueDaemon.INSTANCE.start(); FinalizerDaemon.INSTANCE.start(); FinalizerWatchdogDaemon.INSTANCE.start(); HeapTaskDaemon.INSTANCE.start(); }

// 已簡化 private static class FinalizerDaemon extends Daemon {

private static final FinalizerDaemon INSTANCE = new FinalizerDaemon();

// 這個佇列就是 FinalizerReference 關聯的引用佇列
private final ReferenceQueue<Object> queue = FinalizerReference.queue;

FinalizerDaemon() {
    super("FinalizerDaemon");
}

@Override public void runInternal() {
    while (isRunning()) {
        // 1、從引用佇列中取出引用
        FinalizerReference<?> finalizingReference = (FinalizerReference<?>)queue.poll();
        // 2、執行引用所指向物件 Object#finalize()
        doFinalize(finalizingReference);
        // 提示:poll() 是非阻塞的,FinalizerDaemon 是與 FinalizerWatchDogDaemon 配合實現等待喚醒機制的
    }

@FindBugsSuppressWarnings("FI_EXPLICIT_INVOCATION")
private void doFinalize(FinalizerReference<?> reference) {
    // 2.1 移除 FinalizerReference 物件
    FinalizerReference.remove(reference);
    // 2.2 取出引用所指向的物件(不可思議,為什麼不為空呢?)
    Object object = reference.get();
    // 2.3 解除關聯關係
    reference.clear();
    // 2.4 呼叫 Object#finalize()
    object.finalize();
}

} ```

這裡你有發現問題嗎,當普通的引用物件在進入引用佇列時,虛擬機器已經解除了引用物件與實際物件的關聯,此時呼叫 Reference#get() 應該返回 null 才對。 但 FinalizerReference#get() 居然還能拿到實際物件,實際物件不是已經被回收了嗎!? 這隻能從原始碼中尋找答案。

2.4 FinalizerReference 引用物件入隊過程

由於標記為 finalizable 的物件在被回收之前需要呼叫 finalize() 方法,因此這一類物件的回收過程比較特殊,會經歷兩次 GC 過程。我將整個過程概括為 3 個階段:

  • 階段 1 - 首次 GC 過程: 當垃圾收集器發現物件變成不可達物件時,會解綁實際物件與引用物件的關聯關係。當實際物件被清除後,會將引用物件加入關聯的引用佇列(這個部分我們在上一篇文章中分析過了)。然而,finalizable 物件還需要呼叫 finalize() 方法,所以首次 GC 時還不能回收實際物件。為此,垃圾收集器會主動將原本不可達的實際物件重新標記為可達物件,使其從本次垃圾收集中豁免,並且將實際物件臨時儲存到 FinalizerReference 的 zombie 欄位中。實際物件與 FinalizerReference 的關聯關係依然會解除,否則會陷入死迴圈永遠無法回收;
  • 階段 2 - FinalizerDaemon 執行 finalize() 方法: FinalizerDaemon 守護執行緒消費引用佇列時,呼叫 ReferenceQueue#get() 只是返回暫存在 zombie 欄位中的實際物件而已,其實此時關聯關係早就解除了(這就是為什麼 FinalizerReference#get() 還可以獲得實際物件)。
  • 階段 3 - 二次 GC: 由於實際物件和 FinalizerReference 已經沒有關聯關係了,第二次回收過程跟普通物件相同。前提是 finalize() 中將實際物件重新變成可達物件,那麼二次 GC 不會那麼快執行,要等到它重新變為不可達狀態。

提示: 這就是為什麼 finalize() 方法只會執行一次,因為執行 finalize() 時實際物件和 FinalizerReference 已經解除關聯了,後續的垃圾回收跟普通的非 finalizable 物件一樣。

原始碼摘要如下:

垃圾收集器清理過程:

方法呼叫鏈: ReclaimPhase→ProcessReferences→ReferenceProcessor::ProcessReferences→ReferenceQueue::EnqueueFinalizerReferences

reference_queue.cc

cpp void ReferenceQueue::EnqueueFinalizerReferences(ReferenceQueue* cleared_references, collector::GarbageCollector* collector) { while (!IsEmpty()) { ObjPtr<mirror::FinalizerReference> ref = DequeuePendingReference()->AsFinalizerReference(); mirror::HeapReference<mirror::Object>* referent_addr = ref->GetReferentReferenceAddr(); // IsNullOrMarkedHeapReference:判斷引用指向的實際物件是否被標記 if (!collector->IsNullOrMarkedHeapReference(referent_addr, /*do_atomic_update*/false)) { // MarkObject:重新標記位可達物件 ObjPtr<mirror::Object> forward_address = collector->MarkObject(referent_addr->AsMirrorPtr()); // 將實際物件暫存到 zombie 欄位 ref->SetZombie<false>(forward_address); // 解除關聯關係(普通引用物件亦有此操作) ref->ClearReferent<false>(); // 將引用物件加入 cleared_references 佇列(普通引用物件亦有此操作) cleared_references->EnqueueReference(ref); } DisableReadBarrierForReference(ref->AsReference()); } }

實際物件暫存在 zombie 欄位中:

FinalizerReference.java

```java // 由虛擬機器維護,用於暫存實際物件 private T zombie;

// 2.2 取出引用所指向的物件(其實是取 zombie 欄位) @Override public T get() { return zombie; }

// 2.3 解除關聯關係,實際上虛擬機器內部早就解除關聯關係了,這裡只是返回暫存在 zombie 中的實際物件 @Override public void clear() { zombie = null; } ```

至此,Finalizer 機制實現原理分析完畢。

使用一張示意圖概括整個過程:


3. 總結

總結一下 Finalizer 機制最主要的環節:

  • 1、為了實現物件的 Finalizer 機制,虛擬機器設計了 FinalizerReference 引用型別。重寫了 Object#finalize() 方法的型別在類載入過程中會被標記位 finalizable 型別,每次建立物件時會同步建立關聯的 FinalizerReference 引用物件;
  • 2、不可達物件在即將被垃圾收集器回收時,虛擬機器會解除實際物件與引用物件的關聯關係,並將引用物件加入關聯的引用佇列中。然而,由於 finalizable 物件還需要執行 finalize() 方法,因此垃圾收集器會主動將物件標記為可達物件,並將實際物件暫存到 FinalizerReference 的 zombie 欄位中;
  • 3、守護執行緒 ReferenceQueueDaemon 會輪詢全域性臨時佇列 unenqueued 佇列,將引用物件分別投遞到關聯的引用佇列中
  • 4、守護執行緒 FinalizerDaemon 會輪詢觀察引用佇列,並執行實際物件上的 finalize() 方法。

參考資料

你的點贊對我意義重大!微信搜尋公眾號 [彭旭銳],希望大家可以一起討論技術,找到志同道合的朋友,我們下次見!

不只程式碼。

image.png