Netty學習筆記(9)——Netty組件ByteBuf

1. ByteBuf作用

    1. 當進行數據傳輸時,都會使用到一個緩衝區,在jdk提供的NIO中最常用的就是ByteBuffer,但在使用的時候,我們很容易會感到ByteBuffer有以下缺點:

  • ByteBuffer實際上就是一個Byte數組,所以在一開始進行創建時,就必須要指定其大小,而且不能進行動態擴容以及縮容,這就引起了很多問題,經常會導致數組下標越界異常。
  • ByteBuffer中有三個標示位,用於標示讀寫時的位置,所以在使用ByteBuffer進行讀寫時必須經常調用flip()方法來切換讀寫模式,否則就會導致程序異常。
  • ByteBuffer的API功能有限。

    2. 爲了彌補ByteBuffer的缺點,Netty中提供了ByteBuf類來取代NIO的ByteBuffer。其優點如下:

  • 通過內置的複合緩衝區類型實現了透明的零拷貝;
  • 容量可以按需增長(類似於 JDK 的 StringBuilder);
  • 在讀和寫這兩種模式之間切換不需要調用 ByteBuffer 的 flip()方法;
  • 讀和寫使用了不同的索引;
  • 支持方法的鏈式調用;
  • 支持引用計數;
  • 支持池化;
  • 它可以被用戶自定義的緩衝區類型擴展;

    ByteBuf並不是和ByteBuffer一樣,是一個具體實現類,而是一個抽象類,我們可以通過繼承該抽象類自己去實現所需要的緩衝區,當然,Netty中也提供了非常豐富的具體實現類,基本滿足我們的使用需求。

2. 原理分析

    1. 首先,ByteBuf肯定還是和ByteBuffer一樣,底層都是一個Byte類型的數組,基本功能是與ByteBuffer一致的,也就是ByteBuffer有的API,ByteBuf裏也有基本相同功能的API。但除此之外,爲了彌補NIO中ByteBuffer的缺點,ByteBuf在ByteBuffer的基礎上進行了一些擴展,擴展方式有兩種

  • 通過直接繼承或者是直接賦值代碼的方式,將基本功能的代碼移植過來,然後再添加一些新的功能代碼。
  • 通過外觀模式,也就是通過聚合的方式,對ByteBuffer進行包裝,可以減少需要編寫的代碼量。基本上都是使用這種方式,外觀模式的應用在這裏有着體現,具體的ByteBuf實現類比如io.netty.buffer.ReadOnlyByteBuf等都是使用聚合的方式進行擴展ByteBuffer。

    2. 爲什麼ByteBuf不需要進行讀寫狀態切換:

    其實原理很簡單,在ByteBuffer中只用了一個position變量來記錄標示當前操作的數組下標位置,所以讀寫需要切換狀態;而在ByteBuf中則另外添加兩個變量來記錄讀或寫進行操作的當前數組下標,對應變量分別是readerIndex和writerIndex,而且必須滿足readerIndex   <=   writerIndex。也就是說,讀操作只會改變readerIndex變量,而寫操作只會改變writerIndex,同時要滿足滿足readerIndex   <=   writerIndex,這樣就能使得不需要進行讀寫狀態切換。

3. API介紹

    1. 讀操作:ByteBuffer中通過相關的get方法來實現數據讀取,上面說了,ByteBuf中有ByteBuffer的基本功能,所以也有ByteBuffer的get方法,但是ByteBuf還是擴展了相關的read方法,get方法的功能主要是通過下標index隨機讀取,而read方法實現的是順序讀取。

    但是,儘管read方法和get方法的功能都是基本相同的,在底層原理上還是不同的,因爲ByteBuf的get方法是直接調用ByteBuffer的get方法來實現的,所以其不會改變ByteBuf中的readerIndex變量,只會改變ByteBuffer中的position,必須要注意這一點。不能get方法和read方法混用,會出現問題。部分讀操作API如下

9d407b7a7d36c9e2da5a3859da92bdd58af.jpgaaa929b58c35916e4b454dc0505eaa6f338.jpg

    2. 寫操作:ByteBuf中的寫操作是write方法,與ByteBuffer中的put方法類似,但是ByteBuf中並沒有put方法,而是換成了set方法,set方法的功能主要是通過下標index隨機寫入數據,而write方法實現的是順序寫入數據。同樣的,雖然write和set方法都可以想緩衝區插入數據,但是隻有write方法可以操作改變writerIndex變量,而set方法則不可以。部分API如下

b9d8fc22c83d902f0405c375a370425bb44.jpg

714abea70eed5c78bf15b351da24832ec26.jpg

    3. 可丟棄字節(discardReadBytes()方法),上面提到過,ByteBuf相較於ByteBuffer有一個改進就是可以動態擴容,其底層原理無非就是對於Byte數組進行擴容或縮容,但是這個操作是非常耗時的。爲了提高性能,ByteBuf中還通過readerIndex索引變量實現了可丟棄字節的功能,也就是重複利用已讀取過的數組空間。原理圖如下

c1bae7f84c09fa97f1eaabbe7a9034d7dda.jpg

字節丟棄後

f556f0a594c442ae6e963ada4711ffeb8a4.jpg

但是,discardReadBytes方法原理說白了就是內存複製,只不過比單純的數組擴容的性能要好得多,因爲數組擴容需要先進行開闢內存空間,然後再複製原數組中的每一個元素,而discardReadBytes方法只需要複製部分元素即可,但是頻繁調用discardReadBytes方法仍然很耗費性能,所以如果不是必須的話,不建議執行。

    4. 可讀可寫判斷(isReadable方法和isWritable方法):通過readerIndex  和  writerIndex,ByteBuf還提供了一個可讀可寫的判斷操作,從Byte數組下標0處到readerIndex 之前都是讀取過的數據,而readerIndex到writerIndex之間就是可以進行讀取的數據空間,writerIndex到array.length-1之間就是可以寫入的數據空間,所以很容易就可以實現一個可讀可寫的判斷條件。

    5. 讀寫索引管理:讀寫索引管理就是指調整改變readerIndex  和  writerIndex的值,除了讀寫操作以及discardReadBytes外,ByteBuf還提供了幾個專門管理讀寫索引的方法

  • markReaderIndex()和resetReaderIndex():標記當前讀索引的位置,然後調用resetReaderIndex重置readerIndex 的值到該位置,這兩個方法都是配套使用的
  • markWriterIndex()和resetWriterIndex():標記當前寫索引的位置,然後調用resetWriterIndex重置writerIndex的值到該位置,這兩個方法都是配套使用的
  • clear():清空緩衝區,這個方法雖說是清空緩衝區,但實際上只是重置讀寫索引的值爲0,並不會將數組中的元素值爲null,所以該方法性能很好。

    6. 查找操作:有時候需要在緩衝區裏查找某個byte數據,比如查找換行符字節等,ByteBuf提供了一系列的方法來實現

  • int indexOf(int fromIndex, int toIndex, byte value):從起始索引fromIndex開始遍歷,查詢首次出現value的位置索引,終點索引是toIndex,沒有找到則返回-1
  • int bytesBefore(byte value):從readerIndex 到writerIndex之間查詢首個出現value的索引下標,沒有找到則返回-1
  • int bytesBefore(int length, byte value):從readerIndex 到readerIndex +length之間查詢首個出現value的索引下標,沒有找到則返回-1,但要注意如果readerIndex +length大於writerIndex就會拋出數組下標越界異常。
  • int bytesBefore(int index, int length, byte value):從index到index+length之間查詢首個出現value的索引下標,沒有找到則返回-1,如果index+length大於緩衝區數組長度,就會拋出數組下標越界異常。
  • int forEachByte(ByteBufProcessor processor):從readerIndex 到writerIndex之間遍歷滿足ByteBufProcessor 查詢條件的索引下標,沒有找到則返回-1
  • int forEachByte(int index, int length, ByteBufProcessor processor):從index到index+length之間查詢首個滿足ByteBufProcessor 查詢條件的索引下標,沒有找到則返回-1,如果index+length大於緩衝區數組長度,就會拋出數組下標越界異常。
  • int forEachByteDesc(ByteBufProcessor processor):這個就是forEachByte的逆序查找版本
  • int forEachByteDesc(int index, int length, ByteBufProcessor processor):forEachByte的逆序查找版本

    7. 派生緩衝區:有多種方式

  • ByteBuf copy():將當前的ByteBuf 複製一份,也就是說創建一個新的ByteBuf 對象,並且將其中的Byte數組內容一併被複制,而且讀寫索引變量不變,但是兩個ByteBuf 之間互不影響,數據內容和讀寫索引都是獨立的(不共享緩衝區內容)。
  • ByteBuf copy(int index, int length):同上,但是是從指定的index下標位置卡死是複製length個字節,同樣複製後的讀寫索引和內容都是獨立的(不共享緩衝區內容)。
  • ByteBuf slice():返回當前ByteBuf 對象的可讀子緩衝區,也就是將readerIndex 到writerIndex之間的字節數據組裁剪複製一份出來,並且子緩衝區的讀寫索引獨立維護,但是共享緩衝區的數據內容,也就是說,相當於兩個ByteBuf 對象中的Byte數組對象是同一個,但是兩個ByteBuf對象維護了各自的讀寫索引。
  • ByteBuf slice(int index, int length):返回當前ByteBuf 對象的可讀子緩衝區,範圍從index到index+length,讀寫索引獨立維護,但是共享數據內容。
  • ByteBuf duplicate():完整複製當前的ByteBuf對象,共享整個緩衝區的數據內容,但是各自維護各自的讀寫索引。

    8. 轉換爲NIO中的ByteBuffer:

  • ByteBuffer nioBuffer():將當前ByteBuf 對象可讀的緩衝區轉換爲ByteBuffer對象並返回,共享緩衝區內容,但各自維護的讀寫索引獨立,而且ByteBuffer對象無法感知到ByteBuf對象中對於緩衝區的擴容操作。
  • ByteBuffer nioBuffer(int index, int length):將當前ByteBuf 對象從index處到index+length的的緩衝區轉換爲ByteBuffer對象並返回,共享緩衝區內容,但各自維護的讀寫索引獨立,而且ByteBuffer對象無法感知到ByteBuf對象中對於緩衝區的擴容操作。

4. ByteBuf的具體實現子類

    ByteBuf的子類非常複雜,而且功能種類繁多,只列舉幾個示例。

    1. 緩衝區內存分配模式分類:在NIO中說過緩衝區的內存分配方式有兩種,一種是通過JVM的堆內存進行分配,比如ByteBuffer中的Byte數組,另一種則是直接通過物理內存空間(或者說系統內核空間)分配的,這種方式分配的特點就是其數據讀寫並不能以JVM中的數組形式進行,同樣的Netty中也提供了ByteBuf的兩種實現(直接內存和堆內存這裏有一個零拷貝的相關知識點,後面詳細講解,其實通過這裏基本也能知道什麼是零拷貝)

  • 堆內存分配:內存分配在JVM的堆中進行,由於是在JVM的堆中所以其內存分配和回收的速度都比較快,但缺點是相較於直接內存,需要額外的進行一次IO操作將數據在系統內核空間和用戶空間之積進行復制移動,所以性能較差。(比如io.netty.buffer.PooledHeapByteBuf),以讀操作爲例
            ByteBuf heapByteBuf = Unpooled.wrappedBuffer(new byte[1024]);
            if(heapByteBuf.hasArray()) {//判斷是否爲堆內存分配的緩衝區,是則進行下面的操作
                byte[] array = heapByteBuf.array();//獲取當前緩衝區的字節數組引用
                int offset = heapByteBuf.arrayOffset();//獲取數組偏移量
                int length = heapByteBuf.readableBytes();//獲取數組中的可讀緩衝區字節數量
                //do something
            }

     

  • 直接內存分配:直接在系統內核空間進行內存分配,這種方式避免了需要額外的進行一次IO操作將數據在系統內核空間和用戶空間之積進行復制移動,所以性能較高,但是內存的分配和回收清理比較慢。(比如io.netty.buffer.PooledDirectByteBuf),以讀操作爲例
            ByteBuf directByteBuf = Unpooled.directBuffer(1024);
            if(!directByteBuf.hasArray()) {//判斷是否爲直接內存分配的緩衝區,是則進行下面的操作
                int length = directByteBuf.readableBytes();//獲取可讀緩衝區的字節數
                byte[] array = new byte[length];
    
                directByteBuf.getBytes(directByteBuf.readerIndex(), array);//將直接內存緩衝區中的數據讀取到array中
    
                //do something
    

     

因爲這兩種方式各有利弊,最佳應用應該是在IO通信線程中使用直接內存分配,而在數據消息的編解碼等模塊使用堆內存分配。

    2. 內存回收模式分類:

  • 類似於線程池的緩衝區ByteBuf對象池:和線程池的原理類似,作用也是一樣的,ByteBuf緩存池中會存放多個ByteBuf對象,這些ByteBuf對象可以重複利用,減少了高高負載情況下的頻繁的進行內存分配和回收。
  • 普通ByteBuf:需要進行頻繁的內存分配和回收,內存使用效率低。

使用ByteBuf對象池確實要比直接使用ByteBuf對象在性能上要好得多,但ByteBuf對象池的管理和維護非常複雜,代碼中使用時必須要更加謹慎。

    3. Netty中的Scatter/Gather機制實現:Netty中提供了一種複合緩衝區來實現NIO中的Scatter/Gather機制,複合緩衝區中存放並管理多個ByteBuf,可以在這個複合緩衝區增刪ByteBuf對象。Netty中通過io.netty.buffer.CompositeByteBuf來實現了這個機制,它提供了一個將多個緩衝區合併表示爲單個緩衝區的虛擬表示。

CompositeByteBuf中的ByteBuf對象可能同時包含直接內存分配和非直接內存分配兩種。 如果CompositeByteBuf只有一個ByteBuf實例,那麼對 CompositeByteBuf 上的 hasArray()方法的調用將返回該組件上的hasArray()方法的值;否則它將返回 false。

 CompositeByteBuf 總因爲可能有直接內存分配的ByteBuf,所以其可能不支持訪問其支撐數組,因此訪問 CompositeByteBuf 中的數據類似於(訪問)直接緩衝區的模式。

 

 

總結:Bytebuf的具體實現子類非常多,也非常複雜,但核心內容差不多就是以上這些東西,發現其實ByteBuf與ByteBuffer其實差不多,只是對ByteBuffer進行了一些擴充,比ByteBuffer擁有功能更多更復雜的子類,但他們的核心原理都是一致的。

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