Java 的 finalize() 方法與垃圾回收的祕密人生【Thinking in Java筆記(4-3)】
資源清理,在 Java 世界裡是個默默努力卻容易被忽略的英雄。特別是當你開始處理非 JVM 記憶體(像是透過 JNI 分配的記憶體)時,事情會變得有點像房東忘了把房租登記進帳本:明明東西在用,但 JVM 不知道
這篇文章會從一個初學者能懂的角度,帶你拆解 Java 的 finalize() 方法到底在搞什麼鬼、為什麼它其實不太可靠,還有垃圾回收器背後的神祕邏輯——你以為它很智能,其實它有點懶惰又挑剔
認識 JNI:Java 與 C 的異國戀愛
在我們談 finalize() 和資源清理前,有必要先認識一下經常被提到但常被跳過的「JNI」
什麼是 JNI?
JNI(Java Native Interface)是一座橋樑,讓你能在 Java 裡呼叫用 C 或 C++ 寫的程式碼。當 Java 發現自己做不到的事(像是控制硬體、呼叫作業系統底層 API),它會打電話給 JNI,請它去找 C 幫忙
用人話解釋:Java 像是你手機上的 App,C 就像是低階硬體控制器,JNI 就是那條 USB 線。你用 JNI 就能讓 App 下指令控制機器,但如果你拔掉 USB 線(或寫錯程式),後果很可能是煙冒出來
為什麼 JNI 會牽扯到記憶體問題?
因為你透過 JNI 分配的記憶體(例如 C 的 malloc()
)根本不在 JVM 的監控範圍內,JVM 完全無法掌握這段記憶體的存在,就像 Java 把東西藏在宿舍外的秘密倉庫一樣。這導致:
- JVM 不會幫你清除那塊記憶體
- 如果你不自己 free() 它,就會發生記憶體外洩(Memory Leak)
所以我們才會在 finalize()
裡放上 free()
,嘗試彌補這段地下戀情可能留下的後果——但說真的,這方法根本靠不住,稍後會講為什麼
為什麼資源清理很重要?
想像你在租房,搬走前如果不清空冰箱、不關瓦斯、不還鑰匙,就會有下一位租客爆炸。程式也是一樣。對於像 int、float 這類「住在宿舍裡」的資料型別,系統會自動幫你處理;但一旦牽涉到你手動「在外面租房」的資源(像是檔案、網路連線、或透過 JNI 分配的記憶體),你不處理,JVM 就真的不知道該怎麼辦
Java 的垃圾回收機制是很方便沒錯,但它的關注範圍只在 JVM 所控的堆積記憶體(heap)。那些透過 JNI 請 C 語言幫你分配的記憶體,JVM 根本連住址都不知道,怎麼幫你回收?
想像一下,Java 是一個外文系學生,C 是一位只說方言的老工匠,JNI 就是那個不太穩定的翻譯軟體,他可能翻完就走,JVM 還完全不知道工匠幫你搬進來一堆垃圾需要清掉
為何需要關注資源清理
程式設計師應該意識到,對資源的適當清理對於維持應用程式的穩定性和性能至關重要。雖然對於基本資料型態(如整數)通常不需要特別的清理,但當涉及到物件(尤其是那些使用非標準記憶體分配的物件)時,必須確保它們在不再使用時被正確地回收。
你可以把這個關係想像成:Java 是一個現代工程師,C 是一位老派的手作匠人,兩人語言不通,但透過 JNI 這位翻譯員能短暫協助他們合作。問題來了——翻譯員只管把話傳好,翻完就走,結果 C 幫忙造了東西(例如分配記憶體),JVM(也就是 Java 的大腦)根本不知道這件事,自然也無法幫忙清理
finalize() 方法:誤解最多的清潔工
finalize() 是什麼?
Java 中每個物件都繼承自 Object 類別,而這個類別中就藏著一個方法:finalize()
。這個方法會在物件被「考慮」要垃圾回收時被 JVM 調用,就像在退租時,房東可能會看你房間有沒有亂七八糟(只是他不一定來看)
但跟 C++ 那種保證在物件離開作用域就執行的解構子(destructor)不同,finalize()
的觸發根本是賭博——JVM 什麼時候收垃圾它自己也不太確定
finalize() 能幫忙什麼?
- 非標準記憶體清理:如果你有用 JNI 分配了記憶體(比如 malloc()),那 JVM 是絕對不會主動幫你 free() 的,這種時候你可以在
finalize()
裡寫上free()
的調用邏輯 - 遺漏的清理檢查點:有時你寫程式忘了呼叫清理方法(像是 close()),可以在
finalize()
裡檢查一下,有沒有什麼該關沒關的資源
finalize() 為何不可靠?
- 執行時間不確定:你永遠不知道 JVM 什麼時候會心血來潮執行 finalize()。有時它懶得收,有時它突然來大掃除
- 可能完全不執行:如果 JVM 沒有遇到資源緊張的情況,它甚至不會收這些記憶體,你的 finalize() 就被晾在角落吃灰塵
- 會拖慢回收效率:有 finalize() 的物件會被 JVM 特別關照,進入「終結隊列」,這會延遲它們被回收的時間
正確的清理方式:你自己來比較快
Java 的做法比較像租房合約條款:你負責把東西收好,JVM 不會幫你檢查。這就是為什麼你應該自己寫清理方法,比如 close()
、dispose()
等等,並且明確在程式裡呼叫
在 C++ 裡,當物件離開它的使用範圍(scope)時,會自動呼叫解構子來做清理;這就像租房一到期,房東自動來收房。而在 Java 裡,所有物件都是用 new
建立在記憶體堆積(heap)上,它們不會自動清理,而是等 JVM 的垃圾回收機制來處理。但問題是,這些物件就像住進宿舍後永遠不收拾的室友,如果你不主動規定清潔時間,他們就會一直賴著不走,讓你的系統越來越髒亂
範例:驗證物件的終結條件
finalize()
方法可以用來驗證物件是否被正確地清理,從而揭示程式中的潛在錯誤
class Book {
boolean checkedOut = false;
Book(boolean checkOut) {
checkedOut = checkOut;
}
void checkIn() {
checkedOut = false;
}
protected void finalize() {
if(checkedOut)
System.out.println("Error: checked out");
// Normally, you'll also do this:
// super.finalize(); // Call the base-class version
}
}
public class TerminationCondition {
public static void main(String[] args) {
Book novel = new Book(true);
// Proper cleanup:
novel.checkIn();
// Drop the reference, forget to clean up:
new Book(true);
// Force garbage collection & finalization:
System.gc();
}
} /* Output:
Error: checked out
*/
這就像你借了書但忘記還,等圖書館要結算庫存時才發現書沒還,發出警告。但如果圖書館永遠不盤點,那你就一直佔著資源
System.gc()
只是建議 JVM 進行垃圾回收,不保證立即執行。即使不使用System.gc()
,當系統資源緊張時,垃圾回收器也會自動運作
JVM 垃圾回收:它其實不只是「撿垃圾」
為什麼 Java 的記憶體分配很快?
- 堆積分配:所有物件都放在 heap 上
- 像傳送帶一樣連續分配:新的物件就像是放在傳送帶末端,只要移動一個指標就好,超快
垃圾回收策略有哪些?
- Reference Counting(引用計數):每個物件記錄誰在用它,缺點是處理不了循環引用,因而未被 JVM 採用
- Mark-and-Sweep(標記-清除):標記還活著的物件,清掉沒人用的,適合垃圾少的情況
- Stop-and-Copy(停止-複製):把活的搬去新空間,剩下直接丟掉,適合垃圾多、空間夠的時候
JVM 的自適應垃圾回收
JVM 很聰明,會根據執行狀況選策略,比如:
- 代數分區:新生代(容易死掉)和老年代(活得久)分開處理
- 策略切換:根據垃圾生成量和碎片情況,自動在 Mark-and-Sweep 與 Stop-and-Copy 間切換
JIT 編譯器:加速 Java 的關鍵角色
雖然 JIT(Just-In-Time)編譯器跟垃圾回收器不直接相關,但它對整體程式效能的影響巨大,所以值得在這裡介紹一下
什麼是 JIT 編譯器?
Java 程式一開始是被編譯成 bytecode,這種形式不屬於任何一種機器的原生語言,而是由 JVM 編譯執行。但編譯執行效率相對較慢,因此 JVM 提供了 JIT 編譯器,它會在程式執行的過程中,將常用的 bytecode 動態轉換為機器語言,加快之後的執行速度
你可以把它想像成咖啡店的點餐流程:
- 一開始是現點現做(編譯執行)——每次都要從頭準備
- JIT 編譯後就是預先知道你常點什麼,直接做一份放旁邊,來就給你(機器碼執行)
JIT 的運作方式
JIT 編譯器會監控哪些程式碼執行得最頻繁(這些稱為「hot spots」),然後才決定是否要將它們編譯為機器碼。這種策略稱為「延遲編譯」(Lazy Compilation),可以節省不必要的資源消耗
JIT 的優點與缺點
- 優點:
- 執行速度更快,特別是對於經常重複執行的邏輯
- 可以根據執行期間的實際資料與模式進行最佳化(例如內聯、消除 dead code 等)
- 缺點:
- 初次執行時會比純編譯稍慢,因為多了一個「分析+編譯」的過程
- 編譯動作會暫時吃掉一些 CPU 資源與記憶體
總結:finalize 不是魔法,清理資源要靠你我他
閱讀到這裡,你應該能理解 finalize() 並不是一個值得信賴的資源清理工具。它像一個懶散的打掃阿姨,可能來、可能不來,就算來了也不保證打掃乾淨。因此,真正負責任的做法是:自己寫明確的清理方法(像是 close()
),並在需要時手動呼叫。
透過這篇文章,我們了解了:
- finalize() 的作用與限制,以及為什麼它不保證執行
- 為什麼對非 JVM 管控的資源(如透過 JNI 分配的記憶體)不能依賴垃圾回收器
- JVM 垃圾回收的運作原理與策略選擇
- JIT 編譯器如何提升整體效能
Java 的 finalize() 是個「說不定會來幫你收拾房間」的朋友,但你不能指望他。真的重要的東西,還是得自己清理、自己負責。理解 finalize 的限制、掌握 JVM 垃圾回收邏輯,才能寫出又快又乾淨的程式
你還是得當個成熟的開發者:用 try-with-resources、用明確的 close(),別指望 JVM 幫你擦屁股
Comments ()