Java 的 GC 會自動回收 Heap 上不再使用的物件,但 native memory、file descriptor 這類 Heap 之外的資源,GC 管不到。過去 Java 提供 finalize() 方法作為物件被回收前的清理機制,但它的問題太多(執行時機不可預測、拖慢 GC、允許物件「復活」),從 Java 9 開始被標記為 deprecated,Java 18 進一步標記為 deprecated for removal。取而代之的是 java.lang.ref.Cleaner,靠 PhantomReference 和 ReferenceQueue 實現安全的資源清理(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