# Java try-with-resource很方便,但是真的能夠相信它嗎? 以java.sql中的資料庫物件們進行探討 ## 前言 程式裡面經常需要處理資源,所以會借助一些能夠處理資源的類別,來協助我們操控資源。 馬上先來看看以下的例子: ```java import java.io.ByteArrayInputStream; import java.text.SimpleDateFormat; import java.util.Calendar; import java.util.Date; public class TryWithResouceNote { public static void main(String[] args) { byte[] data = initByteData(); String contentInFile = readByteArray(data); System.out.print(contentInFile); } public static String readByteArray(byte[] data) { ByteArrayInputStream bf = new ByteArrayInputStream(data); int c; StringBuilder sb = new StringBuilder(); while ((c = bf.read()) != -1) { sb.append((char) c); } return sb.toString(); } public static byte[] initByteData() { StringBuilder sb = new StringBuilder(); sb.append("Hi, my name is Kevin Chen,\n"); sb.append("this article will discuss the problem of resource usage,\n"); Date today = Calendar.getInstance().getTime(); SimpleDateFormat formater = new SimpleDateFormat("yyyy-MM-dd"); sb.append("Today is: ").append(formater.format(today)); return sb.toString().getBytes(); } } ``` 在程式裡面,讀取資料的程式被抽成一個函數```readByteArray(byte[])```,它專門負責將byte[]轉換為String,並且裡面使用了BufferedInputStream讀取byte[],但是在函數結束之後,因為BufferedInputStream沒有被關閉,所以byte[]這個資源也沒有辦法被釋放,這樣可能就會產生資源洩漏的問題。同樣的狀況可能也會發生在讀取**檔案**的程式,只要沒有對物件做關閉,就會導致資源無法順利地被釋放。 ## 記得.close() 在JAVA裡面,能夠操控資源的物件,常見的有: Reader, Writer, Inputstream, Outputstream * 這些物件可以用來讀取檔案、系統屬性,或是像上面的程式範例讀取byte[] * 列舉出來的都只是Interface,每個Interface下面會有各自的實作類別,例如Reader下面的實作類別就有BufferedReader、InputStreamReader、FileReader...(類別繁多不一一列舉) DataSource, Connection, Statement, Resultset * 這些物件可以讓JAVA與資料庫進行互動,例如在應用程式啟動時可以建立連線池、利用連線進行SQL查詢、處理SQL查詢的結果等等 當程式有使用到以上的物件,不再需要使用的時候,記得把物件進行關閉,只需要調用物件的```.close()```方法: ```java public static String readByteArray(byte[] data) { ByteArrayInputStream bf = null; try { bf = new ByteArrayInputStream(data); int c; StringBuilder sb = new StringBuilder(); while ((c = bf.read()) != -1) { sb.append((char) c); } return sb.toString(); } catch (Exception e) { throw new RuntimeException("read Byte Array Failed", e); } finally { if (bf != null) { try { bf.close(); } catch (IOException e) { System.out.println("close byte array input stream failed"); } } } } ``` JAVA 7裡面引入的語法糖```Try-with-resource```,讓我們不必明確的在程式裡面撰寫```.close()```方法,因為語法糖會幫助我們,自動將```try()```裡面所宣告的的物件進行關閉。 所以,我們可以將程式進行簡化: ```java public static String readByteArray(byte[] data) { try (ByteArrayInputStream bf = new ByteArrayInputStream(data)) { int c; StringBuilder sb = new StringBuilder(); while ((c = bf.read()) != -1) { sb.append((char) c); } return sb.toString(); } catch (Exception e) { throw new RuntimeException("read Byte Array Failed", e); } } ``` ## 保持質疑 有了語法糖的幫助,確實是能夠讓程式變得比較簡化一點,但是在程式離開try區塊之後, 那些在```try()```宣告的物件,真的會如預期地自動關閉嗎? 近期在工作的時候,處理到了下SQL到DB進行資料查詢的程式,原先是直接使用try-with-resource的語法進行撰寫,所以非常直覺的將Connection、Statement這些物件全部都包到```try()```裡面進行宣告,所以有了以下的程式: ```java public static List<Map<String, Object>> selectBySQL(String sql) { try(Connection connection = dataSource.getConnection(); PreparedStatement ps = connection.prepareStatement(sql)) { try (ResultSet rs = ps.executeQuery()) { //process data in ResultSet... } } catch (SQLException e) { throw new RuntimeException("failed to query data by SQL", e); } } ``` 在上面的程式裡面,可以發現其實各個物件是層層相依的: * Connection依賴DataSource * PreparedStatement依賴Connection * ResultSet依賴PreparedStatement 但是後來在網上搜尋發現這篇討論串,理解到這似乎不是一個很好的寫法: [Must JDBC Resultsets and Statements be closed separately although the Connection is closed afterwards?](https://stackoverflow.com/questions/4507440/must-jdbc-resultsets-and-statements-be-closed-separately-although-the-connection) 即使有語法糖,也無法保證所有相關物件都會被自動關閉。 有人就遇到了問題 1. 使用某廠家的Driver實作,即使將Connection物件關閉,其他相依的物件:ResultSet、PreparedStatement**卻沒有如預期地關閉**,導致資源洩漏的問題產生。 (ResultSet物件可以想像成是一個資料的結果集,它**會佔據JVM的記憶體空間**,如果這個結果集越大、資料量越多,它所佔據的記憶體也會越多。) 2. 從依賴關係可以知道ResultSet會去依賴Statement,Statement會去依賴Connection,所以理當來說關閉Connection物件後,由它所創造出來的Statement與ResultSet物件應該也會一起被關閉,到[Connection javadoc](https://docs.oracle.com/javase/8/docs/api/java/sql/Connection.html#close--)查看,調用```.close()```會將Connection物件相關的資源進行釋放。 > Releases this Connection object's database and JDBC resources immediately instead of waiting for them to be automatically released. 不過還是有人遇到了問題,即使它關閉了Connection,Statement與ResultSet卻沒有被關閉,至DB查看,ResultSet所關聯的Cursor物件仍然保持開啟,最終導致應用掛掉。 所以它改寫程式,一併將Statement進行關閉,結果問題就沒有再發生了,原因竟然是因為沒有關閉Statement而導致Cursor持續開啟。 相關的討論文不計其數,就此篇討論文來看,有正反兩派的說法: 有些人覺得Connection關閉了其他物件都會被關閉,所以使用語法糖的寫法就夠了;另一派則覺得不要過度依賴語法糖自動關閉的機制,因為有時候無法預期JAVA何時會將物件關閉,也無法知道物件何時會被垃圾回收。 以開發者的角度來看,如果確定了物件之後不會再被程式使用到,進行**顯式關閉**(調用```物件.close()```, explicitly close)也是個可嘗試的選項,這會讓JVM更快的將物件進行回收,並且也能夠將記憶體重新被分配的時間提前。 語法糖其實也沒有說不好,畢竟它能夠簡化許多行程式的撰寫,減少許多```.close()```的程式,在大部分的使用案例中,它還是能夠如期運作,它使用了**隱式關閉**(implicitly close),就是說Java會幫你把物件進行關閉,**不過你不會知道物件何時被關閉**。 > 從這個例子也學習到,即使廠家們都是依照一個規範好的介面去實作自家的功能,但可能不是每家都有好好地遵循規則進行程式開發,文章中留言區提到的例子,筆者並未特別去研究是否真有此事,但有這樣的例子出現,還是警惕了身為開發者的我,要記得多去理解一下自己程式的寫法,會不會造成什麼不可預測的影響。 然後又在討論串裡面發現這個十幾年前的文章,講述了最安全的寫法: [How to Close JDBC Resources Properly – Every Time](https://shinesolutions.com/2007/08/04/how-to-close-jdbc-resources-properly-every-time/) 摘要重點: * 將物件進行分層宣告 * 物件初始化的時候,不要給它null作為初始值,應該要立即指派一個真實的物件給它 * 一個物件宣告完成後,下面接著一個try區塊,並且在finally區塊手動關閉這個物件 其餘細節文章裡面講得非常詳細,這裡就來應用在前面的範例程式上,改寫看看: ```java public static List<Map<String, Object>> selectBySQL(String sql) throws SQLException { Connection connection = dataSource.getConnection(); try { PreparedStatement ps = connection.prepareStatement(sql); try { ResultSet rs = ps.executeQuery(); try { //process data in ResultSet... } finally { rs.close(); } } finally { ps.close(); } } catch (SQLException e) { throw new RuntimeException("failed to query data by SQL", e); } finally { connection.close(); } } ``` ## 總結 - 不要太相信自動關閉物件的這個機制,不要過度依賴語法糖。 - 物件宣告分層的概念,可以運用到所有需要調用```.close()```的情境,或是某些物件會有```.destroy()```、```.dispose()```等等。 - 關閉物件的寫法很多種,使用哪種看個人偏好,最主要的是養成習慣,隨手關閉沒有用到的物件。 - 對於不會再被使用的物件進行顯式關閉,也是個可嘗試的選項,畢竟有時候```.close()```的實作內容是會按照廠家的實作而變化的。