Effective Java 避免使用終結方法和清空方法

菜園子5發表於2020-12-15

終結方法是不可預測的,通常很危險,一般情況下是不必要的(Finalizers are unpredictable, often dangerous, and generally unnecessary.)。使用 終結方法會導致行為不穩定,降低效能,以及可移植性問題。當然,終結方法也有可用之處,我們將在本項的最後再做介紹;但是,作為一項規則,我們應該避免使用它們。在Java 9 中,終結方法已經過時了,但是在Java庫中還在使用。Java 9中替代終結方法的方式是清理方法。清理方法比終結方法危險性更低,但仍然是不可預測的,效能低,而且是不必要的(Cleaners are less dangerous than finalizers, but still unpredictable,
slow, and generally unnecessary)。

  提醒C ++程式設計師不要將終結方法或清理方法視為Java的C ++解構函式的類比。 在C ++中,解構函式是回收與物件關聯的資源的常用方法,物件是建構函式的必要對應物。 在Java中,當一個物件無法訪問時,垃圾回收器會回收與物件相關聯的記憶體,而不需要程式設計師的特別處理(requiring no special effort on the part of the programmer)。C++的解構函式也可以被用來回收其他的非記憶體資源。在Java中,使用try-with-resources或者try-finally塊來完成這個目的。

  終結方法或者清理方法的缺點在於不能保證會被及時地執行[JLS, 12.6]。從一個物件變得不可達開始,到它的終結方法或清理方法被執行,所花費的這段時間是任意長的(也就是說我們無法預知一個物件在銷燬之後和執行終結方法和清理方法之間的間隔時間)。這意味著,對時間有嚴格要求(time-critical)的任務不應該由終結方法或清理方法來完成。例如,用中介方法來關閉已經開啟的檔案,這是嚴重的錯誤,因為開啟檔案的描述符是一種有限的資源。如果由於系統在執行終結方法或清理方法時延遲而導致許多檔案處於開啟狀態,則程式可能會因為無法再開啟檔案而執行失敗。

  執行終結演算法和清除方法的及時性主要取決於垃圾回收演算法,垃圾回收演算法在不同的JVM實現中大相徑庭。如果程式依賴於終結方法或清理方法被執行的時間點,這個程式可能在你測試它的JVM上完美執行,然而在你最重要客戶的JVM平臺上卻執行失敗,這完全是有可能的。

  延遲終結過程並不只是一個理論問題。為類提供終結方法可以延遲其例項的回收過程。一位同事在除錯一個長期執行的GUI應用程式的時候,該應用程式莫名其妙地出現OutOfMemoryError錯誤而死亡。分析表明,該應用程式死亡的時候,其終結方法佇列中有數千個圖形物件正在等待被回收和終結。遺憾的是,終結方法所在的執行緒優先順序比應用程式其他執行緒的要低得多,所以物件沒有在符合回收條件的時候及時被回收( so objects were not getting finalized at the rate they became eligible for finalization)。語言規範並不保證哪個執行緒將會執行終結方法,所以,除了避免使用中介方法之外,並沒有很輕便的辦法能夠避免這樣的問題。在這方面,清理方法比終結方法要好一些,因為類的建立者可以控制他們自己的清理執行緒,但是清理方法仍然是在後臺執行,還是在垃圾收集器的控制下,因此無法保證及時清理。

  語言規範不僅不保證終結方法會被及時地執行,而且根本就不保證它們會被執行。當一個程式終止的時候,某些已經無法訪問的物件上的終結方法卻根本沒有被執行,這完全是有可能的。因此,你不應該依賴終結方法或者清理方法來更新重要的持久狀態。例如,依賴終結方法或者清理方法來釋放共享資源(比如資料庫)上的永久鎖,很容易讓整個分散式系統垮掉。

  不要被System.gcSystem.runFinalization這兩個方法所誘惑,他們確實增加了終結方法和清理方法被執行的機會,但是他們不保證終結方法或清理方法一定會被執行。唯一聲稱保證這兩個方法一定會被執行的方法是System.runFinalizersOnExit,以及它臭名昭著的孿生兄弟Runtime.runFinalizersOnExit。這兩個方法都有致命的缺陷,已經被廢棄了[ThreadStop]。

  終結方法的另一個問題是忽略了在終止過程中被丟擲的未捕獲的異常,那麼該物件的終結過程也會終止(Another problem with finalizers is that an uncaught exception thrown during finalization is ignored, and finalization of that object terminates)[JLS, 12.6]。未捕獲的異常會使物件處於破壞的狀態(a corrupt state)。如果另一個執行緒企圖使用這種被破壞的物件,則可能發生任何不確定的行為。正常情況下,未被捕獲的異常將會使執行緒終止,並列印出堆疊資訊,但是,如果異常發生在終止過程中,則不會如此,甚至連警告都不會列印出來。清理方法就不會有這種問題,因為使用清潔方法的庫可以控制其所在的執行緒。

  使用終結方法和清理方法會嚴重影響效能。在我的機器上,建立一個簡單的AutoCloseable物件,使用try-with-resources關閉它,並讓垃圾收集器回收它的時間大約是12 ns。使用終結方法之後時間增加到550ns。換句話說,用終結方法建立和銷燬物件慢了大約50倍。這主要是因為終結器會抑制有效的垃圾收集。如下所述,如果你使用清潔方法或終結方法去清理類的所有例項,清理方法和終結方法的速度是差不多的(在我的機器上每個例項大約500ns),但是如果你只是把這兩個方法作為安全保障(safety net)的話,清理方法比終結方法快很多。在這種情況下,在我的機器上建立,清理d和銷燬一個物件大約需要66 ns,這意味著如果你不適用它,你需要支付五倍(而不是五十)安全保障的費用(which means you pay a factor of five (not fifty) for the insurance of a safety net if you don’t use it)。

  終結方法有一個很嚴重的安全問題:它們會開啟你的類直到終結方法對其進行攻擊(they open your class up to finalizer attacks)。使用終結方法進行攻擊的原理很簡單(The idea behind a finalizer attack is simple):如果從構造方法或將其序列化的等價方法(readObject和readResolve[第12章])中丟擲異常,惡意子類的終結方法可以在部分構造的物件上執行,這些物件應該“死在藤上(died on the vine)”。這些終結方法可以在一個靜態域上記錄下這些物件的引用,保護它們不被垃圾回收器回收。一旦這些異常的物件唄記錄下來,在這個物件上呼叫任意方法是一件簡單的事情,這些方法本來就不應該被允許存在。從建構函式中丟擲異常應足以防止物件的建立,在終結方法中,事實並非如此(Throwing an exception from a constructor should be sufficient to prevent an object from coming into existence; in the presence of finalizers, it is not)。這種攻擊會產生可怕的後果。final修飾的類不會受到終結方法的攻擊,因為沒人可以編寫final類的惡意子類。要保護非final類受到終結方法的攻擊,請編寫一個不執行任何操作的final finalize方法。

  某些類(比如檔案或執行緒)封裝了需要終止的資源,對於這些類的物件,你應該用什麼方法來替代終結方法和清理方法呢?(So what should you do instead of writing a finalizer or cleaner for a class whose objects encapsulate resources that require termination, such as files or threads?)對於這些類,你只需要讓其實現AutoCloseable介面,並要求其客戶端在每個例項不再需要的時候呼叫例項上的close方法,通常使用try-with-resources來確保即使出現異常時資源也會被終止(第9項)。值得一提的一個細節是例項必須跟蹤其本身是否已被關閉:close方法必須在一個欄位中記錄這個例項已經無效,而其他方法必須檢查此欄位並丟擲IllegalStateException(如果其他方法在例項關閉之後被呼叫)。

  那麼清理方法和終結方法有什麼作用呢?它們可能有兩種合理的用途。第一種用途是,當物件的所有者忘記呼叫其終止方法的情況下充當安全網(safety net)。雖然不能保證清理方法或終結方法能夠及時呼叫(或者根本不執行),晚一點釋放關鍵資源總比永遠不釋放要好。如果你正在考慮編寫這樣的一個安全網終結方法,就要考慮清楚,這種額外的保護是否值得你付出這份額外的代價。某些Java類庫(如FileInputStream、FileOutputStream、ThreadPoolExecutor、和java.sql.Connection)具有充當安全網終結方法。

  清理方法的第二個合理用途與物件的本地對等體(native peers)有關。本地對等體是普通物件通過本機方法委託的本機(非Java)物件,因為本地對等體不是普通物件,因此垃圾收集器不會知道它,並且在回收Java對等體時無法回收它。假設效能可接受並且本地對等體沒有關鍵資源,則清理方法或終結方法可以是用於該任務的適當工具。如果效能不可接受或者本機對等體擁有必須回收的資源,則該類應該具有close方法,這正如之前所說的。

  清理方法使用起來有一點棘手。下面是一個使用Room類簡單演示。讓我們假設在rooms回收之前必須進行清理。這個Room類實現了AutoCloseable介面;事實上,它的自動清理安全網採用的是清理方法的實現僅僅是一個實現細節(the fact that its automatic cleaning safety net uses a cleaner is merely an implementation detail)。跟終結方法不一樣的是,清理方法不會汙染類的公共API:

// An autocloseable class using a cleaner as a safety net
public class Room implements AutoCloseable {
    private static final Cleaner cleaner = Cleaner.create();
    // Resource that requires cleaning. Must not refer to Room!
    private static class State implements Runnable {
        int numJunkPiles; // Number of junk piles in this room
        State(int numJunkPiles) {
            this.numJunkPiles = numJunkPiles;
        }
        // Invoked by close method or cleaner
        @Override public void run() {
            System.out.println("Cleaning room");
            numJunkPiles = 0;
        }
    }
    // The state of this room, shared with our cleanable
    private final State state;
    // Our cleanable. Cleans the room when it’s eligible for gc
    private final Cleaner.Cleanable cleanable;
    public Room(int numJunkPiles) {
        state = new State(numJunkPiles);
        cleanable = cleaner.register(this, state);
    }
    @Override public void close() {
        cleanable.clean();
    }
}

  靜態巢狀State類包含清理程式清理Room所需的資源。 在這種情況下,它只是numJunkPiles欄位,它表示room的混亂程度。 更現實的是,它可能是一個包含指向本地對等體的指標的final long。 State實現了Runnable,它的run方法最多被呼叫一次,當我們在Room建構函式中使用我們的清理器註冊State例項時,我們得到了Cleanable。 對run方法的呼叫將由以下兩種方法之一觸發:通常是通過呼叫Room的close方法呼叫Cleanable的clean方法來觸發。 如果客戶端無法在Room例項符合垃圾收集條件時呼叫close方法,則清理器將(希望)呼叫State的run方法。

  State例項不引用其Room例項至關重要。 如果是這樣,它將建立一個迴圈,以防止Room例項符合垃圾收集的資格(以及自動清理)。 因此,State必須是靜態巢狀類,因為非靜態巢狀類包含對其封閉例項的引用(第24項)。 使用lambda同樣不可取,因為它們可以輕鬆捕獲對封閉物件的引用。

  正如我們之前所說,Room的清潔劑僅用作安全網。 如果客戶端在try-with-resource塊中包圍所有Room例項,則永遠不需要自動清理。 這個表現良好的客戶端演示了這種行為:

public class Adult {
    public static void main(String[] args) {
        try (Room myRoom = new Room(7)) {
            System.out.println("Goodbye");
        }
    }
}

  正如你所期望的那樣,執行Adult程式會列印Goodbye,然後是Cleaning Room。 但是,這個永遠不會清理room的不合理的程式怎麼樣呢?

public class Teenager {
    public static void main(String[] args) {
        new Room(99);
        System.out.println("Peace out");
    }
}

  你可能希望它列印出Peace out,然後是Cleaning Room,但在我的機器上,它從不列印Cleaning Room; 它只是退出。 這是我們之前談到的不可預測性。 Cleaner規範說:“在System.exit期間清理方法的行為是特定實現的。不保證是否呼叫清理操作。”雖然規範沒有說明,但正常程式退出也是如此。在我的機器上,將System.gc()新增到Teenager類的main方法就足以讓它在退出之前列印Cleaning Room,但不能保證你會在你的機器上看到相同的行為。

  總之,除了作為安全網或終止非關鍵的本機資源之外,不要使用清理方法,也不要使用Java 9之前的版本(終結方法)。即使這樣,也要注意不確定性和影響效能導致的後果(Even then, beware the indeterminacy and performance consequences)。

相關文章