我在專欄上一講提到,NIO 不止是多路復用,NIO 2 也不只是異步 IO,今天我們來看看 Java IO 體系中,其他不可忽略的部分。
今天我要問你的問題是,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](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 只是個抽象類,閱讀它的[源碼](http://hg.openjdk.java.net/jdk/jdk/file/f84ae8aa5d88/src/java.base/share/classes/java/nio/file/spi/FileSystemProvider.java)能夠理解到,原來文件系統實際邏輯存在于 JDK 內部實現里,公共 API 其實是通過 ServiceLoader 機制加載一系列文件系統實現,然后提供服務。
* 我們可以在 JDK 源碼里搜索 FileSystemProvider 和 nio,可以定位到[sun/nio/fs](http://hg.openjdk.java.net/jdk/jdk/file/f84ae8aa5d88/src/java.base/share/classes/sun/nio/fs),我們知道 NIO 底層是和操作系統緊密相關的,所以每個平臺都有自己的部分特有文件系統邏輯。

* 省略掉一些細節,最后我們一步步定位到 UnixFileSystemProvider → UnixCopyFile.Transfer,發現這是個本地方法。
* 最后,明確定位到[UnixCopyFile.c](http://hg.openjdk.java.net/jdk/jdk/file/f84ae8aa5d88/src/java.base/unix/native/libnio/fs/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。
更進一步的詳細使用,我建議參考相關[教程](http://tutorials.jenkov.com/java-nio/buffers.html)。
4.Direct Buffer 和垃圾收集
我這里重點介紹兩種特別的 Buffer。
* Direct Buffer:如果我們看 Buffer 的方法定義,你會發現它定義了 isDirect() 方法,返回當前 Buffer 是否是 Direct 類型。這是因為 Java 提供了堆內和堆外(Direct)Buffer,我們可以以它的 allocate 或者 allocateDirect 方法直接創建。
* MappedByteBuffer:它將文件按照指定大小直接映射為內存區域,當程序訪問這個內存區域時將直接操作這塊兒文件數據,省去了將數據從內核空間向用戶空間傳輸的損耗。我們可以使用[FileChannel.map](https://docs.oracle.com/javase/9/docs/api/java/nio/channels/FileChannel.html#map-java.nio.channels.FileChannel.MapMode-long-long-)創建 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 的生命周期管理和診斷進行了較詳細的分析。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?你可以思考下,如果我們需要在 channel 讀取的過程中,將不同片段寫入到相應的 Buffer 里面(類似二進制消息分拆成消息頭、消息體等),可以采用 NIO 的什么機制做到呢?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?