垃圾收集幾乎是每個開發人員都喜愛的一個 Java 平台特性,它簡化了開發,消除了所有種類的潛在代碼錯誤。可儘管垃圾收集一般來說可以讓您無需進行資源管理,有時候您還是必須自己進行一些內務處理。在本文中,Brian Goetz 討論了垃圾收集的局限性,並指出了您必須自己做內務處理的情境。
小時候,父母總是叮囑我們玩了玩具之後要收好。如果您仔細想想,其實這種嘮叨並不過分,要保持整潔是因為存在實際的限制,房間裡沒有太多的空間,如果到處堆滿了玩具,那麼連走路都無處下腳了。
如果有了足夠的空間,保持整潔就不是那麼必要了。空間越多,就越不必要保持整潔。Arlo Guthrie 著名的民謠 Alice's Restaurant Massacre 說明了這一點:
他們住在教堂樓下的大廳,裡面的椅子全都搬走了,剩下一個空蕩蕩的大房間,所以他們想,很長時間都不用把垃圾扔出去,有的是地方裝垃圾……
無論如何,垃圾收集可以幫我們減輕內務整理方面的工作。
顯式地釋放資源
Java 程式中使用的絕大多數資源都是對象,垃圾收集在清理對象方面做得很好。因此,您可以使用任意多的 String。垃圾收集器最終無需您的幹預就會算出它們何時失效,並收回它們使用的記憶體。
另一方面,像檔案控制代碼和通訊端控制代碼這類非記憶體資源必須由程式顯式地釋放,比如使用 close()、destroy()、shutdown() 或 release() 這樣的方法來釋放。有些類,比如平台類庫中的檔案控制代碼流實現,提供終結器(finalizer)作為安全保證,以便當垃圾收集器確定程式不再使用資源而程式卻忘了釋放資源時,終結器還可以來做這個釋放工作。但是儘管檔案控制代碼提供了終結器來在您忘記了時為您釋放資源,最好還是在使用完之後顯式地釋放資源。這樣做可以更早地釋放資源,降低了資源耗盡的可能。
對於有些資源來說,一直等到終結(finalization)釋放它們是不可取的。對於重要的資源,比如鎖擷取和訊號量許可證,Lock 或 Semaphore 直到很晚都可能不會被垃圾收集掉。對於資料庫連接這樣的資源,如果您等待終結,那麼肯定會消耗完資源。許多資料庫伺服器根據許可的容量,只接受一定數量的串連。如果伺服器應用程式為每個請求都開啟一個新的資料庫連接,然後用完之後就不管了,那麼資料庫遠遠未到終結器關閉不再需要的串連,就會到達它的最高容量。
只限於一個方法的資源
多數資源都不會持續整個應用程式的生命週期,相反,它們只被用於一個活動的生命週期。當應用程式開啟一個檔案控制代碼讀取檔案以處理文檔時,它通常讀取檔案後就不再需要檔案控制代碼了。
在最簡單的情況下,資源在同一個方法調用中被擷取、使用和釋放,比如清單 1 中的 loadPropertiesBadly() 方法:
清單 1. 不正確地在一個方法中擷取、使用和釋放資源 —— 不要這樣做
public static Properties loadPropertiesBadly(String fileName)
throws IOException {
FileInputStream stream = new FileInputStream(fileName);
Properties props = new Properties();
props.load(stream);
stream.close();
return props;
}
不幸的是,這個例子存在潛在的資源泄漏。如果一切進展順利,流將會在方法返回之前被關閉。但是如果 props.load() 方法拋出一個 IOException,那麼流則不會被關閉(直到垃圾收集器運行其終結器)。解決方案是使用 try...finally 機制來確保流被關閉,而不管是否發生錯誤,如清單 2 所示:
清單 2. 正確地在一個方法中擷取、使用和釋放資源
public static Properties loadProperties(String fileName)
throws IOException {
FileInputStream stream = new FileInputStream(fileName);
try {
Properties props = new Properties();
props.load(stream);
return props;
}
finally {
stream.close();
}
}
注意,資源擷取(開啟檔案)是在 try 塊外面進行的;如果把它放在 try 塊中,那麼即使資源擷取拋出異常,finally 塊也會運行。不僅該方法會不適當(您無法釋放您沒有擷取的資源),finally 塊中的代碼也可能拋出其自己的異常,比如 NullPointerException。從 finally 塊拋出的異常取代導致塊退出的異常,這意味著原來的異常丟失了,不能用於協助進行調試。 並不總像看起來那麼容易
使用 finally 來釋放在方法中擷取的資源是可靠的,但是當涉及多個資源時,很容易變得難以處理。下面考慮這樣一個方法,它使用一個 JDBC Connection 來執行查詢和迭代 ResultSet。該方法獲得一個 Connection,使用它來建立一個 Statement,並執行 Statement 以得到一個 ResultSet。但是中間 JDBC 對象 Statement 和 ResultSet 具有它們自己的 close() 方法,並且當您使用完之後,應該釋放這些中間對象。然而,進行資源釋放的 “明顯的” 方式並不起作用,如清單 3 所示:
清單 3. 不成功的釋放多個資源的企圖 —— 不要這樣做
public void enumerateFoo() throws SQLException {
Statement statement = null;
ResultSet resultSet = null;
Connection connection = getConnection();
try {
statement = connection.createStatement();
resultSet = statement.executeQuery("SELECT * FROM Foo");
// Use resultSet
}
finally {
if (resultSet != null)
resultSet.close();
if (statement != null)
statement.close();
connection.close();
}
}
這個 “解決方案” 不成功的原因在於,ResultSet 和 Statement 的 close() 方法自己可以拋出 SQLException,這會導致後面 finally 塊中的 close() 語句不執行。您在這裡有幾種選擇,每一種都很煩人:用一個 try..catch 塊封裝每一個 close(),像清單 4 那樣嵌套 try...finally 塊,或者編寫某種小型架構用於管理資源擷取和釋放。
清單 4. 可靠的釋放多個資源的方法
public void enumerateBar() throws SQLException {
Statement statement = null;
ResultSet resultSet = null;
Connection connection = getConnection();
try {
statement = connection.createStatement();
resultSet = statement.executeQuery("SELECT * FROM Bar");
// Use resultSet
}
finally {
try {
if (resultSet != null)
resultSet.close();
}
finally {
try {
if (statement != null)
statement.close();
}
finally {
connection.close();
}
}
}
}
private Connection getConnection() {
return null;
}
幾乎每一樣東西都可以拋出異常
我們都知道應該使用 finally 來釋放像資料庫連接這樣的重量級對象,但是我們並不總是這樣細心,能夠記得使用它來關閉流(畢竟,終結器會為我們做這件事,是不是?)。很容易忘記在使用資源的代碼不拋出已檢查的異常時使用 finally。清單 5 展示了針對綁定串連的 add() 方法的實現,它使用 Semaphore 來實施綁定,並有效地允許客戶機等待空間可用:
清單 5. 綁定串連的脆弱實現 —— 不要這樣做
public class LeakyBoundedSet<T> {
private final Set<T> set = ...
private final Semaphore sem;
public LeakyBoundedSet(int bound) {
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = set.add(o);
if (!wasAdded)
sem.release();
return wasAdded;
}
}
LeakyBoundedSet 首先等待一個許可證成為可用的(表示串連中有空間了),然後試圖將元素添加到串連中。添加操作如果由於該元素已經在串連中了而失敗,那麼它會釋放許可證(因為它不實際使用它所保留的空間)。
與 LeakyBoundedSet 有關的問題沒有必要馬上跳出:如果 Set.add() 拋出一個異常呢?如果 Set 實現中有缺陷,或者 equals() 或 hashCode() 實現(在 SortedSet 的情況下是 compareTo() 實現)中有缺陷,原因在於添加元素時元素已經在 Set 中了。當然,解決方案是使用 finally 來釋放訊號量許可證,這是一個很簡單卻容易被遺忘的方法。這些類型的錯誤很少會在測試期間暴露出來,因而成了定時炸彈,隨時可能爆炸。清單 6 展示了 BoundedSet 的一個更加可靠的實現:
清單 6. 使用一個 Semaphore 來可靠地綁定 Set
public class BoundedSet<T> {
private final Set<T> set = ...
private final Semaphore sem;
public BoundedHashSet(int bound) {
sem = new Semaphore(bound);
}
public boolean add(T o) throws InterruptedException {
sem.acquire();
boolean wasAdded = false;
try {
wasAdded = set.add(o);
return wasAdded;
}
finally {
if (!wasAdded)
sem.release();
}
}
}
像 FindBugs這樣的代碼審查工具可以檢測出不適當的資源釋放的一些執行個體,比如在一個方法中開啟一個流卻不關閉它。
具有任意生命週期的資源
對於具有任意生命週期的資源,我們要回到 C 語言的時代,即手動地管理資源生命週期。在一個伺服器應用程式中,客戶機到伺服器的一個持久網路連接存在於一個會話期間(比如一個多人蔘與的遊戲伺服器),每個使用者的資源(包括通訊端串連)在使用者退出時必須被釋放。好的組織是有協助的;如果對每個使用者資源的動作項目參考儲存在一個 ActiveUser 對象中,那麼它們就可以在 ActiveUser 被釋放時(無論是顯式地釋放,還是通過垃圾收集而釋放)而被釋放。
具有任意生命週期的資源幾乎總是儲存在一個全域集合中(或者從這裡可達)。要避免資源泄漏,因此非常重要的是,要識別出資源何時不再需要了並可以從這個全域集合中刪除了。(以前的一篇文章 “用弱引用堵住記憶體流失” 給出了一些有用的技巧。)此時,因為您知道資源將要被釋放,任何與該資源關聯的非記憶體資源也可以同時被釋放。
資源所有權
確保及時的資源釋放的一個關鍵技巧是維護所有權的一個嚴格階層,其中的所有權具有釋放資源的職責。如果應用程式建立一個線程池,而線程池建立線程,線程是程式可以退出之前必須被釋放的資源。但是應用程式不擁有線程,而是由線程池擁有線程,因此線程池必須負責釋放線程。當然,直到它本身被應用程式釋放之後,線程池才能釋放線程。
維護一個所有權階層有助於不至於失去控制,其中每個資源擁有它獲得的資源並負責釋放它們。這個規則的結果是,每個不能由垃圾收集單獨收集的資源(即這樣的資源,它直接或間接擁有不能由垃圾收集釋放的資源)必須提供某種生命週期支援,比如 close() 方法。
終結器
如果說平台庫提供終結器來清除開啟的檔案控制代碼,這大大降低了忘記顯式地關閉這些控制代碼的風險,為什麼不更多地使用終結器呢?原因有很多,最重要的一個原因是,終結器很難正確編寫(並且很容易編寫錯)。終結器不僅難以編寫正確,終結的定時也是不確定的,並且不能保證終結器最終會運行。並且終結還為可終結對象的執行個體化和垃圾收集帶來了開銷。不要依賴於終結器作為釋放資源的主要方式。
結束語
垃圾收集為我們做了大量可怕的資源清除工作,但是有些資源仍然需要顯式的釋放,比如檔案控制代碼、通訊端控制代碼、線程、資料庫連接和訊號量許可證。當資源的生命週期被綁定到特定調用幀的生命週期時,我們通常可以使用 finally 塊來釋放該資源,但是長期存活的資源需要一種策略來確保它們最終被釋放。對於任何一個這樣的對象,即它直接或間接擁有一個需要顯式釋放的對象,您必須提供生命週期方法 —— 比如 close()、release()、destroy() 等 —— 來確保可靠的清除。