摘要: 在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進行操作啦,看看他們提供的兩個接口:
public int read(ByteBuffer dst) throws IOException; public int write(ByteBuffer src) throws IOException;
從上面的接口我們可以看到Channel和ByteBuffer之間發生的兩個基本行為,即readding/writing。無論是對文件(FileChannel)還是對網絡(SocketChannel)的讀寫,他們都會去實現這兩個基本行為。好了,我們已經從總體上認識ByteBuffer在JAVA NIO所處的位置和擔當的角色了,下面我們繼續深入一點認識ByteBuffer。
ByteBuffer有四個重要的屬性,分別為:mark、position、limit、capacity,和兩個重要方法分別為:flip和clear。ByteBuffer的底層存儲結構對於堆內存和直接內存分別表現為堆上的一個byte[]對象和直接內存上分配的一塊內存區域。既然是一塊內存區域,那么我們就可以對其進行基於字節的讀和寫,而ByteBuffer的四個int類型的屬性則是指向這塊區域的指針:
-
position:讀寫指針,代表當前讀或寫操作的位置,這個值總是小於等於limit的。
-
mark:在使用ByteBuffer的過程中,如果想要記住當前的position,則會將當前的position值給mark,讓需要恢復的時候,再將mark的值給position。
-
capacity:代表這塊內存區域的大小。
-
limit:初始的Buffer中,limit和capacity的值是相等的,通常在clear操作和flip操作的時候會對這個值進行操作,在clear操作的時候會將這個值和capacity的值設置為相等,當flip的時候會將當前的position的值給limit,我們可以總結在寫的時候,limit的值代表最大的可寫位置,在讀的時候,limit的值代表最大的可讀位置。clear是為了寫作准備、flip是為了讀做准備。
ByteBuffer指針示意圖
在JAVA NIO中,原生的ByteByffer家族成員很簡單,主要是HeapByteBuffer、DirectByteBuffer和MappedByteBuffer:
-
HeapByteBuffer是基於堆上字節數組為存儲結構的緩沖區。
-
DirectByteBuffer是基於直接內存上的內存區域為存儲結構的緩沖區。
-
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:
return in.read((ByteBuffer) internalNioBuffer().clear().position(index).limit(index + length));
上面是ByteBuf的其中一個具體的讀實現,可以看出ByteBuf維護着一個內部的ByteBuffer,叫做internalNioBuffer。當需要將字節流寫入網絡的時候,需要將ByteBuf轉換為ByteBuffer,see below:
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進行不同的擴展實現。