有必要了解一下Linux中零拷貝原理 | NIO零拷貝技術實踐
- 2019 年 11 月 5 日
- 筆記

本文導讀:
- 什麼是零拷貝
- 傳統 IO 數據拷貝原理
- 什麼是 DMA
- sendfile 數據零拷貝原理
- mmap 數據零拷貝原理
- Java 中 NIO 零拷貝實現
- Java IO 與 NIO 實戰案例分析
什麼是零拷貝
關於零拷貝,WIKI 上給出的定義如下:

「Zero-copy」 describes computer operations in which the CPU does not perform the task of copying data from one memory area to another. This is frequently used to save CPU cycles and memory bandwidth when transmitting a file over a network.
所謂「零拷貝」描述的是電腦作業系統當中,CPU不執行將數據從一個記憶體區域,拷貝到另外一個記憶體區域的任務。通過網路傳輸文件時,這樣通常可以節省 CPU 周期和記憶體頻寬。
從描述中已經了解到零拷貝技術給我們帶來的好處:
1、節省了 CPU 周期,空出的 CPU 可以完成更多其他的任務
2、減少了記憶體區域之間數據拷貝,節省記憶體頻寬
3、減少用戶態和內核態之間數據拷貝,提升數據傳輸效率
4、應用零拷貝技術,減少用戶態和內核態之間的上下文切換
傳統 IO 數據拷貝原理
在正式分析零拷貝機制原理之前,我們先來看下傳統 IO 在數據拷貝的基本原理,從數據拷貝 (I/O 拷貝) 的次數以及上下文切換的次數進行對比分析。
傳統 IO:

1、JVM 進程內發起 read() 系統調用,作業系統由用戶態空間切換到內核態空間(第一次上下文切換) 2、通過 DMA 引擎建數據從磁碟拷貝到內核態空間的輸入的 socket 緩衝區中(第一次拷貝) 3、將內核態空間緩衝區的數據原封不動的拷貝到用戶態空間的快取區中(第二次拷貝),同時內核態空間切換到用戶態空間(第二次上下文切換),read() 系統調用結束 4、JVM 進程內業務邏輯程式碼執行 5、JVM 進程內發起 write() 系統調用 6、作業系統由用戶態空間切換到內核態空間(第三次上下文切換),將用戶態空間的快取區數據原封不動的拷貝到內核態空間輸出的 socket 快取區中(第三次拷貝) 7、write() 系統調用返回,作業系統由內核態空間切換到用戶態空間(第四次上下文切換),通過 DMA 引擎將數據從內核態空間的 socket 快取區數據拷貝到協議引擎中(第四次拷貝)
傳統 IO 方式,一共在用戶態空間與內核態空間之間發生了 4 次上下文的切換,4 次數據的拷貝過程,其中包括 2 次 DMA 拷貝和 2 次 I/O 拷貝(內核態與用戶應用程式之間發生的拷貝)。
內核空間緩衝區的一大用處是為了減少磁碟I/O操作,因為它會從磁碟中預讀更多的數據到緩衝區中。而使用 BufferedInputStream 的用處是減少 「系統調用」。
什麼是DMA
DMA(Direct Memory Access)—直接記憶體訪問 :DMA是允許外設組件將 I/O 數據直接傳送到主存儲器中並且傳輸不需要 CPU 的參與,以此將 CPU 解放出來去完成其他的事情。
sendfile 數據零拷貝原理
sendfile 數據零拷貝: 顯然,在傳統 IO 中,用戶態空間與內核態空間之間的複製是完全不必要的,因為用戶態空間僅僅起到了一種數據轉存媒介的作用,除此之外沒有做任何事情。 Linux 提供了 sendfile() 用來減少我們前面提到的數據拷貝和的上下文切換次數。
如下圖所示:

1、發起 sendfile() 系統調用,作業系統由用戶態空間切換到內核態空間(第一次上下文切換) 2、通過 DMA 引擎將數據從磁碟拷貝到內核態空間的輸入的 socket 緩衝區中(第一次拷貝) 3、將數據從內核空間拷貝到與之關聯的 socket 緩衝區(第二次拷貝) 4、將 socket 緩衝區的數據拷貝到協議引擎中(第三次拷貝) 5、sendfile() 系統調用結束,作業系統由用戶態空間切換到內核態空間(第二次上下文切換)
根據以上過程,一共有 2 次的上下文切換,3 次的 I/O 拷貝。我們看到從用戶空間到內核空間並沒有出現數據拷貝,從作業系統角度來看,這個就是零拷貝。內核空間出現了複製的原因: 通常的硬體在通過DMA訪問時期望的是連續的記憶體空間。
支援 scatter-gather 特性的 sendfile 數據零拷貝:

這次相比 sendfile() 數據零拷貝,減少了一次從內核空間到與之相關的 socket 緩衝區的數據拷貝。
基本流程: 1、發起 sendfile() 系統調用,作業系統由用戶態空間切換到內核態空間(第一次上下文切換)
2、通過 DMA 引擎將數據從磁碟拷貝到內核態空間的輸入的 socket 緩衝區中(第一次拷貝)
3、將描述符資訊會拷貝到相應的 socket 緩衝區當中,該描述符包含了兩方面的資訊:
a) kernel buffer的記憶體地址;
b) kernel buffer的偏移量。
4、DMA gather copy 根據 socket 緩衝區中描述符提供的位置和偏移量資訊直接將內核空間緩衝區中的數據拷貝到協議引擎上(第二次拷貝),這樣就避免了最後一次 I/O 數據拷貝。
5、sendfile() 系統調用結束,作業系統由用戶態空間切換到內核態空間(第二次上下文切換)
下面這個圖更進一步理解:

Linux/Unix 作業系統下可以通過下面命令查看是否支援 scatter-gather 特性。
ethtool -k eth0 | grep scatter-gatherscatter-gather: on
許多的 web server 都已經支援了零拷貝技術,比如 Apache、Tomcat。
sendfile 零拷貝消除了所有內核空間緩衝區與用戶空間緩衝區之間的數據拷貝過程,因此 sendfile 零拷貝 I/O 的實現是完成在內核空間中完成的,這對於應用程式來說就無法對數據進行操作了。
mmap 數據零拷貝原理
如果需要對數據做操作,Linux 提供了mmap 零拷貝來實現。
mmap 零拷貝:

通過上圖看到,一共發生了 4 次的上下文切換,3 次的 I/O 拷貝,包括 2 次 DMA 拷貝和 1 次的 I/O 拷貝,相比於傳統 IO 減少了一次 I/O 拷貝。使用 mmap() 讀取文件時,只會發生第一次從磁碟數據拷貝到 OS 文件系統緩衝區的操作。
1)在什麼場景下使用 mmap() 去訪問文件會更高效?
對文件執行隨機訪問時,如果使用 read() 或 write(),則意味著較低的 cache 命中率。這種情況下使用 mmap() 通常將更高效。 多個進程同時訪問同一個文件時(無論是順序訪問還是隨機訪問),如果使用mmap(),那麼作業系統緩衝區的文件內容可以在多個進程之間共享,從作業系統角度來看,使用 mmap() 可以大大節省記憶體。
2)什麼場景下沒有使用 mmap() 的必要?
訪問小文件時,直接使用 read() 或 write() 將更加高效。 單個進程對文件執行順序訪問時 (sequential access),使用 mmap() 幾乎不會帶來性能上的提升。譬如說,使用 read() 順序讀取文件時,文件系統會使用 read-ahead 的方式提前將文件內容快取到文件系統的緩衝區,因此使用 read() 將很大程度上可以命中快取。
Java 中 NIO 零拷貝實現
Java NIO 中的通道(Channel)相當於作業系統的內核空間(kernel space)的緩衝區,而緩衝區(Buffer)對應的相當於作業系統的用戶空間(user space)中的用戶緩衝區(user buffer)。
- 通道(Channel)是全雙工的(雙向傳輸),它既可能是讀緩衝區(read buffer),也可能是網路緩衝區(socket buffer)。
- 緩衝區(Buffer)分為堆記憶體(HeapBuffer)和堆外記憶體(DirectBuffer),這是通過 malloc() 分配出來的用戶態記憶體。
Java NIO 引入了用於通道的緩衝區的 ByteBuffer。
ByteBuffer有三個主要的實現:
1、HeapByteBuffer
調用 ByteBuffer.allocate() 方法時使用到 HeapByteBuffer。這個快取區域是在 JVM 進程的堆上分配的,可以獲得如GC支援和快取優化的優勢。
但它不是頁面對齊的,這意味著若需通過JNI與本地程式碼交談,JVM將不得不複製到對齊的緩衝區空間。
2、DirectByteBuffer
調用 ByteBuffer.allocateDirect() 方法時使用。 JVM 會使用 malloc() 在堆空間之外分配記憶體空間。 由於它的記憶體空間不由 JVM 管理,所以你的記憶體空間是頁面對齊的,不受GC影響。但需要自己管理這個記憶體,注意分配和釋放記憶體來防止記憶體泄漏。
3、MappedByteBuffer
調用 FileChannel.map() 時使用。與DirectByteBuffer類似,這也是 JVM 堆外部分配記憶體空間。它基本上作為作業系統 mmap() 系統調用的包裝函數,以便程式碼直接操作映射的物理記憶體數據。
Java IO 與 NIO 實戰案例分析
下面我們通過程式碼示例來對比下傳統 IO 與使用了零拷貝技術的 NIO 之間的差異。 我們通過服務端開啟 socket 監聽,然後客戶端連接的服務端進行數據的傳輸,數據傳輸文件大小為 237M。
零拷貝技術的 NIO,這裡咱們通過剛剛介紹的 HeapByteBuffer 來實戰對比一下。
1、構建傳統IO的socket服務端,監聽8898埠。
public class OldIOServer { public static void main(String[] args) throws Exception { try (ServerSocket serverSocket = new ServerSocket(8898)) { while (true) { Socket socket = serverSocket.accept(); DataInputStream inputStream = new DataInputStream(socket.getInputStream()); byte[] bytes = new byte[4096]; // 從socket中讀取位元組數據 while (true) { // 讀取的位元組數大小,-1則表示數據已被讀完 int readCount = inputStream.read(bytes, 0, bytes.length); if (-1 == readCount) { break; } } } } } }
2、構建傳統 IO 的客戶端,連接服務端的 8898 埠,並從磁碟讀取 237M 的數據文件向服務端 socket 中發起寫請求。
public class OldIOClient { public static void main(String[] args) throws Exception { Socket socket = new Socket(); socket.connect(new InetSocketAddress("localhost", 8898)); // 連接服務端socket 8899埠 // 設置一個大的文件, 237M try (FileInputStream fileInputStream = new FileInputStream(new File("/Users/david/Downloads/jdk-8u144-macosx-x64.dmg")); // 定義一個輸出流 DataOutputStream dataOutputStream = new DataOutputStream(socket.getOutputStream());) { // 讀取文件數據 // 定義byte快取 byte[] buffer = new byte[4096]; int readCount; // 每一次讀取的位元組數 int total = 0; // 讀取的總位元組數 long startTime = System.currentTimeMillis(); while ((readCount = fileInputStream.read(buffer)) > 0) { total += readCount; //累加位元組數 dataOutputStream.write(buffer); // 寫入到輸出流中 } System.out.println("發送的總位元組數:" + total + ", 耗時:" + (System.currentTimeMillis() - startTime)); } } }
運行結果:發送的總位元組數:237607747,耗時:450 (400~600毫秒之間) 接下來,我們通過使用 JDK 提供的 NIO 的方式實現數據傳輸與上述傳統 IO 做對比。
1、構建基於 NIO 的服務端,監聽 8899 埠。
public class NewIOServer { public static void main(String[] args) throws Exception { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(8899)); ByteBuffer byteBuffer = ByteBuffer.allocate(4096); while (true) { SocketChannel socketChannel = serverSocketChannel.accept(); socketChannel.configureBlocking(false); // 這裡設置為阻塞模式 int readCount = socketChannel.read(byteBuffer); while (-1 != readCount) { readCount = socketChannel.read(byteBuffer); // 這裡一定要調用下rewind方法,將position重置為0開始位置 byteBuffer.rewind(); } } } }
2、構建基於 NIO 的客戶端,連接NIO的服務端 8899 埠,通過
FileChannel.transferTo 傳輸 237M 的數據文件。
public class NewIOClient { public static void main(String[] args) throws Exception { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress("localhost", 8899)); socketChannel.configureBlocking(true); String fileName = "/Users/david/Downloads/jdk-8u144-macosx-x64.dmg"; FileInputStream fileInputStream = new FileInputStream(fileName); FileChannel fileChannel = fileInputStream.getChannel(); long startTime = System.currentTimeMillis(); long transferCount = fileChannel.transferTo(0, fileChannel.size(), socketChannel); // 目標channel System.out.println("發送的總位元組數:" + transferCount + ",耗時:" + (System.currentTimeMillis() - startTime)); fileChannel.close(); } }
運行結果:發送的總位元組數:237607747,耗時:161(100到300毫秒之間) 結合運行結果,基於 NIO 零拷貝技術要比傳統 IO 傳輸效率高 3倍多。所以,後續當設計大文件數據傳輸時可以優先採用類似 NIO 的方式實現。
這裡我們使用了 FileChannel,其中調用的 transferTo() 方法將數據從 FileChannel傳輸到其他的 channel 中,如果作業系統底層支援的話 transferTo、transferFrom 會使用相關的零拷貝技術來實現數據的傳輸。所以,這裡是否使用零拷貝必須依賴於底層的系統實現。
FileChannel.transferTo 方法:
public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException

將位元組從此通道的文件傳輸到給定的可寫入位元組通道。
試圖讀取從此通道的文件中給定 position 處開始的 count 個位元組,並將其寫入目標通道。
此方法的調用不一定傳輸所有請求的位元組;
是否傳輸取決於通道的性質和狀態。
如果此通道的文件從給定的 position 處開始所包含的位元組數小於 count 個位元組,或者如果目標通道是非阻塞的並且其輸出緩衝區中的自由空間少於 count 個位元組,則所傳輸的位元組數要小於請求的位元組數。
此方法不修改此通道的位置。
如果給定的位置大於該文件的當前大小,則不傳輸任何位元組。
如果目標通道中有該位置,則從該位置開始寫入各位元組,然後將該位置增加寫入的位元組數。
與從此通道讀取並將內容寫入目標通道的簡單循環語句相比,此方法可能高效得多。
很多作業系統可將位元組直接從文件系統快取傳輸到目標通道,而無需實際複製各位元組。
參數:
position – 文件中的位置,從此位置開始傳輸;
必須為非負數
count – 要傳輸的最大位元組數;
必須為非負數
target – 目標通道
返回:實際已傳輸的位元組數,可能為零
FileChannel.transferFrom 方法:
public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException

將位元組從給定的可讀取位元組通道傳輸到此通道的文件中。
試著從源通道中最多讀取 count 個位元組,並將其寫入到此通道的文件中從給定 position 處開始的位置。
此方法的調用不一定傳輸所有請求的位元組;
是否傳輸取決於通道的性質和狀態。
如果源通道的剩餘空間小於 count 個位元組,或者如果源通道是非阻塞的並且其輸入緩衝區中直接可用的空間小於 count 個位元組,則所傳輸的位元組數要小於請求的位元組數。
此方法不修改此通道的位置。
如果給定的位置大於該文件的當前大小,則不傳輸任何位元組。
如果該位置在源通道中,則從該位置開始讀取各位元組,然後將該位置增加讀取的位元組數。
與從源通道讀
取並將內容寫入此通道的簡單循環語句相比,此方法可能高效得多。
很多作業系統可將位元組直接從源通道傳輸到文件系統快取,而無需實際複製各位元組。
參數:
src – 源通道
position – 文件中的位置,從此位置開始傳輸;
必須為非負數
count – 要傳輸的最大位元組數;
必須為非負數
返回:實際已傳輸的位元組數,可能為零
發生相應的異常的情況:

異常拋出:
IllegalArgumentException – 如果關於參數的前提不成立 NonReadableChannelException – 如果不允許從此通道進行讀取操作 NonWritableChannelException – 如果目標通道不允許進行寫入操作 ClosedChannelException – 如果此通道或目標通道已關閉 AsynchronousCloseException – 如果正在進行傳輸時另一個執行緒關閉了任一通道 ClosedByInterruptException – 如果正在進行傳輸時另一個執行緒中斷了當前執行緒,因此關閉了兩個通道並將當前執行緒設置為中斷 IOException – 如果發生其他 I/O 錯誤
參考資料: http://xcorpion.tech/2016/09/10/It-s-all-about-buffers-zero-copy-mmap-and-Java-NIO/ http://www.jianshu.com/p/e76e3580e356 http://www.linuxjournal.com/node/6345 http://senlinzhan.github.io/2017/03/25/%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B%E4%B8%AD%E7%9A%84zerocpoy%E6%8A%80%E6%9C%AF/