12、BIO、NIO、AIO(2)

目錄

Java 有幾種文件拷貝方式?哪一種最高效?

典型回答

考點分析

知識擴展

1. 拷貝實現機制分析

2.Java IO/NIO 源碼結構

3. 掌握 NIO Buffer

4.Direct Buffer 和垃圾收集

5. 跟蹤和診斷 Direct Buffer 內存佔用?


Java 有幾種文件拷貝方式?哪一種最高效?

典型回答

Java 有多種比較典型的文件拷貝實現方式,比如:

利用 java.io 類庫,直接爲源文件構建一個 FileInputStream 讀取,然後再爲目標文件構建一個 FileOutputStream,完成寫入工作。

public static void copyFileByStream(File source, File dest) throws
        IOException {
    try (InputStream is = new FileInputStream(source);
         OutputStream os = new FileOutputStream(dest);){
        byte[] buffer = new byte[1024];
        int length;
        while ((length = is.read(buffer)) > 0) {
            os.write(buffer, 0, length);
        }
    }
 }

或者,利用 java.nio 類庫提供的 transferTo 或 transferFrom 方法實現。

public static void copyFileByChannel(File source, File dest) throws IOException {
    try (FileChannel sourceChannel = new FileInputStream(source).getChannel();
         FileChannel targetChannel = new FileOutputStream(dest).getChannel();){
        for (long count = sourceChannel.size() ;count>0 ;) {
            long transferred = sourceChannel.transferTo(
                 sourceChannel.position(), count, targetChannel);                        
                 sourceChannel.position(sourceChannel.position() + transferred);
                 count -= transferred;
        }
    }
 }

當然,Java 標準類庫本身已經提供了幾種 Files.copy 的實現。

對於 Copy 的效率,這個其實與操作系統和配置等情況相關,總體上來說,NIO transferTo/From 的方式可能更快,因爲它更能利用現代操作系統底層機制,避免不必要拷貝和上下文切換

 

考點分析

今天這個問題,從面試的角度來看,確實是一個面試考察的點,針對我上面的典型回答,面試官還可能會從實踐角度,或者 IO 底層實現機制等方面進一步提問。這一講的內容從面試題出發,主要還是爲了讓你進一步加深對 Java IO 類庫設計和實現的瞭解。
 
從實踐角度,我前面並沒有明確說 NIO transfer 的方案一定最快,真實情況也確實未必如此。我們可以根據理論分析給出可行的推斷,保持合理的懷疑,給出驗證結論的思路,有時候面試官考察的就是如何將猜測變成可驗證的結論,思考方式遠比記住結論重要。

從技術角度展開,下面這些方面值得注意:

  •   不同的 copy 方式,底層機制有什麼區別?
  •   爲什麼零拷貝(zero-copy)可能有性能優勢?
  •   Buffer 分類與使用。
  •   Direct Buffer 對垃圾收集等方面的影響與實踐選擇。


 

知識擴展

1. 拷貝實現機制分析

先來理解一下,前面實現的不同拷貝方法,本質上有什麼明顯的區別。

首先,你需要理解用戶態空間(User Space)和內核態空間(Kernel Space),這是操作系統層面的基本概念,操作系統內核、硬件驅動等運行在內核態空間,具有相對高的特權;而用戶態空間,則是給普通應用和服務使用。你可以參考:https://en.wikipedia.org/wiki/User_space。

我們使用輸入輸出流進行讀寫時,實際上是進行了多次上下文切換,比如應用讀取數據時,先在內核態將數據從磁盤讀取到內核緩存,再切換到用戶態將數據從內核緩存讀取到用戶緩存

寫入操作也是類似,僅僅是步驟相反,你可以參考下面這張圖。

所以,這種方式會帶來一定的額外開銷,可能會降低 IO 效率。

基於 NIO transferTo 的實現方式,在 Linux 和 Unix 上,則會使用到零拷貝技術,數據傳輸並不需要用戶態參與,省去了上下文切換的開銷和不必要的內存拷貝,進而可能提高應用拷貝性能。注意,transferTo 不僅僅是可以用在文件拷貝中,與其類似的,例如讀取磁盤文件,然後進行 Socket 發送,同樣可以享受這種機制帶來的性能和擴展性提高。

transferTo 的傳輸過程是:

 

2.Java IO/NIO 源碼結構

前面我在典型回答中提了第三種方式,即 Java 標準庫也提供了文件拷貝方法(java.nio.file.Files.copy)。如果你這樣回答,就一定要小心了,因爲很少有問題的答案是僅僅調用某個方法。從面試的角度,面試官往往會追問:既然你提到了標準庫,那麼它是怎麼實現的呢?有的公司面試官以喜歡追問而出名,直到追問到你說不知道。

其實,這個問題的答案還真不是那麼直觀,因爲實際上有幾個不同的 copy 方法。

public static Path copy(Path source, Path target, CopyOption... options)
    throws IOException
public static long copy(InputStream in, Path target, CopyOption... options)
    throws IOException
public static long copy(Path source, OutputStream out) 
    throws IOException

可以看到,copy 不僅僅是支持文件之間操作,沒有人限定輸入輸出流一定是針對文件的,這是兩個很實用的工具方法。

後面兩種 copy 實現,能夠在方法實現裏直接看到使用的是 InputStream.transferTo(),你可以直接看源碼,其內部實現其實是 stream 在用戶態的讀寫;而對於第一種方法的分析過程要相對麻煩一些,可以參考下面片段。簡單起見,我只分析同類型文件系統拷貝過程。

public static Path copy(Path source, Path target, CopyOption... options)
    throws IOException
 {
    FileSystemProvider provider = provider(source);
    if (provider(target) == provider) {
        // same provider
        provider.copy(source, target, options);// 這是本文分析的路徑
    } else {
        // different providers
        CopyMoveHelper.copyToForeignTarget(source, target, options);
    }
    return target;
}

我把源碼分析過程簡單記錄如下,JDK 的源代碼中,內部實現和公共 API 定義也不是可以能夠簡單關聯上的,NIO 部分代碼甚至是定義爲模板而不是 Java 源文件,在 build 過程自動生成源碼,下面順便介紹一下部分 JDK 代碼機制和如何繞過隱藏障礙。

  •   首先,直接跟蹤,發現 FileSystemProvider 只是個抽象類,閱讀它的源碼能夠理解到,原來文件系統實際邏輯存在於 JDK 內部實現裏,公共 API 其實是通過 ServiceLoader 機制加載一系列文件系統實現,然後提供服務。
  •   我們可以在 JDK 源碼裏搜索 FileSystemProvider 和 nio,可以定位到sun/nio/fs,我們知道 NIO 底層是和操作系統緊密相關的,所以每個平臺都有自己的部分特有文件系統邏輯。

 

  •   省略掉一些細節,最後我們一步步定位到 UnixFileSystemProvider → UnixCopyFile.Transfer,發現這是個本地方法。
  •   最後,明確定位到UnixCopyFile.c,其內部實現清楚說明竟然只是簡單的用戶態空間拷貝!


所以,我們明確這個最常見的 copy 方法其實不是利用 transferTo,而是本地技術實現的用戶態拷貝。

前面談了不少機制和源碼,我簡單從實踐角度總結一下,如何提高類似拷貝等 IO 操作的性能,有一些寬泛的原則:

  •   在程序中,使用緩存等機制,合理減少 IO 次數(在網絡通信中,如 TCP 傳輸,window 大小也可以看作是類似思路)。
  •   使用 transferTo 等機制,減少上下文切換和額外 IO 操作。
  •   儘量減少不必要的轉換過程,比如編解碼;對象序列化和反序列化,比如操作文本文件或者網絡通信,如果不是過程中需要使用文本信息,可以考慮不要將二進制信息轉換成字符串,直接傳輸二進制信息。


3. 掌握 NIO Buffer

我在上一講提到 Buffer 是 NIO 操作數據的基本工具,Java 爲每種原始數據類型都提供了相應的 Buffer 實現(布爾除外),所以掌握和使用 Buffer 是十分必要的,尤其是涉及 Direct Buffer 等使用,因爲其在垃圾收集等方面的特殊性,更要重點掌握。

Buffer 有幾個基本屬性:

  •   capcity,它反映這個 Buffer 到底有多大,也就是數組的長度。
  •   position,要操作的數據起始位置。
  •   limit,相當於操作的限額。在讀取或者寫入時,limit 的意義很明顯是不一樣的。比如,讀取操作時,很可能將 limit 設置到所容納數據的上限;而在寫入時,則會設置容量或容量以下的可寫限度。
  •   mark,記錄上一次 postion 的位置,默認是 0,算是一個便利性的考慮,往往不是必須的。


前面三個是我們日常使用最頻繁的,我簡單梳理下 Buffer 的基本操作:

  •   我們創建了一個 ByteBuffer,準備放入數據,capcity 當然就是緩衝區大小,而 position 就是 0,limit 默認就是 capcity 的大小。
  •   當我們寫入幾個字節的數據時,position 就會跟着水漲船高,但是它不可能超過 limit 的大小。
  •   如果我們想把前面寫入的數據讀出來,需要調用 flip 方法,將 position 設置爲 0,limit 設置爲以前的 position 那裏。
  •   如果還想從頭再讀一遍,可以調用 rewind,讓 limit 不變,position 再次設置爲 0。

 

4.Direct Buffer 和垃圾收集

我這裏重點介紹兩種特別的 Buffer。

  •   Direct Buffer:如果我們看 Buffer 的方法定義,你會發現它定義了 isDirect() 方法,返回當前 Buffer 是否是 Direct  類型。這是因爲 Java 提供了堆內和堆外(Direct)Buffer,我們可以以它的 allocate 或者 allocateDirect 方法直接創建。
  •   MappedByteBuffer:它將文件按照指定大小直接映射爲內存區域,當程序訪問這個內存區域時將直接操作這塊兒文件數據,省去了將數據從內核空間向用戶空間傳輸的損耗。我們可以使用FileChannel.map創建將MappedByteBuffer,它本質上也是種 Direct Buffer。


在實際使用中,Java 會盡量對 Direct Buffer 僅做本地 IO 操作,對於很多大數據量的 IO 密集操作,可能會帶來非常大的性能優勢,因爲:

  •   Direct Buffer 生命週期內內存地址都不會再發生更改,進而內核可以安全地對其進行訪問,很多 IO 操作會很高效。
  •   減少了堆內對象存儲的可能額外維護工作,所以訪問效率可能有所提高。


但是請注意,Direct Buffer 創建和銷燬過程中,都會比一般的堆內 Buffer 增加部分開銷,所以通常都建議用於長期使用、數據較大的場景。

使用 Direct Buffer,我們需要清楚它對內存和 JVM 參數的影響。首先,因爲它不在堆上,所以 Xmx 之類參數,其實並不能影響 Direct Buffer 等堆外成員所使用的內存額度,我們可以使用下面參數設置大小:

-XX:MaxDirectMemorySize=512M

從參數設置和內存問題排查角度來看,這意味着我們在計算 Java 可以使用的內存大小的時候,不能只考慮堆的需要,還有 Direct Buffer 等一系列堆外因素。如果出現內存不足,堆外內存佔用也是一種可能性。

另外,大多數垃圾收集過程中,都不會主動收集 Direct Buffer,它的垃圾收集過程,就是基於我在專欄前面所介紹的 
Cleaner(一個內部實現)和幻象引用(PhantomReference)機制,其本身不是 public 類型,內部實現了一個 Deallocator 
負責銷燬的邏輯。對它的銷燬往往要拖到 full GC 的時候,所以使用不當很容易導致 OutOfMemoryError。

對於 Direct Buffer 的回收,我有幾個建議:

  •   在應用程序中,顯式地調用 System.gc() 來強制觸發。
  •   另外一種思路是,在大量使用 Direct Buffer 的部分框架中,框架會自己在程序中調用釋放方法,Netty 就是這麼做的,有興趣可以參考其實現(PlatformDependent0)。
  •   重複使用 Direct Buffer。


5. 跟蹤和診斷 Direct Buffer 內存佔用?

因爲通常的垃圾收集日誌等記錄,並不包含 Direct Buffer 等信息,所以 Direct Buffer 內存診斷也是個比較頭疼的事情。幸好,在 JDK 8 之後的版本,我們可以方便地使用 Native Memory Tracking(NMT)特性來進行診斷,你可以在程序啓動時加上下面參數:

-XX:NativeMemoryTracking={summary|detail}

注意,激活 NMT 通常都會導致 JVM 出現 5%~10% 的性能下降,請謹慎考慮。運行時,可以採用下面命令進行交互式對比:

// 打印 NMT 信息
jcmd <pid> VM.native_memory detail 

// 進行 baseline,以對比分配內存變化
jcmd <pid> VM.native_memory baseline

// 進行 baseline,以對比分配內存變化
jcmd <pid> VM.native_memory detail.diff

我們可以在 Internal 部分發現 Direct Buffer 內存使用的信息,這是因爲其底層實際是利用 unsafe_allocatememory。嚴格說,這不是 JVM 內部使用的內存,所以在 JDK 11 以後,其實它是歸類在 other 部分裏。

JDK 9 的輸出片段如下,“+”表示的就是 diff 命令發現的分配變化:

-Internal (reserved=679KB +4KB, committed=679KB +4KB)
              (malloc=615KB +4KB #1571 +4)
              (mmap: reserved=64KB, committed=64KB)

注意:JVM 的堆外內存遠不止 Direct Buffer,NMT 輸出的信息當然也遠不止這些,我在專欄後面有綜合分析更加具體的內存結構的主題。

今天我分析了 Java IO/NIO 底層文件操作數據的機制,以及如何實現零拷貝的高性能操作,梳理了 Buffer 的使用和類型,並針對 Direct 
Buffer 的生命週期管理和診斷進行了較詳細的分析。

發佈了96 篇原創文章 · 獲贊 19 · 訪問量 9萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章