本文是Netty文集中“Netty in action”系列的文章。主要是對Norman Maurer and Marvin Allen Wolfthal 的 《Netty in action》一書簡要翻譯,同時對重要點加上一些自己補充和擴展。
本章含蓋
- ByteBuf —— Netty 數據容器
- API細節
- 使用場景
- 內容分配
如我們之前提到的,網絡數據的基本單位是字節。JAVA NIO 提供了 ByteBuffer 作為字節的容器,但是這個類使用過于復雜并且在一些情況下使用過于笨重。
Netty使用ByteBuf 替換 ByteBuffer,一個強大的實現解決了JDK API 地址的局限性,并且提供了更好的API給網絡應用開發者
Q:JDK API 的局限性指什么?
A:JDK API 的局限性有如下幾點:
① 長度固定。一旦buffer分配完成,它的容量不能動態擴展或者收縮,當需要編碼的POJO對象大于ByteBuffer容量時,會發生索引越界異常。
② 只有一個標識位置的指針position。讀寫是需要手動flip和rewind等,需要十分小心使用這些API,否則很容易導致異常。
③ API功能有限。不支持一些高級使用的特性,需要用戶自己實現。
The ByteBuf API
Netty數據處理API通過兩個組件暴露 —— ByteBuf抽象類 和 ByteBufHolder接口
下面是ByteBuf API 的一些優點:
- 它是可擴展的用戶自定義緩沖器類型
- 通過構建一個復合緩沖器類型來實現傳輸的零拷貝
- 容量根據需求可擴展( 如同JDK的StringBuilder )
- 讀模式和寫模式的轉換不需要調用ByteBuffer的flip方法
- 讀和寫使用不同的索引,即有兩個索引,一個讀索引和一個寫索引。
- 支持方法鏈,即鏈式調用方法
- 支持引用計數,即一個ByteBuf的引用次數
- 支持池
ByteBuf 類 —— Netty 數據容器
ByteBuf是如何工作的
ByteBuf包含了兩個不同的索引:一個用于讀,一個用于寫。當你從ByteBuf中讀數據時,readerIndex將增加所讀字節數量。類似的,當你寫數據到ByteBuf時,writeIndex將增加。
如果你嘗試讀取大于writeIndex位置的數據,將觸發IndexOutOfBoundsException。
ByteBuf類以 ‘read’ 和 ‘write’ 打頭的方法將增加相應的索引,然后以’set’和‘get’打頭的方法并不會增加索引的值。后一種方法,對相對索引的操作,會將索引作為參數傳遞給方法。
比如:
能夠指定ByteBuf的最大容量,當嘗試移動寫索引超過最大容量時將觸發異常。( 默認限制 Integer.MAX_VALUE )
ByteBuf 使用模式
當我們通過Netty工作時,你將遇到幾種圍繞ByteBuf構建的常見使用模式。
ByteBuf主要3種使用模式:①Heap Buffers —— 堆緩沖區;②Direct Buffers —— 直接緩沖區;③Composite Buffers —— 復合緩沖區
Heap Buffers
Heap Buffers :最經常使用的ByteBuf模式,存儲數據到JVM的堆空間??醋鲆粋€后臺數組,這種模式支持快速分配和釋放在不是用池的情況下。
適用于處理遺留數據的場景
注意:嘗試去訪問一個后臺數組當hasArray()返回false,這將觸發一個UnsupportedOperationException異常。這種模式類似與JDK ByteBuffer的使用。
Direct Buffers
Direct Buffer是另外一種ByteBuf模式。我們希望總是從堆中給創建的對象分配內存,但是這不是必須的 —— JDK1.4引進的用于NIO的ByteBuffer允許JVM通過本地調用實現一個內存分配。這個做的目的是為了避免拷貝緩沖區內容到( or from )一個中間緩沖區在每次本地I/O操作調用前( or after )。
Javadoc 對于ByteBuffer 明確聲明,“直接緩沖區的內容將屬于標準垃圾回收的堆范圍外”。這就解釋了為什么直接緩沖區是網絡數據傳輸的理想選擇。如果你的數據被包含在一個堆分配的緩沖區中,則JVM實際上就是復制你的緩沖區數據到直接緩沖區,然后在通過socket發送。
直接緩沖區的主要缺點就是:分配和釋放比基于堆的緩沖區開銷更高些。如果你工作在一個遺留代碼上,你可能還會遇到另外一個缺點:因為數據不在堆上,所以你需要將數據拷貝到堆上。如下:
Composite Buffers
最后一個模式是復合緩沖區,該復合緩沖區表示一個多ByteBuf的聚合視圖。你能夠根據需要添加或刪除ByteBuf實例,這是JDK ByteBuffer現實完全不具有的特性。
Netty通過ByteBuf 的子類 CompositeByteBuf來實現這個模式,該模式提供將多個緩沖區合并為一個緩沖區的虛擬表示。
例子:一個包含了兩個部分的消息,消息頭和消息體,通過HTTP傳輸。這兩個部分通過不同的應用模式生成和裝配當消息被發送的時候。應用可選擇復用消息體對于多個不同的消息。當這發生時,每個消息都會創建一個新的消息頭。
因為我們不想重新分配兩個緩沖區給每個消息,CompositeByteBuf完美適用該情況;它消除了不必要的拷貝通過暴露通用的ByteBuf API。
??直接當做整個緩沖區模式的訪問
注意,Netty使用CompositeByteBuf優化socket I/O 的操作,盡可能的消除JDK的buffer實現造成的性能和內存使用量的問題。這個優化實現在了Netty的核心代碼中,也就是說它不會被暴露,但是我們需要意識到這個造成的影響。
所說的影響可能是:如果你將ByteBuf1、ByteBuf2復合成一個CompositeByteBuf,那么你對ByteBuf1、ByteBuf2的修改都會影響到CompositeByteBuf,因為CompositeByteBuf并不會將ByteBuf1和ByteBuf2中的數據拷貝一份過來,而是共享了ByteBuf1、ByteBuf2數據。而JDK Bytebuffer的話,不存在該問題,因為ByteBuffer的復合使用只能夠直接拷貝數據過來,這樣多個ByteBuffer和復合ByteBuffer之間就不存在數據共享的情況了。
字節操作
ByteBuf提供了大量的讀和寫操作用于修改它的數據。
隨機訪問索引
就像一個普通的java數組,ByteBuf索引從0開始,最后一個索引值為capacity()-1.
順序訪問索引
ByteBuf有兩個索引,一個讀索引,一個寫索引。而JDK的ByteBuffer只有個一個索引,這就是為什么在從寫模式轉換到讀模式時需要調用flip()方法。
廢棄的字節
廢棄的字節:已經被讀取過的字節。
已經讀取過的字節能被丟棄并通過調用discardReadBytes()回收空間。并初始化readerIndex大小為0。
下圖顯示了在圖5.3所示的基礎上調用discardReadBytes()后的結果。你能看到廢棄字節段的空間被轉換成了可寫入空間。
你可能嘗試通過頻繁調用discardReadBytes()為了獲得最大的可寫段,請留意這將很可能造成內存拷貝,因為可讀字節必須被移動到緩沖區頭。除非真的需要我們應該避免這樣的操作。
可讀字節
ByteBuf的可讀字節段存儲了真實的數據。一個新分配、封裝、或復制的緩沖區默認的readerIndex為0。任何以’read’打頭的方法或skip方法將檢索或跳過數據,從當前的readerIndex起并通過讀入字節的數增加readerIndex。
如果方法調用傳入ByteBuf參數作為一個寫目標對象并且沒有一個目標index參數,那么這個ByteBuf [ 作為參數傳入的ByteBuf ]的writerIndex將會增加,比如:
readBytes(ByteBuf dest);
如果嘗試從一個可讀字節已經耗盡的緩沖區里進行讀操作,那么將引發IndexOutOfBoundsException異常。
下面展示了如何讀取所有可讀的字節
可寫字節
可寫字節段是一個未定義內容的內存區域,并為寫入作好準備。一個新分配的緩沖區writerIndex的默認值是0。任何一個以‘write’打頭的方法操作都會從writerIndex索引開始,增加相應的寫入的字節數。如果寫操作的目標是一個ByteBuf [ 如,為下面例子中dest ]并且沒有指定源索引,那么這個被操作的ByteBuf [ 如,為下面例子中dest ]的readerIndex將增加相應的數量,比如:
writeBytes(ByteBuf dest);
如果試圖在超過目標容量的索引下進行寫入操作,這將引發一個IndexOutOfBoundException異常。
Q:這里說的目標容量是不是指最大容量,因為前面的內容說,如果寫索引超過了容量會自動進行擴容,只有寫索引超過了最大容量時,才會引發一個異常。
A:是超過最大容量才會引發異常的
ByteBuf buf = Unpooled.buffer(20, 32);
for(int i = 1; i <= 6; i++ ) {
buf.writeInt(i);
}
for(int i = 7; i <= 9; i++) {
buf.writeInt(i);
}
// 異常
Exception in thread "main" java.lang.IndexOutOfBoundsException: writerIndex(32) + minWritableBytes(4) exceeds maxCapacity(32): UnpooledByteBufAllocator$InstrumentedUnpooledUnsafeHeapByteBuf(ridx: 0, widx: 32, cap: 32/32)
at io.netty.buffer.AbstractByteBuf.ensureWritable0(AbstractByteBuf.java:275)
at io.netty.buffer.AbstractByteBuf.writeInt(AbstractByteBuf.java:982)
at com.bayern.netty.buffer.BufferDemo.main(BufferDemo.java:26)
A:參照的是初始化長度。
但是請注意,當寫入的數據超過了初始容量大小,但是小于最大容量大小時,ByteBuf會根據一定的邏輯進行擴容操作,并更新capacity為新的容量大小值。新的capacity范圍在minNewCapacity到maxCapacity作為一個上界。
索引管理
你能夠設置和重定位ByteBuf的readerIndex和writerIndex通過調用markReaderIndex(),markWriterIndex(),resetReaderIndex(),和resetWriterIndex()。這些操作類似于JDK InputStream的mark(int readlimit)和reset()方法,除了這里沒有指定readlimit參數來指定讀入多少字節后mark變成無效。
你能夠移動索引到指定位置通過調用readerIndex(int)或writerIndex(int)。設置任何一個索引到一個無效的位置將會引發IndexOutOfBoundsException異常。
通過調用clear()可以將readerIndex和writerIndex同時置為0。注意,這并沒有清除內存中的內容。如下圖所示:
調用clear()的開銷遠遠小于discardReadBytes(),因為clear()重置了索引的位置,當沒有進行內存拷貝。
查詢操作
這有幾種方式去確定ByteBuf中一個指定值的索引。
① indexOf()
② 更復雜的查詢可以執行一個帶ByteBufProcessor參數的方法。
派生的緩沖區
一個派生緩沖區提供專門的方式表示其內容的ByteBuf的視圖。
視圖的創建方法:
- duplicate()
- slice()
- slice(int, int)
- Unpooled.unmodifiableBuffer(...)
- orderSlice(int)
每個方法都會返回一個新的ByteBuf實例,每個實例都有他們自己的reader、writer、marker索引。
同JDK ByteBuffer一樣,其內部存儲是共享的。這使得能夠廉價的創建一個被派生的緩沖區,但這也意味著,如果你修改了派生緩沖區的內容,那么源實例的內容也會被修改。
JDK ByteBuffer的slice()派生的緩沖器也是內容共享的:
ByteBuf copying
如果你需要對一個已經存在的緩沖區完全拷貝,可以使用copy()或copy(int, int)。不同于一個派生的緩沖區,該方法返回的ByteBuf是數據的獨立副本。
讀/寫 操作
讀/寫的兩種分類:
- get() 和 set() 操作。從一個給定的索引開始操作,操作完索引值不會改變
- read() 和 write() 操作。從一個給定索引開始操作,操作完畢會根據訪問的字節數量對索引值做調整。
更多操作
ByteBuf提供了額外的實用性操作
ByteBufHolder 接口
我們經常發現,除了實際數據的有效負載外,我們還需要存儲各種屬性值。HTTP的返回是一個很好的例子;除了字節代表的內容外,還有狀態碼、cookies等其他屬性。
Netty提供了ByteBufHolder來處理這個常見的情況。ByteBufHolder還提供了Netty高級功能的支持,比如:緩沖池。一個ByteBuf能從一個池中獲取,并在不需要的時候自動釋放( 釋放的確切含義能被實現特定 )。
ByteBufHolder只有幾個少數的方法用于訪問底層數據和引用計數( reference counting )。
ByteBufHolder是一個很好的選擇,如果你想要實現將一個消息對象的負載存儲在一個ByteBuf中。
ByteBuf 分配
該章節我們將介紹幾種管理ByteBuf實例的方法
ByteBufAllocator 接口
為了減少分配和釋放內存的消耗,Netty使用ByteBufAllocator接口實現了池,該實現能夠分配我們所描述的任何種類的ByteBuf實例。池的使用是特定于應用程序的決定,這決定不會對ByteBuf API做任何改變。
你能夠從一個Channel或通過ChannelHandlerContext得到一個ByteBufAllocator的引用。
Netty為ByteBufAllocator提供了兩個實現:PooledByteBufAllocator和UnpooledByteBufAllocator。
前一種( PooledByteBufAllocator )實現池的ByteBuf,用于提高性能和減小內存碎片。該實現使用了jemalloc的內存分配的有效方法,jemalloc已經被大部分現代操作系統所采用。
后一種( UnpooledByteBufAllocator )實現非池的ByteBuf實例,當調用時總是返回一個新的對象。
盡管Netty默認使用PooledByteBufAllocator,但這能被輕易的改變,通過ChannelConfig API 或在啟動你的應用時指定一個不同的分配器。
Unpooled buffers
當你沒有一個ByteBufAllocator引用時,Netty提供了一個可利用的類叫Unpooled,Unpooled提供了靜態的幫助方法去創建一個非池的ByteBuf實例。
Unpooled類使ByteBuf能在在非網絡項目中有效使用,這使得項目能從高性能可擴展的buffer API中獲益并且不需要其他的Netty組件。
ByteBufUtil 類
ByteBufUtil 提供靜態的幫助方法用于管理一個ByteBuf。因為ByteBufUtil的API 非常通用且與池無關,所以它的方法實現都在分配類外面。
ByteBufUtil最重要的方法大概就是hexdump(),hexdump()打印一個ByteBuf內容的十六進制的表示。這在多種情況下是非常有用的,比如打印內容用于debug。一個十六進制的表示通常比直接使用二進制的表示提供更有用的日志條目。而且,十六進制版本能夠更簡單的被轉換回實際的字節的表示。
另一個有用的方法是boolean equals(ByteBuf , ByteBuf),該方法決定了兩個ByteBuf實例是否相同。
reference counting —— 引用計數
引用計數是一個用于優化內存使用和性能的技術,該技術通過釋放對象持有的資源來實現優化內存和性能,當對象不再被任何一個對象引用時該對象就會被釋放。
Netty 4 對ByteBuf 和 ByteBufHolder 引入了引用計數,ByteBuf 和 ByteBufHolder都實現了ReferenceCounted接口。
引用計數的思路并不復雜;通常它包含追蹤活躍引用的數量到一個指定的對象。一個ReferenceCounted實現實例通常以活躍引用值1開始( 也就是當ReferenceCounted實現實例被創建的時候,其引用計數值就為1了 )。當引用計數值大于0時,該對象保證不會被釋放。當引用計數指減小到0時,該實例將被釋放。注意,釋放的確切含義能被實現特定,但是已經被釋放的對象不應該再被使用。
引用計數對于池的實現是不可以缺少的,比如像PooledByteBufAllocator,它減少了內存分配的開銷。
嘗試去訪問一個已經釋放的引用計數對象,將返回一個IllegalReferenceCountException異常。
注意,一個指定的類可以定義它自己的釋放計數契約以它們特有的方式。舉個例子,我們能想象一個類,它實現了release()方法,總是設置引用值為0無論當前的值是什么,這將使所有的有效引用同時變得無效。
后記
本文主要對Netty的ByteBuf進行了詳細的介紹。Bytebuf是Netty中存儲數據的容器,相比于JDK 的ByteBuffer又進行了進一步的優化和加強。后期我們會通過源碼解析的方式進一步的了解ByteBuf在Netty中的使用。
若文章有任何錯誤,望大家不吝指教:)