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明明剩餘的內存還很多。