Java IO Stream控制程式碼洩露分析

yifanwu發表於2021-09-09

Java io包封裝了常用的I/O操作,流式操作被統一為InputStream、OutputStream、Reader、Writer四個抽象類,其中InputStream和OutputStream為位元組流,Reader和Writer為字元流。流式抽象的好處在於程式設計師無需關心資料如何傳輸,只需按位元組或字元依次操作即可。

在使用流式物件進行操作時,特別是檔案操作,使用完畢後千萬不能忘記呼叫流物件的close()方法,這也是老生常談的話題,但是偏偏很容易忘記,或者沒忘記但是使用不當導致close()方法沒呼叫到。正確的做法是把close()方法放在finally塊中呼叫,比如這樣:

InputStream in = ...;OutputStream out = ...;try {
    doSth(in, out);} catch (Exception e) {
    handleException();} finally {
    try {
        in.close();
    } catch (Exception e1){
    }
    try {
        out.close();
    } catch (Exception e2){
    }}

Java流式操作在便捷了I/O操作的同時,也帶來了錯誤處理上覆雜性,這也是Java被人詬病的理由之一。Golang在這塊的處理就非常優雅,它提供了defer關鍵字來簡化流程。

當檔案流未被顯式關閉時,會產生怎樣的後果?結果就是會引起檔案描述符(fd)耗盡。以下程式碼會開啟一個檔案10次,向系統申請了10個fd。

public static void main(String[] args) throws Exception {
    for (int i = 0; i 

到/proc/{pid}/fd目錄下確定已開啟的檔案描述符:

root@classa:/proc/16333/fd# ls -ltotal 0lrwx------ 1 root root 64 Aug  2 20:43 0 -> /dev/pts/3lrwx------ 1 root root 64 Aug  2 20:43 1 -> /dev/pts/3lr-x------ 1 root root 64 Aug  2 20:43 10 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/ext/sunjce_provider.jar
lr-x------ 1 root root 64 Aug  2 20:43 11 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 12 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 13 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 14 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 15 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 16 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 17 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 18 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 19 -> /root/file
lrwx------ 1 root root 64 Aug  2 20:43 2 -> /dev/pts/3lr-x------ 1 root root 64 Aug  2 20:43 20 -> /root/file
lr-x------ 1 root root 64 Aug  2 20:43 3 -> /usr/lib/jvm/java-7-openjdk-amd64/jre/lib/rt.jar

但是根據以往經驗,即使流未顯式關閉,也沒見過檔案描述符耗盡的情況。這是因為Java檔案流式類做了保護措施,FileInputStream和FileOutputStream類利用Java的finalizer機制向GC註冊了資源回收的回撥函式,當GC回收物件時,例項物件的finalize()方法被呼叫。以FileInputStream為例,看看它是怎麼處理的:

/**
 * Ensures that the close method of this file input stream is
 * called when there are no more references to it.
 *
 * @exception  IOException  if an I/O error occurs.
 * @see        java.io.FileInputStream#close()
 */protected void finalize() throws IOException {
    if ((fd != null) &&  (fd != FileDescriptor.in)) {

        /*
         * Finalizer should not release the FileDescriptor if another
         * stream is still using it. If the user directly invokes
         * close() then the FileDescriptor is also released.
         */
        runningFinalize.set(Boolean.TRUE);
        try {
            close();
        } finally {
            runningFinalize.set(Boolean.FALSE);
        }
    }}

當fd未釋放時,finalize()方法會呼叫close()方法關閉檔案描述符。有了這一層保障後,即使程式設計師粗心忘了關閉流,也能保證流最終會被正常關閉了。以下程式可以驗證:

public static void main(String[] args) throws Exception {
    for (int i = 0; i 

Java執行引數加上GC資訊便於觀察:

# java -verbose:gc -XX:+PrintGCDetails -Xloggc:gc.log -XX:+PrintGCTimeStamps StreamTest

程式在pause1處開啟了10個fd,接著強制透過System.gc()觸發一次GC,等gc.log中GC日誌輸出後再觀察/proc/{pid}/fd目錄,發現已開啟的檔案描述符均已經關閉。

但是即便如此,依然存在資源洩漏導致程式無法正常工作的情況,因為JVM規範並未對GC何時被喚起作要求,而物件的finalize()只有在其被回收時才觸發一次,因此完全存在以下情況:在兩次GC週期之間,檔案描述符被耗盡!這個問題曾經在生產環境中出現過的,起因是某同事在原本只需載入一次的檔案讀取操作寫成了每次使用者請求載入一次,在一定的併發數下就導致too many open file的異常:

Exception in thread "main" java.io.FileNotFoundException: file (Too many open files)
        at java.io.FileInputStream.open(Native Method)
        at java.io.FileInputStream.(FileInputStream.java:146)
        ......

原文連結:

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2157/viewspace-2811121/,如需轉載,請註明出處,否則將追究法律責任。

相關文章