1. 程式人生 > >Netty精粹之玩轉NIO緩衝區

Netty精粹之玩轉NIO緩衝區

https://www.cnblogs.com/wxd0108/p/6681627.html

摘要: 在JAVA NIO相關的元件中,ByteBuffer是除了Selector、Channel之外的另一個很重要的元件,它是直接和Channel打交道的緩衝區,通常場景或是從ByteBuffer寫入Channel,或是從Channel讀入Buffer;而在Netty中,被精心設計的ByteBuf則是Netty貫穿整個開發過程中的核心緩衝區,那麼他們倆有什麼區別呢?Netty對於緩衝區的設計對於高效能應用又帶來了哪些值得借鑑的思路呢?本文在介紹ByteBuffer和ByteBuf基本概念的基礎之上對兩者進行對比,進而擴充套件介紹Netty中的ByteBuf大家族。

在JAVA NIO相關的元件中,ByteBuffer是除了Selector、Channel之外的另一個很重要的元件,它是直接和Channel打交道的緩衝區,通常場景或是從ByteBuffer寫入Channel,或是從Channel讀入Buffer;而在Netty中,被精心設計的ByteBuf則是Netty貫穿整個開發過程中的核心緩衝區,那麼他們倆有什麼區別呢?Netty對於緩衝區的設計對於高效能應用又帶來了哪些值得借鑑的思路呢?本文在介紹ByteBuffer和ByteBuf基本概念的基礎之上對兩者進行對比,進而擴充套件介紹Netty中的ByteBuf大家族。

 

JAVA NIO之ByteBuffer

JAVA NIO中,Channel作為通往具有I/O操作屬性的實體的抽象,這裡的I/O操作通常指readding/writing,而具有I/O操作屬性的實體比如I/O裝置、檔案、網路套接字等等。光有Channel可不行,我們必須為他增加readding/writing的特性,因此JAVA NIO基於Channel擴充套件WritableByteChannel和ReadableByteChannel介面。由於本文的重點是ByteBuffer,因此我們對於Channel的設計就看到這裡,因為有了WritableByteChannel和ReadableByteChannel之後,我們就可以對ByteBuffer進行操作啦,看看他們提供的兩個介面:

1

2

public int read throw  IOException;

public int write(ByteBuffer src) IOException;

從上面的介面我們可以看到Channel和ByteBuffer之間發生的兩個基本行為,即readding/writing。無論是對檔案(FileChannel)還是對網路(SocketChannel)的讀寫,他們都會去實現這兩個基本行為。好了,我們已經從總體上認識ByteBuffer在JAVA NIO所處的位置和擔當的角色了,下面我們繼續深入一點認識ByteBuffer。

ByteBuffer有四個重要的屬性,分別為:mark、position、limit、capacity,和兩個重要方法分別為:flip和clear。ByteBuffer的底層儲存結構對於堆記憶體和直接記憶體分別表現為堆上的一個byte[]物件和直接記憶體上分配的一塊記憶體區域。既然是一塊記憶體區域,那麼我們就可以對其進行基於位元組的讀和寫,而ByteBuffer的四個int型別的屬性則是指向這塊區域的指標:

  1. position:讀寫指標,代表當前讀或寫操作的位置,這個值總是小於等於limit的。

  2. mark:在使用ByteBuffer的過程中,如果想要記住當前的position,則會將當前的position值給mark,讓需要恢復的時候,再將mark的值給position。

  3. capacity:代表這塊記憶體區域的大小。

  4. limit:初始的Buffer中,limit和capacity的值是相等的,通常在clear操作和flip操作的時候會對這個值進行操作,在clear操作的時候會將這個值和capacity的值設定為相等,當flip的時候會將當前的position的值給limit,我們可以總結在寫的時候,limit的值代表最大的可寫位置,在讀的時候,limit的值代表最大的可讀位置。clear是為了寫作準備、flip是為了讀做準備。

    ByteBuffer指標示意圖

在JAVA NIO中,原生的ByteByffer家族成員很簡單,主要是HeapByteBuffer、DirectByteBuffer和MappedByteBuffer:

  1. HeapByteBuffer是基於堆上位元組陣列為儲存結構的緩衝區。

  2. DirectByteBuffer是基於直接記憶體上的記憶體區域為儲存結構的緩衝區。

  3. MappedByteBuffer主要是檔案操作相關的,它提供了一種基於虛擬記憶體對映的機制,使得我們可以像操作檔案一樣來操作檔案,而不需要每次將內容更新到檔案之中,同時讀寫效率非常高。

 

Netty之ByteBuf

相比於ByteBuffer的讀寫指標position,ByteBuf提供了兩個指標readerIndex和writeIndex來分別指向讀的位置和寫的位置,不需要每次為讀寫做準備,直接設定讀寫指標進行讀寫操作即可。我們看看處於中間狀態的狀態:

讀寫中間狀態的Buffer

從開始到readerIndex指標之間的這塊區域是可以被丟棄的區域,後面會講到,readerIndex和writerIndex指標之間的區域是可以被讀的,writerIndex和capacity指標之間的區域是可以寫的區域。當writerIndex指標到達頂端之後,ByteBuf允許使用者複用之前已經被讀過的區域,呼叫discardReadBytes方法即可,對應於上面的狀態,呼叫discardReadBytes之後的狀態如下:

呼叫discardReadBytes之後回收可用區域

除了discardReadBytes方法之外,另外一個比較重要的方法就是clear了,clear即清除緩衝區的指標狀態,回覆到初始值,對應於中間狀態的那張圖,呼叫clear之後的狀態如下:

呼叫clear之後,Buffer狀態的指標狀態得到了初始化

 

Netty ByteBuf的特點

這裡想要比較兩種Buffer,對比ByteBuffer得出ByteBuf的優點點,我們首先要做的就是總結ByteBuf的特點以及相比ByteBuffer,這個特點如何成為優點:

(1)ByteBuf讀寫指標

在ByteBuffer中,讀寫指標都是position,而在ByteBuf中,讀寫指標分別為readerIndex和writerIndex,直觀看上去ByteBuffer僅用了一個指標就實現了兩個指標的功能,節省了變數,但是當對於ByteBuffer的讀寫狀態切換的時候必須要呼叫flip方法,而當下一次寫之前,必須要將Buffe中的內容讀完,再呼叫clear方法。每次讀之前呼叫flip,寫之前呼叫clear,這樣無疑給開發帶來了繁瑣的步驟,而且內容沒有讀完是不能寫的,這樣非常不靈活。相比之下我們看看ByteBuf,讀的時候僅僅依賴readerIndex指標,寫的時候僅僅依賴writerIndex指標,不需每次讀寫之前呼叫對應的方法,而且沒有必須一次讀完的限制。

 

(2)ByteBuf引用計數

ByteBuf擴充套件了ReferenceCountered介面,這個介面定義的功能主要是引用計數:

ReferenceCountered介面定義

也就是所有對ByteBuf的實現,都要實現引用計數,Netty對Buffer資源進行了顯式的管理,這部分要結合Netty的記憶體池技術理解,當Buffer引用+1的時候,需要呼叫retain來讓refCnt+1,當Buffer引用數-1的時候需要呼叫release來讓refCnt-1,當refCnt變為0的時候Netty為pooled和unpooled的不同buffer提供了不同的實現,通常對於非記憶體池的用法,Netty把Buffer的記憶體回收交給了垃圾回收器,對於記憶體池的用法,Netty對記憶體的回收實際上是回收到記憶體池內,以提供下一次的申請所使用,關於記憶體池這部分可以參考我之前的篇文章。

(3)池化Buffer資源

由於Netty是一個NIO網路框架,因此對於Buffer的使用如果基於直接記憶體(DirectBuffer)實現的話,將會大大提高I/O操作的效率,然而DirectBuffer和HeapBuffer相比之下除了I/O操作效率高之外還有一個天生的缺點,即對於DirectBuffer的申請相比HeapBuffer效率更低,因此Netty結合引用計數實現了PolledBuffer,即池化的用法,當引用計數等於0的時候,Netty將Buffer回收致池中,在下一次申請Buffer的沒某個時刻會被複用。Netty這樣做的基本想法是我們花了很大的力氣申請了一塊記憶體,不能輕易讓他被回收呀,能重複利用當然重複利用咯。

(3)ByteBuffer才能和Channel打交道

歸根結底,站在NIO的立場上所有的緩衝區要想和Channel打交道,換句話說也就是從網路Channel讀取資料的時候,都是從Channel到ByteBuffer,從緩衝區寫的網上上的時候,都是從ByteBuffer到Channel。因此,當Netty監聽到I/O讀事件的時候,會將自己流從Channel讀到ByteBuffer而不是ByteBuf,see below:

1

return in.read((ByteBuffer) internalNioBuffer().clear().position(index).limit(index + length));

上面是ByteBuf的其中一個具體的讀實現,可以看出ByteBuf維護著一個內部的ByteBuffer,叫做internalNioBuffer。當需要將位元組流寫入網路的時候,需要將ByteBuf轉換為ByteBuffer,see below:

1

2

3

4

5

6

7

8

ByteBuffer tmpBuf;

    if (internal) {

       tmpBuf = internalNioBuffer();

    else {

        tmpBuf = ByteBuffer.wrap(array);

    }

   return out.write((ByteBuffer)

 tmpBuf.clear().position(index).limit(index + length));

}

上面是ByteBuf的其中一個具體的寫實現,在寫之前,總會將ByteBuf變成ByteBuffer。

稍微總結下這一節,ByteBuf本身的設計,在指標方面用兩個讀寫指標分別代表讀和寫指標,這樣做減少了Buffer使用的難度和出錯率,概念上去理解也比較簡單。在Netty中,每個被申請的Buffer對於Netty來說都可能是很寶貴的資源,因此為了獲得對於記憶體的申請與回收更多的控制權,Netty自己根據引用計數法去實現了記憶體的管理,另外配合精心設計的池化演算法在更大程度上控制了記憶體的使用,雖然相比單純的申請-使用-釋放來說實現可被管理、可被池化的Buffer是略複雜的,但是能為Netty卓越的效能資料做一些貢獻,這絕對是值得的。最後我們要理清概念,JAVA NIO中和Channel打交道的只能是ByteBuffer,Netty在讀寫之前都有做轉換,因此不要搞混,ByteBuf還是ByteBuf,它不是ByteBuffer。

 

Netty的Buffer大家族

這一節介紹一下Netty的Buffer大家族,ByteBuf的家族是龐大的,但是我們可以理清套路來將他們歸類一下,這樣看起來就不會那麼的複雜,Netty主要圍繞著2*2的維度進行對Buffer的擴充套件,他們分別是:

DirectBuffer

HeapBuffer

PooledBuffer

UnPooledBuffer

最高層的抽象是ByteBuf,Netty首先根據直接記憶體和堆記憶體,將Buffer按照這兩個方向去擴充套件,之後再分別對具體的直接記憶體和堆記憶體緩衝區按照是否池話這兩個方向再進行擴充套件。除了這兩個維度,Netty還擴充套件了基於Unsafe的Buffer,我們分別挑出一個比較典型的實現來進行介紹:

PooledHeapByteBuf:池化的基於堆記憶體的緩衝區。

PooledDirectByteBuf:池化的基於直接記憶體的緩衝區。

PooledUnsafeDirectByteBuf:池化的基於Unsafe和直接記憶體實現的緩衝區。

UnPooledHeapByteBuf:非池化的基於堆記憶體的緩衝區。

UnPooledDirectByteBuf:非池化的基於直接記憶體的緩衝區。

UnPooledUnsafeDirectByteBuf:非池化的基於Unsafe和直接記憶體實現的緩衝區。

除了上面這些,另外Netty的Buffer家族還有CompositeByteBuf、ReadOnlyByteBufferBuf、ThreadLocalDirectByteBuf等等,這裡還要說一下UnsafeBuffer,噹噹前平臺支援Unsafe的時候,我們就可以使用UnsafeBuffer,JAVA DirectBuffer的實現也是基於unsafe來對記憶體進行操作的,我們可以看到不同的地方是PooledUnsafeDirectByteBuf或UnPooledUnsafeDirectByteBuf維護著一個memoryAddress變數,這個變數代表著緩衝區的記憶體地址,在使用的過程中加上一個offer就可以對記憶體進行靈活的操作。總的來說,Netty圍繞著ByteBuf及其父介面定義的行為分別從是直接記憶體還是使用堆記憶體,是池話還是非池化,是否支援Unsafe來對ByteBuf進行不同的擴充套件實現。