如此淺顯易懂的零拷貝

Howlet發表於2020-12-11


最近老是能遇到零拷貝的問題,對於作業系統這塊總時很怕,現在抽出時間來攻關






1. 直接記憶體

先鋪墊一些必要的知識點,然後再由淺入深地去認識零拷貝


1.1 什麼是直接記憶體

直接記憶體(Direct Memory)並不是虛擬機器執行時資料區的一部分,也不是《Java虛擬機器規範》中定義的記憶體區域。但是這部分記憶體也被頻繁地使用,而且也可能導致OutOfMemoryError異常出現 ——《深入理解Java虛擬機器》


顯然,從上面得知本機直接記憶體的分配不會受到Java堆大小的限制,但這裡要注意直接記憶體也是實體記憶體的一部分,也受到真實記憶體的限制,所以當直接記憶體佔用過多時,使Java堆分配不到足夠的記憶體空間也就丟擲OOM異常了


因此我們要限制直接記憶體的大小 -XX:MaxDirectMemorySize,當達到指定大小後會觸發 Full GC





1.2 如何使用直接記憶體

在 JDK1.4 中新加入了NIO,引入了一種基於通道(Channel)與緩衝區(Buffer)的I/O方式,它可以使用Native函式直接分配堆外記憶體,然後通過一個儲存在Java堆裡面的 DirectByteBuffer 物件作為這塊記憶體的引用進行操作。這樣能在一些場景中顯著提高效能,因為避免了在Java堆和Native堆中來回複製資料 ——《深入理解Java虛擬機器》


我們可用 NIO 中的緩衝區(Buffer)來使用直接記憶體,不幸的是直接記憶體只支援 Byte 型別的緩衝區,所以我們只能使用 ByteBuffer型別的緩衝區了


其中:

ByteBuffer 是普通的緩衝區,還是在Java堆中分配空間的

DirectByteBuffer 才是我們所找的,其父類是 MappedByteBuffer(記憶體對映),再父類才是 ByteBuffer,是在直接記憶體分配空間


建立方法

// allocateDirect()本質是 return new DirectByteBuffer(capacity);
// 因 DirectByteBuffer 的構造方法是私有的才需這樣返回
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);




1.3 為什麼使用直接記憶體

使用直接記憶體是為了提高讀寫效能,適用於經常讀寫的場景,下面用例子說明:寫入讀出1000個整數,進行10萬次


1.3.1 不使用直接記憶體

final int EVERY_TIME_COUNT = 1000;
final int TOTAL_TIME_COUNT = 100000;
long starTime = System.currentTimeMillis();

// 1000個整數佔用32000個byte
ByteBuffer byteBuffer = ByteBuffer.allocate(32000);

for (int i = 0; i < TOTAL_TIME_COUNT; i++) {
    for (int j = 1; j < EVERY_TIME_COUNT; j++) {
        byteBuffer.putInt(j);
    }
    byteBuffer.flip();
    for (int j = 1; j < EVERY_TIME_COUNT; j++) {
        byteBuffer.get();
    }
    byteBuffer.clear();
}

System.out.println("不用直接記憶體的毫秒是:" + (System.currentTimeMillis() - starTime));
-----------------------------------------------------------------------------------------------
// 250ms

1.3.2 使用直接記憶體

final int EVERY_TIME_COUNT = 1000;
final int TOTAL_TIME_COUNT = 100000;
long starTime = System.currentTimeMillis();

// 1000個整數佔用32000個byte
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(32000);

for (int i = 0; i < TOTAL_TIME_COUNT; i++) {
    for (int j = 1; j < EVERY_TIME_COUNT; j++) {
        byteBuffer.putInt(j);
    }
    byteBuffer.flip();
    for (int j = 1; j < EVERY_TIME_COUNT; j++) {
        byteBuffer.get();
    }
    byteBuffer.clear();
}

System.out.println("直接記憶體的毫秒是:" + (System.currentTimeMillis() - starTime));
-----------------------------------------------------------------------------------------------
// 160ms

1.3.3 建立銷燬的效能

建立銷燬直接記憶體的消耗遠遠大於普通的ByteBuffer

final int TOTAL_COUNT = 100000;
long starTime = System.currentTimeMillis();

for (int i = 0; i < TOTAL_COUNT; i++) {
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
}

System.out.println("不用直接記憶體的毫秒是:" + (System.currentTimeMillis() - starTime));
-----------------------------------------------------------------------------------------------
// 38ms
final int TOTAL_COUNT = 100000;
long starTime = System.currentTimeMillis();

for (int i = 0; i < TOTAL_COUNT; i++) {
    ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024);
}
System.out.println("不用直接記憶體的毫秒是:" + (System.currentTimeMillis() - starTime));
-----------------------------------------------------------------------------------------------
// 145ms

1.3.4 綜上使用總結

缺點:

  • 建立銷燬 DirectByteBuffer 更消耗效能
  • 記憶體洩漏排查難度大了
  • 只能用byte[] 陣列來接收

優點:

  • 避免堆與直接記憶體的來回複製提高效能
  • 可以做JVM程式間共享




2. 零拷貝

為什麼使用直接記憶體就能如此提高讀寫效能?因為使用了零拷貝技術。心心念的零拷貝終於出現了,還是老話,先進行鋪墊,然後才輪到零拷貝的內容(筆者最怕作業系統,所以這方面的知識講得會細一些)


2.1 記憶體

硬碟與CPU的速度差距太大,當CPU處理完資料,硬碟還沒把資料準備好,使CPU處於空閒狀態,嚴重拉低了CPU的處理效能。那麼我們在二者中間增加了記憶體(速度介於硬碟和CPU之間)來進行平衡,這樣我們就得先將資料從硬碟寫入記憶體,然後CPU才去記憶體讀取資料





2.2 核心態、使用者態

核心態:可執行任何指令,訪問所有暫存器和儲存區
使用者態:只能執行和訪問特定的指令和暫存器

區分核心態,使用者態主要是為了系統安全,因為有些指令會危害系統(清記憶體,設定時鐘),這些指令只允許作業系統及其相關模組使用。當使用者需要使用這些功能時,呼叫核心提供的API,陷入核心(即切換成核心態),讓核心去執行


舉例32位的作業系統,最大支援4G大小的執行緒,其中的3G大小是使用者使用的(用來執行我們寫的普通程式碼),剩下的1G分配給了核心(而且這分配的1G大小是共享的,存放了核心的程式碼和核心的功能模組)。當執行自己寫的程式碼時則使用分配的3G空間,當呼叫核心API時(比如檔案操作,網路傳輸等),切換成核心態(轉去共享的核心空間,去執行核心程式碼),就是去使用所分配的1G空間


使用者態切換核心態時,因為二者屬於不同的記憶體空間,那麼對執行緒的大量上下文環境進行儲存

核心態切換使用者態時,也要對這些上下文環境進行還原,這是一個十分耗能的操作





2.3 核心、使用者、硬碟緩衝區

這些緩衝區有什麼作用呢?我們使用ByteBuffer時都要先開闢一塊空間,目的是為了不用一次又一次的傳輸4個位元組(32位系統),而是填滿一個緩衝區大小,然後再一次性傳輸資料。否則使用者讀取硬碟檔案,一次就4個位元組,那得讀取多少次,進行多少次使用者態、核心態的切換





2.4 傳統的資料互動

舉例Web伺服器下的傳統資料互動,資料從硬碟到網路卡

讀資料過程:

  • Web伺服器要讀取硬碟資料(資料庫操作),呼叫read()從使用者態切換到核心態
  • CPU將資料從硬碟緩衝區拷貝到核心緩衝區
  • CPU將資料從核心緩衝區拷貝到伺服器緩衝區
  • CPU完成拷貝,read()返回從核心態切換回使用者態

寫資料過程:

  • Web伺服器要往網路卡寫資料(response響應),呼叫write()從使用者態切換到核心態
  • CPU將資料從伺服器緩衝區拷貝到套接字緩衝區
  • CPU將資料從套接字緩衝區拷貝到網路卡緩衝區
  • CPU完成拷貝,write()返回從核心態切換回使用者態

綜上:

  • 讀過程有2次狀態切換、2次CPU拷貝
  • 寫過程有2次狀態切換、2次CPU拷貝

在傳統模式下,CPU負責了4次的資料拷貝,浪費了CPU的時間片,那麼就出現了下面的DMA





2.5 DMA

直接記憶體訪問,是一種硬體裝置可繞開CPU獨立直接訪問記憶體。所以有了DMA在一定程度上解放了CPU,把之前CPU的雜活讓硬體直接自己做了,提高了CPU效率,增添了DMA的流程圖就變成下面這樣:

綜上:

  • 讀過程有2次狀態切換、1次DMA拷貝、1次CPU拷貝
  • 寫過程有2次狀態切換、1次DMA拷貝、1次CPU拷貝

增添了DMA硬體,總計還是有4次狀態切換,2次的CPU拷貝,有優化空間,那麼此時零拷貝就出現了





2.6 零拷貝

零拷貝可以減少CPU拷貝狀態切換的次數,這樣顯然可以提高效能


其實現方式有:(順便提一下NIO的直接記憶體使用的是mmap方式)

  • mmap + write
  • sendFile
  • sendFile + DMA收集
  • splice




2.6.1 mmap

mmap是Linux提供的一種記憶體對映檔案機制,可以將核心緩衝區和使用者緩衝區的部分空間實現共享,這樣可以減少一次使用者態與核心態的CPU拷貝(總計4次狀態切換,2次DMA拷貝,1次CPU拷貝)





2.6.2 sendFile

sendFile建立了兩個檔案間的傳輸通道,一個函式完成mmap+write的功能,可以減少兩次狀態切換。(總計兩次狀態切換,2次DMA拷貝,1次CPU拷貝)





2.6.3 sendFile + DMA收集

sendFile將核心空間緩衝區中資料的描述資訊拷貝到套接字緩衝區中

DMA控制器根據套接字緩衝區的描述資訊,將資料從核心緩衝區直接拷貝到網路卡

可以減少一次CPU拷貝(總計2次狀態切換,2次DMA拷貝,0次CPU拷貝)





2.6.4 splice

在核心緩衝區和套接字緩衝區之間建立管道來傳輸資料,免去了CPU拷貝(總計兩次狀態切換,2次DMA拷貝,0次CPU拷貝)





3. 總結

在網路應用中經常涉及到讀寫過程,而讀寫過程又要多次狀態切換和CPU拷貝,這是一個十分耗能的操作

為了提高讀寫效能,零拷貝技術出現了,其減少了狀態切換的次數,與避免了CPU拷貝,大大提高了讀寫效能

在Netty這樣高效能網路通訊框架中,也是經常讀寫的,所以其底層也涉及到了零拷貝技術


缺點:

讀過程中要將資料拷貝到使用者緩衝區我們才能進行修改的,而缺失這一環(直接拷貝到套接字緩衝區或網路卡)那麼我們就不能對資料進行修改





參考

《作業系統》

《深入理解Java虛擬機器》

後端技術指南針


相關文章