傳統的文件讀寫
傳統的文件讀寫或者網絡傳輸,通常需要將數據從內核態轉換為用戶態。應用程序讀取用戶態內存數據,寫入文件 / Socket之前,需要從用戶態轉換為內核態之后才可以寫入文件或者網卡當中。
數據首先從磁盤讀取到內核緩沖區,這里面的內核緩沖區就是頁緩存(PageCache)。然后從內核緩沖區中復制到應用程序緩沖區(用戶態),輸出到輸出設備時,又會將用戶態數據轉換為內核態數據。
DMA
在介紹零拷貝之前,我們先來看一個技術名詞DMA(Direct Memory Access 直接內存訪問)。它是現代電腦的重要特征之一,允許不同速度的硬件之間直接交互,而不需要占用CPU的中斷負載。DMA傳輸將一個地址空間復制到另一個地址空間,當CPU 初始化這個傳輸之后,實際的數據傳輸是有DMA設備之間完成,這樣可以大大的減少CPU的消耗。我們常見的硬件設備都支持DMA,如下圖所示:
零拷貝
對於常見的零拷貝,我們下面主要介紹一下mmap 和 sendfile 兩種方式。下面的介紹我們基於磁盤文件拷貝的方式去講解。
mmap
mmap 就是在用戶態直接引用文件句柄,也就是用戶態和內核態共享內核態的數據緩沖區,此時數據不需要復制到用戶態空間。當應用程序往 mmap 輸出數據時,此時就直接輸出到了內核態數據,如果此時輸出設備是磁盤的話,會直接寫盤(flush間隔是30秒)。
上面的圖片我們可以這樣去理解,比如我們需要從 src.data 文件復制數據到 dest.data 文件中。此時我們不需要更改 src.data 里面的數據,但是對於 dest.data 需要追加一些數據。此時src.data 里面的數據可以直接通過DMA 設備傳輸,而應用程序還需要對 dest.data 做一些數據追加,此時應用對 dest.data 做 mmap 映射,直接對內核態數據進行修改。
sendfile
對於sendfile 而言,數據不需要在應用程序做業務處理,僅僅是從一個 DMA 設備傳輸到另一個 DMA設備。 此時數據只需要復制到內核態,用戶態不需要復制數據,並且也不需要像 mmap 那樣對內核態的數據的句柄(文件引用)。如下圖所示:
從上圖我們可以發現(輸出設備可以是網卡/磁盤驅動),內核態有 2 份數據緩存 。sendfile 是 Linux 2.1 開始引入的,在 Linux 2.4 又做了一些優化。也就是上圖中磁盤頁緩存中的數據,不需要復制到 Socket 緩沖區,而只是將數據的位置和長度信息存儲到 Socket 緩沖區。實際數據是由DMA 設備直接發送給對應的協議引擎,從而又減少了一次數據復制。
零拷貝的Java實現
JDK 中的 FileChannel 提供了外部 channel 交互的傳輸方法。transferTo 方法會將當前 FileChannel 的字節直接傳輸到 channel 中,transferFrom() 方法可以將可讀 channel 的字節直接傳輸到當前 FileChannel 中。transferTo() 方法底層是基於操作系統的 sendfile 這個系統調用來實現的,map 是對 Channel 做 mmap 映射。
下面我們看一下 Java NIO 中的方法摘要:
// 將當前 FileChannel 的字節傳輸到給定的可寫 channel 中 public abstract long transferTo(long position, long count, WritableByteChannel target) throws IOException; // 將一個可讀 channel 的字節傳輸到當前 FileChannel中 public abstract long transferFrom(ReadableByteChannel src, long position, long count) throws IOException; // 對 Channel 做 mmap 映射 public abstract MappedByteBuffer map(MapMode mode, long position, long size) throws IOException;
文件拷貝測試對比
下面我們看一下執行下面3段代碼,並且 src.log 文件在不同大小的情況下的測試耗時結果。
1、傳統拷貝
public class OldFileCopy { public static final String source = "C:/data/src.log"; public static final String dest = "C:/data/dest.log"; public static void main(String[] args) { try { FileInputStream inputStream = new FileInputStream(source); FileOutputStream outputStream = new FileOutputStream(dest); long start = System.currentTimeMillis(); byte[] buff = new byte[4096]; long read = 0, total = 0; while ((read = inputStream.read(buff)) >= 0) { total += read; outputStream.write(buff); } outputStream.flush(); System.out.println("耗時:" + (System.currentTimeMillis() - start)); } catch (Exception e) { e.printStackTrace(); } } } 2、mmap 拷貝 public class MmapFileCopy { public static final String source = "C:/data/src.log"; public static final String dest = "C:/data/dest.log"; public static void main(String[] args) { try { FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel(); FileChannel destChannel = new RandomAccessFile(dest, "rw").getChannel(); long start = System.currentTimeMillis(); MappedByteBuffer map = destChannel.map(FileChannel.MapMode.READ_WRITE, 0, sourceChannel.size()); sourceChannel.write(map); map.flip(); System.out.println("耗時:" + (System.currentTimeMillis() - start)); } catch (Exception e) { e.printStackTrace(); } } }
3、sendfile 拷貝
public class SendFileCopy { public static final String source = "C:/data/src.log"; public static final String dest = "C:/data/dest.log"; public static void main(String[] args) { try { FileChannel sourceChannel = new RandomAccessFile(source, "rw").getChannel(); FileChannel destChannel = new RandomAccessFile(dest, "rw").getChannel(); long start = System.currentTimeMillis(); sourceChannel.transferTo(0, sourceChannel.size(), destChannel); System.out.println("耗時:" + (System.currentTimeMillis() - start)); } catch (Exception e) { e.printStackTrace(); } } }
通過對不同大小的文件進行對比測試,我們得到了下面的測試結果。
從上面測試結果可以看出,mmap 和 sendfile 的方式要遠遠優於傳統的文件拷貝。對於 mmap 和 sendfile 在文件較小的時候, mmap 耗時更短,當文件較大時 sendfile 的方式最優。
本文來自: