1. 程式人生 > 其它 >☕【Java深層系列】「技術盲區」讓我們一起探索一下Netty(Java)底層的“零拷貝Zero-Copy”技術(上)

☕【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

極限就是為了超越而存在的