Java 的 GC 會自動回收 Heap 上不再使用的物件,但 native memory、file descriptor 這類 Heap 之外的資源,GC 管不到。過去 Java 提供 finalize() 方法作為物件被回收前的清理機制,但它的問題太多(執行時機不可預測、拖慢 GC、允許物件「復活」),從 Java 9 開始被標記為 deprecated,Java 18 進一步標記為 deprecated for removal。取而代之的是 java.lang.ref.Cleaner,靠 PhantomReferenceReferenceQueue 實現安全的資源清理(GC 的基礎原理與收集器比較可以參考 Java GC 完整解析,有更詳細的介紹)。這篇把這三個機制串起來,從原理到實作完整走一遍。

什麼是 Cleaner

java.lang.ref.Cleaner 是 Java 9 引入的資源清理機制。簡單來說,它能在某個物件被 GC 回收的時候,自動執行一段開發者指定的清理動作(例如釋放 native memory、關閉 file descriptor)。跟被淘汰的 finalize() 相比,Cleaner 不會拖慢 GC、不會讓物件復活、而且支援手動觸發,是目前 JDK 推薦的做法。

為什麼需要 Cleaner

Java 的 GC 只管 Heap 上的物件,但應用程式常常會用到 Heap 之外的資源:透過 JNI 分配的 native memory、開啟的 file descriptor、socket 連線、GPU buffer 等等。這些資源 GC 看不到也不會自動回收,需要開發者手動釋放。

比較理想的做法是用 try-with-resources 搭配 AutoCloseable,在使用完畢後立刻關閉。但如果呼叫端忘了 close,或是物件的生命週期無法用 try-with-resources 管理(例如被多個地方共用的 buffer),就需要一個「最後防線」在物件被 GC 回收時自動清理殘留資源。finalize() 原本是這個防線,但它帶來的問題比解決的還多。

finalize() 為什麼被淘汰

回收需要兩次 GC

GC 第一次掃描發現物件不可達時,不能直接回收,因為要先呼叫 finalize()。JVM 會把這個物件丟進 Finalizer Queue,由一條專門的 Finalizer 執行緒去處理。等 finalize() 跑完之後,物件才能在下一次 GC 被真正回收。一個有 finalize() 的物件至少要撐過兩次 GC 才會被清掉,期間持續佔用記憶體。

物件可以「復活」

finalize() 方法裡,物件可以把 this 指派給一個 static 變數或其他存活物件的欄位,重新讓自己從 GC Roots 可達。這就是所謂的 object resurrection。JVM 對每個物件只會呼叫一次 finalize(),復活後第二次不可達時就不會再呼叫了,但這個行為本身讓程式的記憶體管理變得極難預測。

執行時機和順序不可控

JVM 規格不保證 finalize() 何時執行,也不保證一定會執行(JVM 直接關閉時就不會跑)。Finalizer 執行緒的優先順序低,如果 finalize() 裡面跑太久或卡住,Finalizer Queue 會越積越長,反而加速 OutOfMemoryError。多個物件的 finalize() 執行順序也無法預測。

PhantomReference 與 ReferenceQueue

Cleaner 靠 PhantomReference 搭配 ReferenceQueue 運作。

Java 從 JDK 1.2 開始把物件參照分成四種強度:Strong、Soft、Weak、Phantom(四種引用的回收優先順序與使用場景在 Java GC 完整解析 有更詳細的介紹)。PhantomReference 是其中最弱的一種,它的 get() 永遠回傳 null,完全不影響物件的生命週期。它的唯一用途是:當物件被 GC 判定為可回收之後,JVM 會把對應的 PhantomReference 放進指定的 ReferenceQueue,通知開發者「這個物件已經要被回收了」。

開發者可以開一條背景執行緒輪詢這個 ReferenceQueue,從裡面取出 PhantomReference 時就知道「原本指向的物件已經要被回收了」,這時候去做對應的資源清理。

get() 永遠回傳 null 是刻意的安全設計。PhantomReference 的用途不是讓開發者存取物件,而是「物件已被回收」的事件通知。清理邏輯需要的資料(例如 native pointer)必須在建立 PhantomReference 時就另外存好,不能等到清理時再從物件身上拿。這樣做的好處是從根本上堵住了 finalize() 的物件復活問題,清理動作拿不到原物件的參照,就不可能讓它復活。

// PhantomReference + ReferenceQueue 的基本用法
ReferenceQueue<MyObject> queue = new ReferenceQueue<>();
MyObject obj = new MyObject();
PhantomReference<MyObject> phantomRef = new PhantomReference<>(obj, queue);

// phantomRef.get() 永遠是 null
System.out.println(phantomRef.get()); // null

// 清除強引用,讓 GC 可以回收 obj
obj = null;

// 背景執行緒輪詢 queue
// 當 obj 被 GC 回收後,phantomRef 會出現在 queue 裡
Reference<?> ref = queue.poll(); // 或 queue.remove()(阻塞式)
if (ref != null) {
    // obj 已被回收,在這裡做資源清理
    cleanupNativeResource();
    ref.clear(); // 清除 PhantomReference 本身
}

實務上不會自己寫輪詢迴圈,這正是 Cleaner 封裝好的部分。

Cleaner 的運作機制

java.lang.ref.Cleaner(Java 9 引入)把 PhantomReference + ReferenceQueue + 背景輪詢執行緒封裝成一個簡潔的 API。Cleaner.create() 內部會自動建立 ReferenceQueue 和一條 daemon 執行緒來輪詢,開發者不用自己管 queue 和執行緒,只要呼叫 register() 註冊清理動作就好。使用體驗跟 finalize() 一樣簡單(只管註冊,不管背後的 queue),但不會有 finalize 的副作用。

Cleaner API

Cleaner 的 API 只有兩個步驟。第一步用 Cleaner.create() 建立實例(通常一個 class 共用一個,放在 static final 欄位)。第二步用 register(obj, action) 註冊清理動作:第一個參數是要監控的物件,第二個參數是一個 Runnable,定義物件被 GC 回收時要執行的清理邏輯。register() 會回傳一個 Cleanable,可以呼叫 clean() 手動提前觸發清理,不用等 GC。

實際應用範例

以下用一個 NativeBuffer 類別來示範,模擬一個透過 JNI 分配了 native memory、需要在物件銷毀前特別回收的場景:

import java.lang.ref.Cleaner;

public class NativeBuffer implements AutoCloseable {
    // 整個 class 共用一個 Cleaner(一條背景清理執行緒)
    private static final Cleaner CLEANER = Cleaner.create();

    private long nativePointer; // native memory 的位址
    private final Cleaner.Cleanable cleanable;

    public NativeBuffer(int size) {
        this.nativePointer = allocateNative(size); // JNI 分配 native memory

        // 註冊清理動作:注意 Runnable 不能參照 this
        long ptr = this.nativePointer;
        this.cleanable = CLEANER.register(this, () -> freeNative(ptr));
    }

    // 正常使用路徑:手動 close
    @Override
    public void close() {
        cleanable.clean(); // 立刻執行清理,並從 Cleaner 移除
    }

    // JNI 方法:向 OS 申請 size bytes 的 native memory,回傳記憶體位址
    private static native long allocateNative(int size);
    // JNI 方法:釋放 pointer 指向的 native memory
    private static native void freeNative(long pointer);
}

幾個重要的設計細節:

  • 清理動作不能參照原物件:如果清理用的 Runnable 持有 this 的參照,物件就永遠不會變成不可達,Cleaner 也永遠不會觸發清理。上面的範例把 nativePointer 先存到區域變數 ptr,讓 lambda 只捕獲基本型別的值,不持有 NativeBuffer 的參照。
  • clean() 是冪等的:不管呼叫幾次 cleanable.clean() 都只會執行一次清理動作。如果使用者呼叫了 close()(觸發 clean()),後來 GC 回收時 Cleaner 就不會再執行,不用擔心重複釋放。
  • Cleaner 用的是 daemon 執行緒:背景清理執行緒是 daemon thread,JVM 退出時不會等它跑完。如果資源必須在 JVM 關閉前釋放(例如 flush 寫入),應該搭配 shutdown hook,不能只依賴 Cleaner
  • 一個 Cleaner 共用一條執行緒:建議每個 class 用一個 static final Cleaner,不要每個物件建一個。過多的 Cleaner 實例會浪費執行緒資源。

finalize() 與 Cleaner 的比較

finalize()Cleaner
觸發時機GC 判定不可達後,由 Finalizer 執行緒呼叫GC 回收後,PhantomReference 入隊,由 Cleaner 執行緒處理
GC 影響需要兩次 GC 才能回收不影響 GC 回收速度
物件復活可以在 finalize() 裡復活get() 回傳 null,無法復活
執行順序不可預測不可預測,但清理動作獨立互不影響
手動觸發不支援clean() 可以立刻執行
重複執行只呼叫一次,沒有冪等保證clean() 冪等,多次呼叫只執行一次
JDK 狀態Java 9 deprecated,Java 18 deprecated for removalJava 9 引入,推薦使用

常見的錯誤用法

清理動作持有 this 的參照

// 錯誤示範:lambda 捕獲了 this
CLEANER.register(this, () -> {
    freeNative(this.nativePointer); // this 讓物件永遠可達,Cleaner 永遠不會觸發
});

這很容易踩到。lambda 或匿名內部類別如果用了 this 或實例欄位,就等於持有外部物件的強引用,GC 永遠判定物件可達,清理動作永遠不會被觸發。解法是把需要的資料提取到區域變數或獨立的 static 內部類別。

每個物件建立一個 Cleaner

// 錯誤示範:每個物件一個 Cleaner
public NativeBuffer(int size) {
    Cleaner cleaner = Cleaner.create(); // 每次都建新的 → 浪費執行緒
    this.cleanable = cleaner.register(this, () -> freeNative(ptr));
}

每個 Cleaner.create() 會建立一條新的 daemon 執行緒。如果大量建立物件,就會產生大量執行緒。應該用 static final 讓整個 class 共用一個 Cleaner

JDK 內部怎麼用 Cleaner

Cleaner 不只是給應用程式開發者用的,JDK 自己內部也在用。java.nio.DirectByteBuffer 就是一個典型案例:透過 Unsafe.allocateMemory() 分配的 off-heap 記憶體,在 DirectByteBuffer 被 GC 回收後由 Cleaner 負責呼叫 Unsafe.freeMemory() 釋放。這也是為什麼前一篇提到 -XX:+DisableExplicitGC 可能導致 Direct Memory 不足的原因,System.gc() 被禁用後,觸發 Cleaner 的機會變少。

什麼時候該用 Cleaner

Cleaner 是資源管理的最後防線,不是第一選擇。正確的優先順序是:

  • 第一選擇:try-with-resources + AutoCloseable。明確的生命週期管理,資源在 try 區塊結束時立刻釋放,不依賴 GC。
  • 第二選擇:手動呼叫 close()。物件的生命週期跨越多個方法或由框架管理時,在適當的時機(如 @PreDestroy、shutdown hook)呼叫 close()。
  • 最後防線:Cleaner。作為保險,防止使用者忘了 close() 時 native 資源永久洩漏。不要把 Cleaner 當成主要的資源管理機制。

如果管理的是純 Java 物件(沒有 native 資源),通常不需要 Cleaner,讓 GC 自然回收就好。Cleaner 存在的意義是處理 GC 管不到的東西。

延伸閱讀


Sponsored Links