☕【Java深層系列】「技術盲區」讓我們一起探索一下Netty(Java)底層的“零拷貝Zero-Copy”技術(上)
Netty的零拷貝
Netty中的零拷貝與我們傳統理解的零拷貝不太一樣。
傳統的零拷貝指的是資料傳輸過程中,不需要CPU進行資料的拷貝。主要是資料在使用者空間與核心中間之間的拷貝。
傳統意義的零拷貝
Zero-Copy describes computer operations in which the CPU does not perform the task of copying data from one memory area to another.
在傳送資料的時候,傳統的實現方式是:
File.read(bytes)
Socket.send(bytes)
這種方式需要四次資料拷貝和四次上下文切換:
- 資料從磁碟讀取到核心的read buffer
- 資料從核心緩衝區拷貝到使用者緩衝區
- 資料從使用者緩衝區拷貝到核心的socket buffer
- 資料從核心的socket buffer拷貝到網絡卡介面的緩衝區
明顯上面的第二步和第三步是沒有必要的,通過java的FileChannel.transferTo方法,可以避免上面兩次多餘的拷貝(當然這需要底層作業系統支援)。
- 呼叫transferTo,資料從檔案由DMA引擎拷貝到核心read buffer
- 接著DMA從核心read buffer將資料拷貝到網絡卡介面buffer
上面的兩次操作都不需要CPU參與,所以就達到了零拷貝。
Netty中的零拷貝
Netty中也用到了FileChannel.transferTo方法,所以Netty的零拷貝也包括上面將的作業系統級別的零拷貝,除此之外,在ByteBuf的實現上,Netty也提供了零拷貝的一些實現。
關於ByteBuffer,Netty提供了兩個介面:
- ByteBuf
- ByteBufHolder
對於ByteBuf,Netty提供了多種實現:
- Heap ByteBuf:直接在堆記憶體分配
- Direct ByteBuf:直接在記憶體區域分配而不是堆記憶體
- CompositeByteBuf:組合Buffer
Direct Buffers(直接記憶體)
直接在記憶體區域分配空間,而不是在堆記憶體中分配。
-
如果使用傳統的堆記憶體分配,當我們需要將資料通過socket傳送的時候,就需要從堆記憶體拷貝到直接記憶體,然後再由直接記憶體拷貝到網絡卡介面層。
-
Netty提供的直接Buffer,直接將資料分配到記憶體空間,從而避免了資料的拷貝,實現了零拷貝。
堆外記憶體
如果在JVM 內部執行 I/O 操作時,必須將資料拷貝到堆外記憶體,才能執行系統呼叫。
VM語言都會存在的問題,那麼為什麼作業系統不能直接使用JVM堆記憶體進行 I/O 的讀寫呢?
主要有兩點原因:
-
作業系統並不感知JVM 的堆記憶體,而且 JVM 的記憶體佈局與作業系統所分配的是不一樣的,作業系統並不會按照 JVM 的行為來讀寫資料。
-
同一個物件的記憶體地址隨著 JVM GC 的執行可能會隨時發生變化,例如 JVM GC 的過程中會通過壓縮來減少記憶體碎片,這就涉及物件移動的問題了。
Netty 在進行 I/O 操作時都是使用的堆外記憶體,可以避免資料從 JVM 堆記憶體到堆外記憶體的拷貝。
-
JDK 告訴我們,NIO操作並不適合直接在堆上操作。由於 heap 受到 GC 的直接管理,在 IO 寫入的過程中 GC 可能會進行記憶體空間整理,這導致了一次 IO 寫入的記憶體地址不完整。
-
JNI(Java Native Inteface)在呼叫 IO 操作的 C 類庫時,規定了寫入時地址不能失效,這就導致了不能在 heap 上直接進行 IO 操作。在 IO 操作的時候禁止 GC 也是一個選項,如果 IO 時間過長,那麼則可能會引起堆空間溢位。
Composite Buffers
傳統的ByteBuffer,如果需要將兩個ByteBuffer中的資料組合到一起,我們需要首先建立一個size=size1+size2大小的新的陣列,然後將兩個陣列中的資料拷貝到新的陣列中。但是使用Netty提供的組合ByteBuf,就可以避免這樣的操作,因為CompositeByteBuf並沒有真正將多個Buffer組合起來,而是儲存了它們的引用,從而避免了資料的拷貝,實現了零拷貝。
FileChannel.transferTo的使用
Netty中使用了FileChannel的transferTo方法,該方法依賴於作業系統實現零拷貝。
總結
Netty的零拷貝體現在三個方面:
-
Netty的接收和傳送ByteBuffer採用DIRECT BUFFERS,使用堆外直接記憶體進行Socket讀寫,不需要進行位元組緩衝區的二次拷貝。
- 如果使用傳統的堆記憶體(HEAP BUFFERS)進行Socket讀寫,JVM會將堆記憶體Buffer拷貝一份到直接記憶體中,然後才寫入Socket中。相比於堆外直接記憶體,訊息在傳送過程中多了一次緩衝區的記憶體拷貝。
-
Netty提供了組合Buffer物件,可以聚合多個ByteBuffer物件,使用者可以像操作一個Buffer那樣方便的對組合Buffer進行操作,避免了傳統通過記憶體拷貝的方式將幾個小Buffer合併成一個大的Buffer。
-
Netty的檔案傳輸採用了transferTo方法,它可以直接將檔案緩衝區的資料傳送到目標Channel,避免了傳統通過迴圈write方式導致的記憶體拷貝問題。
關於堆外記憶體的回收
堆外記憶體的回收其實依賴於我們的GC機制
-
首先,我們要知道在java層面和我們在堆外分配的這塊記憶體關聯的只有與之關聯的DirectByteBuffer物件了,它記錄了這塊記憶體的基地址以及大小,那麼既然和GC也有關,那就是GC能通過操作DirectByteBuffer物件來間接操作對應的堆外記憶體了。
-
DirectByteBuffer物件在建立的時候關聯了一個PhantomReference,說到PhantomReference其實主要是用來跟蹤物件何時被回收的,它不能影響GC決策。
-
GC過程中如果發現某個物件除了只有PhantomReference引用它之外,並沒有其他的地方引用它了,那將會把這個引用放到java.lang.ref.Reference.pending佇列裡,在GC完畢的時候通知ReferenceHandler這個守護執行緒去執行一些後置處理,而DirectByteBuffer關聯的PhantomReference是PhantomReference的一個子類,在最終的處理裡會通過Unsafe的free介面來釋放DirectByteBuffer對應的堆外記憶體塊。
為什麼要主動呼叫System.gc
System.gc()會對新生代的老生代都會進行記憶體回收,這樣會比較徹底地回收,DirectByteBuffer物件以及他們關聯的堆外記憶體.
DirectByteBuffer物件本身其實是很小的,但是它後面可能關聯了一個非常大的堆外記憶體,因此我們通常稱之為冰山物件。
做ygc的時候會將新生代裡的不可達的DirectByteBuffer物件及其堆外記憶體回收了,但是無法對old裡的DirectByteBuffer物件及其堆外記憶體進行回收,這也是我們通常碰到的最大的問題.
如果有大量的DirectByteBuffer物件移到了old,但是又一直沒有做cms gc或者full gc,而只進行ygc,那麼我們的實體記憶體可能被慢慢耗光,但是我們還不知道發生了什麼,因為heap明明剩餘的記憶體還很多。