Java 的 finalize() 方法與垃圾回收的祕密人生【Thinking in Java筆記(4-3)】

Java 的 finalize() 方法與垃圾回收的祕密人生【Thinking in Java筆記(4-3)】
Photo by orbtal media / Unsplash

資源清理,在 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 幫你擦屁股