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 removal | Java 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 管不到的東西。
延伸閱讀
- Java GC 完整解析|垃圾回收原理、七種收集器比較與面試重點整理 — 四種引用類型、finalize() 面試題
- Java 21 Virtual Threads 教學 — 虛擬執行緒與並行處理
- java.lang.ref.Cleaner API 文件(JDK 25) — Oracle 官方 API 文件
- java.lang.ref.PhantomReference API 文件(JDK 25) — PhantomReference 的詳細規格