在Java繼承體系中,最頂層是Object類別,當中定義了各式物件的基礎行為,然而,其中的finalize方法問題重重,絕大多數情況下應避免使用。到了Java 9,斷然廢棄了finalize方法,這動作對基礎類別來說,絕不尋常,而且引發的問題是,當資源清除不得不依賴在物件生命週期結束之時,到底該怎麼做呢?

惡名昭彰的finalize

早在2001年《Effective Java》第一版的條款六,就談到「避免使用finalize方法」,這方法的規範聲稱,若開發者定義了該方法,在垃圾收集打算清除物件之前,會被執行,然而,JVM啟動垃圾收集的時機不定,因此,finalize執行的時機也就不定,這是多數Java開發者被告誡避免使用finalize方法時,最常用的理由之一。

實際上,finalize方法還有更多問題,finalize只會執行一次,而在finalize被執行時,若物件自身因故被其他執行緒參考了,這時就會終止回收物件,之後就算該物件又有了被回收的時機,finalize也不會再執行了;finalize中拋出例外會終止方法的執行,然而例外會被忽略,令除錯發生困難;在實作繼承時,子類也可能疏忽,忘了呼叫父類的finalize方法,導致資源清理不完全等問題。

話雖如此,Java SE標準API卻也有類別,定義了finalize方法,例如,java.io的FileInputStream等。若查看Java 8或早期的API原始碼,可看到實作中呼叫了本身定義的close方法;第三方程式庫實作,也可見到定義了finalize的類別,例如MySQL JDBC驅動程式的Connection介面實作中,就可見到定義finalize來清除資源。

顯然地,這類API是將finalize作為資源清理的最後安全網,畢竟,有時資源慢一點清除,總比不清除來得好,是吧!問題就在於定義了finalize之後,垃圾收集的時機可能被一再推延,甚至沒有機會執行,而且,垃圾收集需要的時間會拉長,不少開發者甚至會發現,只不過加了個finalize方法,就導致了OutOfMemoryError。

這是因為,如果定義了finalize的物件,會被排入一個終結佇列(finalization queue),而且,可能在主垃圾收集(Major GC)發生時,才執行finalize,若物件產生的速度快於清除的速度,就會發生記憶體不足的問題(可參考〈The Secret Life Of The Finalizer〉(https://bit.ly/2tDvfGA))。

物件回收時自動執行?

當涉及系統資源或非本地資源(資料庫之類),不使用相關資源時,應主動將之關閉,這是開發者的基本常識。然而,關閉資源的邏輯在實作上,遠比許多開發者想像的還要麻煩(特別是使用Java時)且容易出錯,為了有個最後的安全網,FileInputStream、Connection等實作類別時,選擇了finalize作為最後防線,若開發者忘了或者實作錯誤時,至少還有個finalize可以在物件回收時,自動執行資源清理動作。

資源的清理和物件的生命週期,實際上,應該是兩個不同階段的概念,為了鼓勵、簡化與正確地實作資源清理邏輯,Java 7引進了try-with-resources語法,以及AutoCloseable介面,對於涉及資源清理職責的物件,都應該實作AutoCloseable,並善用try-with-resources語法,而不是依賴於物件生命週期。

然而,資源清理非得考慮與物件生命週期掛勾的時機,還是有的。若不定義finalize方法的話,在Java 8或早期版本中,PhantomReference會是個解法,建構PhantomReference時,必須指定參考物件(referent)與ReferenceQueue實例,若某物件只剩PhantomReference參考著,那麼,垃圾收集時可以回收該物件,PhantomReference實例則會移入ReferenceQueue。

因此,可以繼承PhantomReference,並自定義清理資源的方法(例如close),在受控的邏輯下,來實作定時或不定時對ReferenceQueue的查詢(像是透過poll方法),在能夠從ReferenceQueue取得PhantomReference實例時,呼叫自定義的清理資源方法,並從ReferenceQueue當中,移除PhantomReference(可參考〈Phantom References in Java〉(https://bit.ly/2SZkz3X))。

儘管透過PhantomReference,清理資源的執行時機仍是不確定的,然而,開發者無法取得PhantomReference參考的物件(PhantomReference的get方法總是傳回null),清理資源的方法執行時,也不會私吞例外,不致於強制客戶端,只能無從選擇地順從finalize的實作。

PhantomReference運用的實際案例,存在於JDK內部API,例如,內部API中的sun.misc.Cleaner,是PhantomReference子類,在DirectByteBuffer當中,擔當釋放記憶體的職責。

Java 9的Cleaner

在Java 9中,正式將finalize標示為廢棄(Deprecated),對於物件回收時自動執行指定邏輯的這件事,則提供了java.lang.ref.Cleaner類別,這有很大的程度是sun.misc.Cleaner公開移植版本,不過,並非PhantomReference子類,不能繼承、也沒有公開建構式,必須透過工廠方法create建立Cleaner實例,開發者透過Cleaner實例的register註冊物件,以及一個Runnable實例。

此時,被註冊的物件,在內部的實作上,會被PhantomReference的子類實例持有,若那時物件只剩PhantomReference參考,就會執行註冊時提供的Runnable實例之run方法,並從Cleaner取消註冊,因此,可以實作Runnable來進行資源清理。

透過Cleaner實例的register註冊時,會傳回Cleaner.Cleanable實例,它具有clean方法,呼叫的話,會執行Runnable的run方法。並從Cleaner取消註冊,因此,API客戶端也可以自行呼叫clean方法來清理資源,在Cleaner的API文件上,就有個結合AutoCloseable的範例,若需要提供主動關閉資源的時機,同時,也希望有個最後清理資源的安全網,可以考慮採用該模式。

這時,開發者會想問了:過去,Java標準API中FileInputStream等的finalize相容性,怎麼辦呢?Java 9後FileInputStream等類別,清空了finalize方法的實作,而且標示了@Deprecated(since="9", forRemoval = true),不過,就這麼清掉其中邏輯,是否會造成依賴在finalize的物件,在Java 9之後無法清理資源呢?

對此,FileInputStream等在內部實作上,預設會改用PhantomReference機制,作為最後呼叫close方法的防線,如果有子類且自定義了close方法,實例化時,會有個內部的AltFinalizer實例產生,作用就是等著被回收時,呼叫AltFinalizer定義的finalize方法(被加了@SuppressWarnings("deprecation")),並且在其中進行相關資源的清理。

避免使用Cleaner

儘管Cleaner在使用上,可以避免掉finalize的一些問題,然而在《Effective Java》第三版條款八中,依舊建議避免使用。因為「仍然是不可預測、運行緩慢」,而且,在System.exit之類結束應用程式的情況下,規範上並沒有指明是否一定要執行清理。

總而言之,真正重要的是,我們\必須分別看待資源清理和物件生命週期。對於資源,應優先考慮實現AutoCloseable,明確地關閉資源或者搭配try-with-resources,非不得已,才能將Cleaner或PhantomReference當成最後的手段。

作者簡介


Advertisement

更多 iThome相關內容