【Java】Java中的零拷貝

物理記憶體

電腦物理記憶體條的容量,比如我們買電腦會關注記憶體大小有多少G,這個容量就是電腦的物理記憶體。

虛擬記憶體

作業系統為每個進程分配了獨立的虛擬地址空間,也就是虛擬記憶體,虛擬地址空間又分為用戶空間和內核空間,作業系統的位數不同,虛擬地址空間的大小也不同,32位作業系統虛擬地址內核空間為1G,用戶空間大小為3G,64位作業系統用戶空間和內核空間大小各為128T:

既然每個進程都擁有一塊獨立的虛擬地址空間,那麼所有進程的虛擬地址空間大小加起來必定大於物理記憶體的大小,所以虛擬地址空間只是一個虛擬的概念,只有需要分配記憶體的時候才會為虛擬記憶體分配物理記憶體,並通過記憶體映射來管理虛擬地址和物理記憶體地址之間的映射關係。

用戶空間 / 內核空間

用戶空間:是運行用戶程式程式碼的地方,為了保證系統內核的安全,它不能直接訪問記憶體等硬體設備,必須通過系統調用進入到內核空間來訪問那些受限的資源。

內核空間:是運行內核程式碼的地方,可以執行任意的指令訪問系統資源,既可以訪問內核空間也可以訪問用戶空間。

用戶態:進程運行在用戶空間時處於用戶態。

內核態:進程運行在內核空間時處於內核態。

文件I/O

文件I/O與讀寫文件有關,比如我們啟動了一個程式,此時運行在用戶空間(用戶態),接著準備做一個讀取磁碟文件的操作,由於用戶空間是無法直接從磁碟讀取文件的,所以需要調用內核提供的介面來完成文件的讀取,調用內核的介面的過程中由用戶空間進入到了內核空間(內核態),DMA從磁碟讀取文件到內核的緩衝區,之後再將數據從內核的緩衝區拷貝到用戶空間完成文件的讀取操作:

  1. 應用程式調用read函數發起系統調用,此時由用戶空間切換到內核空間;
  2. 內核通過DMA從磁碟拷貝數據到內核緩衝區(DMA複製);
  3. 將內核緩衝區的數據拷貝到用戶空間的緩衝區(CPU複製),切換回用戶空間;

可以發現,整個讀取過程發生了兩次數據拷貝,一次是DMA將磁碟上的文件數據拷貝到內核緩衝區,一次是將內核緩衝區的數據拷貝到用戶緩衝區。寫操作與讀取操作類似,只不過是將用戶緩衝區的數據拷貝到內核緩衝區,再將內核緩衝區的數據拷貝到文件。

文件I/O從作業系統的角度來看還可以劃分為快取I/O、直接I/O和mmap記憶體映射。

快取I/O

也稱標準I/O,上面提到的文件I/O讀取數據的例子就是使用的快取I/O,它需要將數據先拷貝到內核緩衝區,再將內核緩衝區的數據拷貝到用戶緩衝區,數據經過兩次拷貝,內核緩衝區和用戶緩衝區分別指向不同的物理記憶體,在文件I/O中,內核緩衝區是在Page Cache層,這也是稱為快取I/O的原因

JAVA中通過java.io包下進行讀寫文件使用的就是快取I/O。

為什麼需要快取IO?

因為磁碟I/O是比較耗時的操作,如果每次都從磁碟上讀取文件,性能將會大大下降,為了提升讀取性能,增加了一層Page Cache,用於快取讀取的文件數據,Page Cache佔用的是記憶體,從記憶體讀取的速度遠遠大於從磁碟讀取,內核緩衝區就是在Page Cache中開闢的一塊記憶體,用戶空間進行系統調用讀取文件內容時,首先會判斷Page Cache中是否快取了文件的內容,如果快取了直接讀取即可,否則再從磁碟讀取,所以快取I/O可以減少磁碟I/O的次數提升性能。

文件的寫操作同樣如此,進行寫操作時,將數據先寫到Page Cache的緩衝區中,後續由作業系統將數據刷回到磁碟中。

快取I/O的優缺點

優點:減少磁碟I/O次數,提升讀寫性能。

缺點:數據需要在內核空間和用戶空間來回拷貝。

DirectByteBuffer

使用快取I/O讀取數據時,數據會經過兩次拷貝,經過兩次拷貝是從系統調用開始講起,在JAVA中由於涉及到JVM堆內和堆外記憶體,如果使用java.io下的類進行文件讀寫實際上還會再多一次拷貝(詳細可參考【JAVA】普通IO數據拷貝次數的問題探討 ):

  1. 底層發起JNI調用,創建堆外緩衝區;
  2. JNI中發起read系統調用,此時需要由用戶空間切換到內核空間;
  3. 進入到內核空間,DMA讀取文件數據到內核緩衝區(DMA拷貝);
  4. 將內核緩衝區的數據拷貝到用戶緩衝區(CPU拷貝),切換回用戶空間;
  5. 將堆外緩衝區的數據拷貝到JVM堆內緩衝區中(CPU拷貝);

img

在Java的NIO中,提供了DirectByteBuffer,可以直接分配堆外記憶體,減少了一次從堆外記憶體到堆內記憶體的複製(CPU複製)

直接I/O

快取I/O經過了Page Cache,讀取過程中需要將數據從Page Cache的緩衝區中拷貝到用戶空間的快取區,那麼有沒有一種方式可以省去這個拷貝的過程?

答案是有的,那就是直接I/O,應用程式直接訪問磁碟數據,繞過了Page Cache,省去了從內核緩衝區拷貝到用戶緩衝區的過程:

目前JAVA並沒有原生的直接/O操作方式,不過公眾號部落客Kirito提供了在JAVA中進行直接I/O操作的方法,具體參見【Kirito的技術分享】Java 文件 IO 操作之 DirectIO

記憶體映射

記憶體映射就是將虛擬空間地址映射到物理空間地址,每個進程維護了一張頁表,記錄虛擬地址和物理地址之間的映射關係,當進程訪問的虛擬地址在頁表中無法查到映射關係時,系統產生缺頁異常,進入內核空間為虛擬地址分配物理記憶體,並更新頁表,記錄映射關係。

文件映射

記憶體映射除了映射虛擬空間地址和物理空間地址,還包括將磁碟的文件內容映射到虛擬地址空間,稱為文件映射,此時可以通過訪問記憶體來訪問文件裡面的數據 。

mmap系統調用可以將文件映射到虛擬記憶體空間。文件映射的流程如下:

  1. 進行mmap系統調用,將文件和虛擬地址空間建立映射,注意此時還沒有分配物理記憶體空間,只是在邏輯上建立了虛擬地址和文件之間的映射關係,物理記憶體只有真正使用的時候才會分配。
  2. 應用程式訪問用戶空間虛擬記憶體中的某個地址,發現無法在頁表中查到數據,產生缺頁異常,此時進入內核空間
  3. 因為不能直接使用物理地址,所以需要使用內核的虛擬地址臨時建立與物理記憶體的映射關係,將文件內容讀取到物理記憶體中,待數據讀取完畢之後取消臨時映射即可。
  4. 缺頁異常處理完畢,物理記憶體中已經載入了文件的數據,此時用戶空間就可以通過虛擬地址直接訪問物理記憶體中映射的文件數據。

img

從文件映射的流程中可以看出它與快取I/O相比,少了從內核緩衝區將數據拷貝到用戶緩衝區的步驟,減少了一次拷貝。

Java NIO中提供了MappedByteBuffer來處理文件映射,下面是一個讀取文件的例子:

public class MappedByteBufferTest {

    public static void main(String[] args) {
        
        try (RandomAccessFile file = new RandomAccessFile(new File("/Users/sml/test.txt"), "r")) {
            // 獲取FileChannel
            FileChannel fileChannel = file.getChannel();
            long size = fileChannel.size();
            // 調用map方法進行文件映射,返回MappedByteBuffer
            MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, size);
            byte[] bytes = new byte[(int)size];
            for (int i = 0; i < size; i++) {
                // 讀取數據
                bytes[i] = mappedByteBuffer.get();
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

    }
}

零拷貝

零拷貝一般指的是從磁碟讀取文件發送到網路或者從網路接收數據寫入到磁碟文件的過程中,減少數據的拷貝次數。

網路I/O

網路I/O與網路數據發送/接收有關,與文件I/O的底層原理一致,同樣以讀取數據為例,文件I/O是從磁碟讀取文件,網路I/O是從網卡中讀取數據。比如客戶端與服務端建立了一個連接,客戶端向服務端發送數據,服務端從網卡中讀取客戶端發送的數據到內核中的socket緩衝區,再將socket緩衝區的數據複製到用戶空間的緩衝區:

使用快取I/O發送數據到網路

首先看一下使用快取I/O從磁碟文件讀取數據並發送到網路上的過程:

  1. 用戶發起系統調用,進入到內核態,DMA從磁碟上讀取數據到內核緩衝區(DMA複製);
  2. CPU將內核緩衝區的數據拷貝到用戶緩衝區(CPU複製),切換回到用戶空間;
  3. 再次從用戶空間切換到內核空間,CPU將用戶緩衝區的數據拷貝到socket緩衝區(CPU複製);
  4. DMA將socket緩衝區的數據拷貝到網卡(DMA複製),之後從內核空間切換回用戶空間;

使用快取I/O數據經過了四次拷貝,需要多次在內核空間和用戶空間來回切換,影響系統性能。從數據拷貝的過程可以看到有些步驟其實是多餘的,比如第二步,如果可以直接將內核快取區的數據拷貝到socket緩衝區,或者直接將內核緩衝區的數據拷貝到網卡,豈不是減少了數據拷貝的次數?零拷貝就是這樣一種致力於減少數據拷貝的技術。

Linux中的零拷貝

sendfile

Linux在2.1版本中引入了sendfile函數,可以實現將數據從一個文件描述符傳輸到另外一個文件描述符:

  1. 發起sendfile系統調用,進入到內核空間;
  2. DMA從磁碟讀取文件到內核緩衝區(DMA複製);
  3. 將內核緩衝區數據拷貝到socket緩衝區(CPU複製);
  4. 將socket緩衝區數據拷貝到網卡(DMA複製),之後切換回用戶空間;

sendfile減少了一次數據從內核緩衝區拷貝到用戶緩衝區的過程,可以直接將內核緩衝區的數據拷貝到socket緩衝區。

sendfile + DMA GATHER

Linux在2.4版本中引入了gather技術,我們知道內核緩衝區在記憶體中有對應的地址,gather操作可以將內核緩衝區的記憶體地址、地址偏移量資訊記錄到socket緩衝區中,之後DMA根據地址資訊從記憶體中讀取數據到網卡中,減少了數據從內核緩衝區到socket緩衝區的拷貝過程:

可以看到零拷貝並不是指的數據一次拷貝都沒有發生,而是指減少CPU進行數據拷貝的次數。

Java中的零拷貝

MappedByteBuffer

在記憶體映射中說過,可以通過文件映射的方式將磁碟的文件內容映射到虛擬地址空間,用戶空間就可以通過虛擬地址直接訪問物理記憶體中的映射的文件數據,而Java NIO中也提供了MappedByteBuffer來處理文件映射,使用MappedByteBuffer向網路中發送數據的過程如下:

  1. 使用MappedByteBuffer建立文件映射,用戶空間可以通過虛擬地址直接訪問映射的文件數據;

  2. 將映射的文件數據拷貝到socket網路緩衝區(CPU複製);

  3. DMA將socket緩衝區的數據拷貝到網卡(DMA複製);

MappedByteBuffer減少了從內核緩衝區到用戶緩衝區的數據拷貝,可以直接將內核緩衝區的數據拷貝到網路緩衝區。

FileChannel

Java NIO中的FileChannel可以實現將數據從FileChannel直接傳輸到另一個Channel,它是sendfile的一種實現:

            RandomAccessFile file = new RandomAccessFile(new File("/Users/sml/test.txt"), "r");
            // 獲取FileChannel
            FileChannel fileChannel = file.getChannel();
            long size = fileChannel.size();
            SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("localhost", 8080));
            fileChannel.transferTo(0,size,socketChannel);

參考

【極客時間-倪朋飛】Linux性能優化實戰

【極客時間-劉超】趣談Linux作業系統

【拉勾教育-若地】Netty 核心原理剖析與 RPC 實踐

【 Kirito的技術分享】文件IO操作的最佳實踐

【小碼農叔叔】java使用nio讀寫文件

【佔小狼】深入淺出MappedByteBuffer

【零壹技術棧】深入剖析Linux IO原理和幾種零拷貝機制的實現

【tomas家的小撥浪鼓】堆外記憶體 之 DirectByteBuffer 詳解

網路IO和磁碟IO詳解

Tags: