try-with-resources 的理解

簡介

典型的 Java 應用程序可以處理多種類型的資源,如文件、流、套接字和數(shù)據(jù)庫連接。必須謹慎處理這些資源,因為對它們的操作會占用系統(tǒng)資源。因此,需要確保即便在出錯的情況下也能釋放這些資源。實際上,不正確的資源管理是生產(chǎn)應用中常見的故障根源,常見的錯誤是,代碼中其他位置出現(xiàn)異常后,數(shù)據(jù)庫連接和文件描述符依然處于打開狀態(tài)。由于操作系統(tǒng)和服務器應用程序通常有一個資源上限,因此在資源耗竭時這會導致應用服務器頻繁重啟。

針對 Java 中資源管理和異常管理的正確做法已經(jīng)有了很好的文檔說明。對于任何已成功初始化的資源,都需要相應地調(diào)用它的 close() 方法。這就要求嚴格遵守 try/catch/finally 塊的用法,以確保任何從資源打開時起的執(zhí)行路徑最終都能調(diào)用一個方法來關閉資源。靜態(tài)分析工具(如 FindBugs)在識別此類錯誤時很有幫助。然而通常的情況是,經(jīng)驗不足的開發(fā)人員和經(jīng)驗豐富的開發(fā)人員都會編寫錯誤的資源管理代碼,從而導致資源泄漏甚至更嚴重的后果。

然而,應該承認,編寫正確的資源代碼需要大量采用嵌套了 try/catch/finally 塊的樣板代碼,您在后文中將看到這一點。正確編寫這種代碼本身很快就會成為難題。與此同時,Python 和 Ruby 等其他編程語言已經(jīng)提供了語言級工具(即自動資源管理)來解決這一問題。

本文介紹 Java Platform, Standard Edition (Java SE) 7 針對自動資源管理問題給出的解決辦法,即 Coin 項目中提出的新語言結構 try-with-resources 語句。我們將看到,該語句的好處遠不止像 Java SE 5 的循環(huán)語句增強一樣地加入更多語法糖。實際上,異常會彼此屏蔽,從而導致有時難以找到問題的根源。

本文首先將概述資源和異常管理,然后將從 Java 開發(fā)人員的視角介紹 try-with-resources 語句的要點。隨后將展示如何準備一個類,使之支持此類語句。接下來,將討論異常屏蔽的問題,以及 Java SE 7 做了哪些改變來解決此類問題。最后,本文將揭開語言擴展背后語法糖的神秘面紗,進行討論并給出結論。

管理資源和異常

我們先從下面節(jié)選的一段代碼開始:

private void incorrectWriting() throws IOException {
       DataOutputStream out = new DataOutputStream(new FileOutputStream("data"));
       out.writeInt(666);
       out.writeUTF("Hello");
       out.close();
   }

乍一看,此方法似乎不會造成什么損害:它打開一個名為 data 的文件,隨后寫入一個整數(shù)和一個字符串。java.io 程序包中對流類的設計使之能夠通過修飾設計模式進行組合。

例如,我們可以在 DataOutputStreamFileOutputStream 之間添加一個用于壓縮數(shù)據(jù)的輸出流。關閉一個流時,也會關閉它所修飾的流。重新回到這個示例,在對 DataOutputStream 的實例調(diào)用 close() 時,同樣也會調(diào)用 FileOutputStreamclose() 方法。

然而,關于在這種方法中對 close() 方法的調(diào)用存在一個嚴重的問題。假設在寫入整數(shù)或字符串時因底層文件系統(tǒng)已滿而拋出一個異常。那么,將不再有機會調(diào)用 close() 方法。

這對于 DataOutputStream 不是什么嚴重的問題,因為它僅對 OutputStream 實例進行操作,用于將基本數(shù)據(jù)類型解碼并把它們寫入字節(jié)數(shù)組中。真正的問題在于 FileOutputStream,因為它在一個文件描述符內(nèi)部保留了一個操作系統(tǒng)資源,僅在調(diào)用 close() 時才能釋放該資源。因此,這種方法會泄漏資源。

這個問題對短時運行的程序基本無礙,但對于建立在 Java Platform, Enterprise Edition (Java EE) 應用服務器上的、長期運行的應用程序來說,由于達到了底層操作系統(tǒng)所允許打開的文件描述符的最大數(shù)量,可能會導致整個服務器重啟。

一種正確地重寫前述方法的方式如下:

private void correctWriting() throws IOException {
       DataOutputStream out = null;
       try {
           out = new DataOutputStream(new FileOutputStream("data"));
           out.writeInt(666);
           out.writeUTF("Hello");
       } finally {
           if (out != null) {
               out.close();
           }
       }        
   }

在任何情況下,拋出的異常都會傳播給方法的調(diào)用者,但 try 塊后的 finally 塊能確保調(diào)用數(shù)據(jù)輸出流的 close() 方法。這相應地確保了底層文件輸出流的 close() 方法同樣獲得調(diào)用,從而正確釋放與文件關聯(lián)的操作系統(tǒng)資源。

適合缺乏耐心者的 try-with-resources 語句
不可否認,前例中存在大量確保正確關閉資源的樣板代碼。如果存在更多的流、網(wǎng)絡套接字或 Java 數(shù)據(jù)庫連接 (JDBC) 連接,此類樣板代碼會使您更難以閱讀一個方法的業(yè)務邏輯。更糟糕的是,它需要開發(fā)人員的自律,因為在編寫錯誤處理和資源關閉邏輯時非常容易出錯。

與此同時,其他編程語言已經(jīng)引入了簡化此類情況處理的結構。例如,上一個方法可以使用 Ruby 寫成如下所示的樣子:

def writing_in_ruby 
       File.open('rdata', 'w') do |f|
           f.write(666)
           f.write("Hello")
       end
   end

用 Python 可寫成這個樣子:

def writing_in_python():
       with open("pdata", "w") as f:
           f.write(str(666))
           f.write("Hello")

Ruby 中,File.open 執(zhí)行了一個代碼塊,即便在該塊的執(zhí)行出現(xiàn)異常時也能確保關閉所打開的文件。

Python 的示例與之相似,其特殊的 with 語句采用一個帶有 close 方法和一個代碼塊的對象。同樣,無論是否拋出異常,都能確保正確關閉資源。

Java SE 7 在 Coin 項目中引入了類似的語言結構。之前的示例可重寫為如下所示:

private void writingWithARM() throws IOException {
       try (DataOutputStream out 
               = new DataOutputStream(new FileOutputStream("data"))) {
           out.writeInt(666);
           out.writeUTF("Hello");
       }
   }

新結構擴展了 try 塊,按照與 for 循環(huán)相似的方式聲明了資源。在 try 塊中聲明打開的任何資源都會關閉。因此,這個新結構使您不必配對使用 try 塊與對應的 finally 塊,后者專用于正確的資源管理。使用分號分隔每個資源,例如:

try (
       FileOutputStream out = new FileOutputStream("output");
       FileInputStream  in1 = new FileInputStream(“input1”);
       FileInputStream  in2 = new FileInputStream(“input2”)
   ) {
       // Do something useful with those 3 streams!
   }   // out, in1 and in2 will be closed in any case

最后需要提到的是,這樣一條 try-with-resources 語句后面可能跟 catch 和 finally 塊,就像 Java SE 7 之前的常規(guī) try 語句一樣。

構造可自動關閉的類

您可能已經(jīng)猜到了,try-with-resources 語句無法管理所有類。Java SE 7 引入了一個新接口 java.lang.AutoCloseable。它的作用就是提供一個名為 close() 的 void 方法,該方法可能拋出一個檢查到的異常 (java.lang.Exception)。任何希望在 try-with-resources 語句中使用的類都應實現(xiàn)該接口。強烈建議,實現(xiàn)的類和子接口應聲明一種比 java.lang.Exception 更精確的異常類型,當然,更好的情況是,如果調(diào)用 close() 方法不會導致失敗,就根本不用聲明異常類型。

此類 close() 方法已經(jīng)進行了改進,包含在標準 Java SE 運行時環(huán)境的許多類中,這些類包括 java.io、java.nio、javax.crypto、java.security、java.util.zip、java.util.jar、javax.net 和 java.sql packages。這種方法的主要優(yōu)點在于,現(xiàn)有代碼可繼續(xù)像以前那樣工作,而新代碼可以輕松利用 try-with-resources 語句。

我們來看看以下示例:

public class AutoClose implements AutoCloseable {    
       
       @Override
       public void close() {
           System.out.println(">>> close()");
           throw new RuntimeException("Exception in close()");
       }
       
       public void work() throws MyException {
           System.out.println(">>> work()");
           throw new MyException("Exception in work()");
       }
       
       public static void main(String[] args) {
           try (AutoClose autoClose = new AutoClose()) {
               autoClose.work();
           } catch (MyException e) {
               e.printStackTrace();
           }
       }
   }
class MyException extends Exception {
       
       public MyException() {
           super();
       }
       
       public MyException(String message) {
           super(message);
       }
   }

AutoClose 類實現(xiàn)了 AutoCloseable,因此可用作 try-with-resources 語句的一部分,如 main() 方法中所示。我們特意添加了一些控制臺輸出,并在該類的 work() 和 close() 方法中拋出異常。運行該程序?qū)a(chǎn)生以下輸出:

>>> work()
   >>> close()
   MyException: Exception in work()
          at AutoClose.work(AutoClose.java:11)
          at AutoClose.main(AutoClose.java:16)
          Suppressed: java.lang.RuntimeException: Exception in close()
                 at AutoClose.close(AutoClose.java:6)
                 at AutoClose.main(AutoClose.java:17)

輸出顯然證實了在進入應處理異常的 catch 塊之前,確實調(diào)用了 close()。然而,Java 開發(fā)人員意外地發(fā)現(xiàn),在 Java SE 7 中出現(xiàn)了以“Suppressed:(…)”為前綴的異常堆棧跟蹤行。它相當于 close() 方法拋出的異常,但在 Java SE 7 之前,您可能從未遇到過這種形式的堆棧跟蹤。這是怎么回事?

異常屏蔽

為了理解前面示例中所發(fā)生的情況,讓我們暫時拋開 try-with-resources 語句,手動重新編寫正確的資源管理代碼。首先,我們提取將由 main 方法調(diào)用的以下靜態(tài)方法:

public static void runWithMasking() throws MyException {
       AutoClose autoClose = new AutoClose();
       try {
           autoClose.work();
       } finally {
           autoClose.close();
       }
   }

隨后,相應地改造 main 方法:

public static void main(String[] args) {
       try {
           runWithMasking();        
       } catch (Throwable t) {
           t.printStackTrace();
       }
   }

現(xiàn)在,運行程序后會給出以下輸出:

>>> work()
   >>> close()
   java.lang.RuntimeException: Exception in close()
          at AutoClose.close(AutoClose.java:6)
          at AutoClose.runWithMasking(AutoClose.java:19)
          at AutoClose.main(AutoClose.java:52)

這段代碼是在 Java SE 7 之前慣用的正確資源管理方法,它顯示了一個異常被另一個異常屏蔽的問題。實際上,調(diào)用 runWithMasking() 方法的客戶端代碼將獲知 close() 方法拋出一個異常,盡管實際上是 work() 方法先拋出了異常。

然而,一次只能拋出一個異常,這就意味著在處理異常時即使正確的代碼也會遺漏一些信息。如果一個重要異常被關閉資源時進而拋出的另一個異常所屏蔽,開發(fā)人員就要浪費大量時間進行調(diào)試。敏銳的讀者可能會對此提出異議,畢竟異常是可以嵌套的。然而,僅應對彼此之間存在因果關系的異常使用嵌套,通常將一個低級異常包裝在位于應用程序架構較高層的異常中。一個很好的例子是 JDBC 驅(qū)動程序?qū)⑻捉幼之惓0b在一個 JDBC 連接中。我們的示例中實際上有兩個異常:一個在 work() 中,一個在 close() 中,兩者之間絕對不存在因果關系。

支持“被抑制的”異常

由于異常屏蔽在實際中是如此重要的一個問題,因此 Java SE 7 擴展了異常,這樣就可以將“被抑制的”異常附加到主異常上。我們之前所說的“屏蔽的”異常實際上就是一個被抑制并附加到主異常的異常。

java.lang.Throwable 的擴展如下:

public final void addSuppressed(Throwable exception) 

將一個被抑制的異常附加到另一個異常上,從而避免異常屏蔽。
public final Throwable[] getSuppressed() 獲取添加到一個異常中的被抑制的異常。

這些擴展是專門為支持 try-with-resources 語句和修復異常屏蔽問題而引入的。

回到之前的 runWithMasking() 方法,我們在考慮支持被抑制異常的前提下重新編寫此方法:

public static void runWithoutMasking() throws MyException {
       AutoClose autoClose = new AutoClose();
       MyException myException = null;
       try {
           autoClose.work();
       } catch (MyException e) {
           myException = e;
           throw e;
       } finally {
           if (myException != null) {
               try {
                   autoClose.close();
               } catch (Throwable t) {
                   myException.addSuppressed(t);
               }
           } else {
               autoClose.close();
           }
       }
   }

很明顯,這里使用了大量代碼,其目的僅僅是正確處理一個可自動關閉類的兩個異常拋出方法!一個局部變量用于捕獲主異常,也就是 work() 方法可能拋出的異常。如果拋出這樣一個異常,則捕獲該異常,隨后立即再次拋出,以便將其余工作委托給 finally 塊。

進入 finally 塊,檢查對主異常的引用。如果拋出了一個異常,則 close() 方法可能拋出的異常將作為被抑制異常附加到此異常。否則將調(diào)用 close() 方法,如果該方法拋出一個異常,那么該異常實際上就是主異常,因此不會屏蔽其他異常。

我們來運行使用這個新方法修改后的程序:

>>> work()
   >>> close()
   MyException: Exception in work()
          at AutoClose.work(AutoClose.java:11)
          at AutoClose.runWithoutMasking(AutoClose.java:27)
          at AutoClose.main(AutoClose.java:58)
          Suppressed: java.lang.RuntimeException: Exception in close()
                 at AutoClose.close(AutoClose.java:6)
                 at AutoClose.runWithoutMasking(AutoClose.java:34)
                 ... 1 more

正如您所見到的那樣,我們手動重現(xiàn)了前文所述的 try-with-resources 語句的行為。

語法糖揭秘

我們實現(xiàn)的 runWithoutMasking() 方法通過正確關閉資源以及防止異常屏蔽來重現(xiàn)了 try-with-resources 語句的行為。實際上,Java 編譯器將以下方法的代碼擴展為與 runWithoutMasking() 代碼一致的情形,使用了 try-with-resources 語句:

public static void runInARM() throws MyException {
       try (AutoClose autoClose = new AutoClose()) {
           autoClose.work();
       }
   }

可以通過反編譯來進行檢查。雖然我們可以使用 Java Development Kit (JDK) 二進制工具中包含的 javap 來比較字節(jié)碼,但我們把它當作一個字節(jié)碼到 Java 源代碼的反編譯器來使用。JD-GUI 工具提取出的 runInARM() 代碼如下(經(jīng)過重新排版):

public static void runInARM() throws MyException {
       AutoClose localAutoClose = new AutoClose();
       Object localObject1 = null;
       try {
           localAutoClose.work();
       } catch (Throwable localThrowable2) {
           localObject1 = localThrowable2;
           throw localThrowable2;
       } finally {
           if (localAutoClose != null) {
               if (localObject1 != null) {
                   try {
                       localAutoClose.close();
                   } catch (Throwable localThrowable3) {
                       localObject1.addSuppressed(localThrowable3);
                   }
               } else {
                   localAutoClose.close();
               }
           }
       }
   }

可以看到,我們手動編寫的代碼使用的資源管理畫布與編譯器根據(jù) try-with-resources 語句推斷出的畫布相同。還應注意到,編譯器處理了可能為 null 的資源引用,在 finally 塊中添加了額外的 if 語句來檢查給定資源是否為 null,從而避免對 null 引用調(diào)用 close() 時的空指針異常。我們的手動實現(xiàn)中并未這樣做,因為資源不可能為 null。但編譯器會系統(tǒng)性地生成此類代碼。

現(xiàn)在,我們來考慮另外一個示例,這次涉及三個資源:


private static void compress(String input, String output) throws IOException {
       try(
           FileInputStream fin = new FileInputStream(input);
           FileOutputStream fout = new FileOutputStream(output);
           GZIPOutputStream out = new GZIPOutputStream(fout)
       ) {
           byte[] buffer = new byte[4096];
           int nread = 0;
           while ((nread = fin.read(buffer)) != -1) {
               out.write(buffer, 0, nread);
           }
       }
   }

這個方法操縱三個資源來壓縮一個文件:一個流用于讀取、一個流用于壓縮、一個流指向輸出文件。從資源管理的視角來看,這樣的代碼是正確的。在 Java SE 7 之前,您不得不類似于下面的代碼,這段代碼是再次使用 JD-GUI 來反編譯包含此方法的類獲得的:

private static void compress(String paramString1, String paramString2)
           throws IOException {
       FileInputStream localFileInputStream = new FileInputStream(paramString1);
    Object localObject1 = null;
       try {
           FileOutputStream localFileOutputStream = new FileOutputStream(paramString2);
        Object localObject2 = null;
           try {
               GZIPOutputStream localGZIPOutputStream = new GZIPOutputStream(localFileOutputStream);
            Object localObject3 = null;
               try {
                   byte[] arrayOfByte = new byte[4096];
                   int i = 0;
                   while ((i = localFileInputStream.read(arrayOfByte)) != -1) {
                       localGZIPOutputStream.write(arrayOfByte, 0, i);
                   }
               } catch (Throwable localThrowable6) {
                   localObject3 = localThrowable6;
                   throw localThrowable6;
               } finally {
                   if (localGZIPOutputStream != null) {
                       if (localObject3 != null) {
                           try {
                               localGZIPOutputStream.close();
                           } catch (Throwable localThrowable7) {
                               localObject3.addSuppressed(localThrowable7);
                           }
                       } else {
                           localGZIPOutputStream.close();
                       }
                   }
               }
           } catch (Throwable localThrowable4) {
               localObject2 = localThrowable4;
               throw localThrowable4;
           } finally {
               if (localFileOutputStream != null) {
                   if (localObject2 != null) {
                       try {
                           localFileOutputStream.close();
                       } catch (Throwable localThrowable8) {
                           localObject2.addSuppressed(localThrowable8);
                       }
                   } else {
                       localFileOutputStream.close();
                   }
               }
           }
       } catch (Throwable localThrowable2) {
           localObject1 = localThrowable2;
           throw localThrowable2;
       } finally {
           if (localFileInputStream != null) {
               if (localObject1 != null) {
                   try {
                       localFileInputStream.close();
                   } catch (Throwable localThrowable9) {
                       localObject1.addSuppressed(localThrowable9);
                   }
               } else {
                   localFileInputStream.close();
               }
           }
       }
   }

對于這樣的示例來說,Java SE 7 中的 try-with-resources 語句的好處是不言而喻的:要編寫的代碼很少、代碼的可讀性更高,最后但并非最不重要的是,代碼不會泄漏資源!

討論

java.lang.AutoCloseable 接口中 close() 方法的定義意味著可能拋出 java.lang.Exception。然而,前面的 AutoClose 示例對該方法進行聲明,但并未提及任何檢查到的異常,這是我們有意為之,部分是為了說明異常屏蔽。

可自動關閉類的規(guī)范建議避免拋出 java.lang.Exception,優(yōu)先使用具體的受檢異常,如果預計 close() 方法不會失敗,就不必提及任何受檢異常。此外還建議,不要聲明任何不應被抑制的異常,java.lang.InterruptedException 就是最好的例子。實際上,抑制該異常并將其附加到另一個異??赡軙е潞雎跃€程中斷事件,使應用程序處于不一致的狀態(tài)。

一個關于 try-with-resources 語句使用的合理問題是,與手動編寫的正確資源管理代碼相比,其對性能的影響如何。實際上并不存在性能方面的影響,因為編譯器為所有異常的正確處理推斷出盡可能少的正確代碼,正如我們在之前示例中通過反編譯所演示的那樣。

最后要說的是,try-with-resources 語句是語法糖,就像 Java SE 5 為擴展迭代器循環(huán)而引入的增強 for 循環(huán)一樣。

話雖如此,我們?nèi)匀豢梢韵拗?try-with-resources 語句擴展的復雜程度。一般來說,一個 try 塊聲明的資源越多,所生成的代碼也就越復雜。之前的 compress() 方法可重寫成僅使用兩個資源而不是三個,從而生成更精簡的異常處理塊:

private static void compress(String input, String output) throws IOException {
       try(
           FileInputStream fin = new FileInputStream(input);
           GZIPOutputStream out = new GZIPOutputStream(new FileOutputStream(output))
       ) {
           byte[] buffer = new byte[4096];
           int nread = 0;
           while ((nread = fin.read(buffer)) != -1) {
               out.write(buffer, 0, nread);
           }
       }
   }

就像 Java 中出現(xiàn) try-with-resources 語句之前的情況一樣,一般經(jīng)驗是,開發(fā)人員在鏈接資源實例化時應始終明白需要取舍的東西。為此,最好的方法就是閱讀每個資源的 close() 方法的規(guī)范,理解其語義和影響。

回到本文最初的 writingWithARM() 示例,鏈接是安全的,因為 DataOutputStream 不可能在 close() 上拋出異常。但是,這不適用于最后一個示例,因為 GZIPOutputStream 會嘗試寫入其余壓縮數(shù)據(jù)作為 close() 方法的一部分。如果在寫入壓縮文件時,拋出異常的時間較早,GZIPOutputStream 中的 close() 方法更有可能進而拋出另一個異常,導致不會調(diào)用 FileOutputStream 中的 close() 方法,從而泄漏一個文件描述符資源。

好的做法是在 try-with-resources 語句中為每一個持有關鍵系統(tǒng)資源(如文件描述符、套接字或者 JDBC 連接)的每個資源進行單獨聲明,必須確保 close() 方法最終得到調(diào)用。否則,如果相關資源 API 允許,選擇鏈接分配就不僅是一種慣例:在防止資源泄漏的同時還能得到更為緊湊的代碼。

結論

本文介紹了 Java SE 7 中一種新的用于安全管理資源的語言結構。這種擴展帶來的影響不僅僅是更多的語法糖。事實上,它能位開發(fā)人員生成了正確的代碼,消除了編寫容易出錯的樣板代碼的需要。更重要的是,這種變化還伴隨著將一個異常附加到另一個異常的改進,從而為眾所周知的異常彼此屏蔽問題提供了完善的解決方案。

歡迎關注我的公眾號

我的公眾號
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容