☕【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方法,可以避免上面兩次多餘的拷貝(當然這需要底層操作系統支持)。

  1. 調用transferTo,數據從文件由DMA引擎拷貝到內核read buffer
  2. 接着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 的讀寫呢?

主要有兩點原因:
  1. 操作系統並不感知JVM 的堆內存,而且 JVM 的內存佈局與操作系統所分配的是不一樣的,操作系統並不會按照 JVM 的行爲來讀寫數據。

  2. 同一個對象的內存地址隨着 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的零拷貝體現在三個方面:

  1. Netty的接收和發送ByteBuffer採用DIRECT BUFFERS,使用堆外直接內存進行Socket讀寫,不需要進行字節緩衝區的二次拷貝。

    • 如果使用傳統的堆內存(HEAP BUFFERS)進行Socket讀寫,JVM會將堆內存Buffer拷貝一份到直接內存中,然後才寫入Socket中。相比於堆外直接內存,消息在發送過程中多了一次緩衝區的內存拷貝。
  2. Netty提供了組合Buffer對象,可以聚合多個ByteBuffer對象,用戶可以像操作一個Buffer那樣方便的對組合Buffer進行操作,避免了傳統通過內存拷貝的方式將幾個小Buffer合併成一個大的Buffer。

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

資源學習

https://www.jianshu.com/p/61a7916b37fd

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章