當我們進行數據傳輸的時候,往往需要使用到緩沖區,常用的緩沖區就是JDK NIO類庫提供的java.nio.Buffer。
實際上,7種基礎類型(Boolean除外)都有自己的緩沖區實現,對於NIO編程而言,我們主要使用的是ByteBuffer。從功能角度而言,ByteBuffer完全可以滿足NIO編程的需要,但是由於NIO編程的復雜性,ByteBuffer也有其局限性,它的主要缺點如下。
(1)ByteBuffer長度固定,一旦分配完成,它的容量不能動態擴展和收縮,當需要編碼的POJO對象大於ByteBuffer的容量時,會發生索引越界異常;
(2)ByteBuffer只有一個標識位置的指針position,讀寫的時候需要手工調用flip()和rewind()等,使用者必須小心謹慎地處理這些API,否則很容易導致程序處理失敗;
(3)ByteBuffer的API功能有限,一些高級和實用的特性它不支持,需要使用者自己編程實現。
為了彌補這些不足,Netty提供了自己的ByteBuffer實現——ByteBuf。
ByteBuf的工作原理
ByteBuf通過兩個位置指針來協助緩沖區的讀寫操作,讀操作使用readerIndex,寫操作使用writerIndex。
readerIndex和writerIndex的取值一開始都是0,隨着數據的寫入writerIndex會增加,讀取數據會使readerIndex增加,但是它不會超過writerIndex。在讀取之后,0~readerIndex的就被視為discard的,調用discardReadBytes方法,可以釋放這部分空間,它的作用類似ByteBuffer的compact方法。readerIndex和writerIndex之間的數據是可讀取的,等價於ByteBuffer position和limit之間的數據。writerIndex和capacity之間的空間是可寫的,等價於ByteBuffer limit和capacity之間的可用空間。
由於寫操作不修改readerIndex指針,讀操作不修改writerIndex指針,因此讀寫之間不再需要調整位置指針,這極大地簡化了緩沖區的讀寫操作,避免了由於遺漏或者不熟悉flip()操作導致的功能異常。
初始分配的ByteBuf如圖:
寫入N個字節之后的ByteBuf如圖:
讀取M(<N)個字節之后的ByteBuf如圖:
調用discardReadBytes操作之后的ByteBuf如圖:
調用clear操作之后的ByteBuf如圖:
ByteBuf是如何實現動態擴展
通常情況下,當我們對ByteBuffer進行put操作的時候,如果緩沖區剩余可寫空間不夠,就會發生BufferOverflowException異常。為了避免發生這個問題,通常在進行put操作的時候會對剩余可用空間進行校驗,如果剩余空間不足,需要重新創建一個新的ByteBuffer,並將之前的ByteBuffer復制到新創建的ByteBuffer中,最后釋放老的ByteBuffer,代碼示例如下。
if(this.buffer.remaining() < needSize) { int toBeExtSize = needSize < 128 ? needSize : 128; ByteBuffer tmpBuffer = ByteBuffer.allocate(this.buffer.capacity() + toBeExtSize); this.buffer.flip(); tmpBuffer.put(this.buffer); this.buffer = tmpBuffer; }
從示例代碼可以看出,為了防止ByteBuffer溢出,每進行一次put操作,都需要對可用空間進行校驗,這導致了代碼冗余,稍有不慎,就可能引入其他問題。為了解決這個問題,ByteBuf對write操作進行了封裝,由ByteBuf的write操作負責進行剩余可用空間的校驗,如果可用緩沖區不足,ByteBuf會自動進行動態擴展,對於使用者而言,不需要關心底層的校驗和擴展細節,只要不超過設置的最大緩沖區容量即可。當可用空間不足時,ByteBuf會幫助我們實現自動擴展。
AbstractByteBuf @Override public ByteBuf writeByte(int value) { ensureWritable(1); setByte(writerIndex++, value); return this; } @Override public ByteBuf ensureWritable(int minWritableBytes) { if (minWritableBytes < 0) { throw new IllegalArgumentException(String.format( "minWritableBytes: %d (expected: >= 0)", minWritableBytes)); } if (minWritableBytes <= writableBytes()) { return this; } if (minWritableBytes > maxCapacity - writerIndex) { throw new IndexOutOfBoundsException(String.format( "writerIndex(%d) + minWritableBytes(%d) exceeds maxCapacity(%d): %s", writerIndex, minWritableBytes, maxCapacity, this)); } // Normalize the current capacity to the power of 2. int newCapacity = calculateNewCapacity(writerIndex + minWritableBytes); // Adjust to the new capacity. capacity(newCapacity); return this; }
通過源碼分析,我們發現當進行write操作時會對需要write的字節進行校驗,如果可寫的字節數小於需要寫入的字節數,並且需要寫入的字節數小於可寫的最大字節數時,對緩沖區進行動態擴展。無論緩沖區是否進行了動態擴展,從功能角度看使用者並不感知,這樣就簡化了上層的應用。
ByteBuf源碼思路
由於NIO的Channel讀寫的參數都是ByteBuffer,因此,Netty的ByteBuf接口必須提供API方便的將ByteBuf轉換成ByteBuffer,或者將ByteBuffer包裝成ByteBuf。考慮到性能,應該盡量避免緩沖區的復制,內部實現的時候可以考慮聚合一個ByteBuffer的私有指針用來代表ByteBuffer。
從內存分配的角度看,ByteBuf可以分為兩類。
(1)堆內存(HeapByteBuf)字節緩沖區:特點是內存的分配和回收速度快,可以被JVM自動回收;缺點就是如果進行Socket的I/O讀寫,需要額外做一次內存復制,將堆內存對應的緩沖區復制到內核Channel中,性能會有一定程度的下降。
(2)直接內存(DirectByteBuf)字節緩沖區:非堆內存,它在堆外進行內存分配,相比於堆內存,它的分配和回收速度會慢一些,但是將它寫入或者從Socket Channel中讀取時,由於少了一次內存復制,速度比堆內存快。
正是因為各有利弊,所以Netty提供了多種ByteBuf供開發者使用,經驗表明,ByteBuf的最佳實踐是在I/O通信線程的讀寫緩沖區使用DirectByteBuf,后端業務消息的編解碼模塊使用HeapByteBuf,這樣組合可以達到性能最優。
從內存回收角度看,ByteBuf也分為兩類:基於對象池的ByteBuf和普通ByteBuf。
兩者的主要區別就是基於對象池的ByteBuf可以重用ByteBuf對象,它自己維護了一個內存池,可以循環利用創建的ByteBuf,提升內存的使用效率,降低由於高負載導致的頻繁GC。測試表明使用內存池后的Netty在高負載、大並發的沖擊下內存和GC更加平穩。盡管推薦使用基於內存池的ByteBuf,但是內存池的管理和維護更加復雜,使用起來也需要更加謹慎,因此,Netty提供了靈活的策略供使用者來做選擇。
內存池原理分析
Arena本身是指一塊區域,在內存管理中,Memory Arena是指內存中的一大塊連續的區域,PoolArena就是Netty的內存池實現類。
為了集中管理內存的分配和釋放,同時提高分配和釋放內存時候的性能,很多框架和應用都會通過預先申請一大塊內存,然后通過提供相應的分配和釋放接口來使用內存。這樣一來,對內存的管理就被集中到幾個類或者函數中,由於不再頻繁使用系統調用來申請和釋放內存,應用或者系統的性能也會大大提高。在這種設計思路下,預先申請的那一大塊內存就被稱為Memory Arena。
不同的框架,Memory Arena的實現不同,Netty的PoolArena是由多個Chunk組成的大塊內存區域,而每個Chunk則由一個或者多個Page組成,因此,對內存的組織和管理也就主要集中在如何管理和組織Chunk和Page了。
PoolChunk
Chunk主要用來組織和管理多個Page的內存分配和釋放,在Netty中,Chunk中的Page被構建成一棵二叉樹。假設一個Chunk由16個Page組成,那么這些Page將會被按照下圖所示的形式組織起來。
Page的大小是4個字節,Chunk的大小是64個字節(4×16)。整棵樹有5層,第1層(也就是葉子節點所在的層)用來分配所有Page的內存,第4層用來分配2個Page的內存,依次類推。
每個節點都記錄了自己在整個Memory Arena中的偏移地址,當一個節點代表的內存區域被分配出去之后,這個節點就會被標記為已分配,自這個節點以下的所有節點在后面的內存分配請求中都會被忽略。舉例來說,當我們請求一個16字節的存儲區域時,上面這個樹中的第3層中的4個節點中的一個就會被標記為已分配,這就表示整個Memroy Arena中有16個字節被分配出去了,新的分配請求只能從剩下的3個節點及其子樹中尋找合適的節點。
對樹的遍歷采用深度優先的算法,但是在選擇哪個子節點繼續遍歷時則是隨機的,並不像通常的深度優先算法中那樣總是訪問左邊的子節點。
PoolSubpage
對於小於一個Page的內存,Netty在Page中完成分配。每個Page會被切分成大小相等的多個存儲塊,存儲塊的大小由第一次申請的內存塊大小決定。假如一個Page是8個字節,如果第一次申請的塊大小是4個字節,那么這個Page就包含2個存儲塊;如果第一次申請的是8個字節,那么這個Page就被分成1個存儲塊。
一個Page只能用於分配與第一次申請時大小相同的內存,比如,一個4字節的Page,如果第一次分配了1字節的內存,那么后面這個Page只能繼續分配1字節的內存,如果有一個申請2字節內存的請求,就需要在一個新的Page中進行分配。
Page中存儲區域的使用狀態通過一個long數組來維護,數組中每個long的每一位表示一個塊存儲區域的占用情況:0表示未占用,1表示以占用。對於一個4字節的Page來說,如果這個Page用來分配1個字節的存儲區域,那么long數組中就只有一個long類型的元素,這個數值的低4位用來指示各個存儲區域的占用情況。對於一個128字節的Page來說,如果這個Page也是用來分配1個字節的存儲區域,那么long數組中就會包含2個元素,總共128位,每一位代表一個區域的占用情況。
內存回收策略
無論是Chunk還是Page,都通過狀態位來標識內存是否可用,不同之處是Chunk通過在二叉樹上對節點進行標識實現,Page是通過維護塊的使用狀態標識來實現。
對於使用者來說,不需要關心內存池的實現細節,也不需要與這些類庫打交道,只需要按照API說明正常使用即可。
輔助類功能介紹
ByteBufHolder
ByteBufHolder是ByteBuf的容器,在Netty中,它非常有用,例如HTTP協議的請求消息和應答消息都可以攜帶消息體,這個消息體在NIO ByteBuffer中就是個ByteBuffer對象,在Netty中就是ByteBuf對象。由於不同的協議消息體可以包含不同的協議字段和功能,因此,需要對ByteBuf進行包裝和抽象,不同的子類可以有不同的實現。為了滿足這些定制化的需求,Netty抽象出了ByteBufHolder對象,它包含了一個ByteBuf,另外還提供了一些其他實用的方法,使用者繼承ByteBufHolder接口后可以按需封裝自己的實現。
ByteBufAllocator
ByteBufAllocator是字節緩沖區分配器,按照Netty的緩沖區實現不同,共有兩種不同的分配器:基於內存池的字節緩沖區分配器和普通的字節緩沖區分配器。
CompositeByteBuf
CompositeByteBuf允許將多個ByteBuf的實例組裝到一起,形成一個統一的視圖,有點類似於數據庫將多個表的字段組裝到一起統一用視圖展示。
CompositeByteBuf在一些場景下非常有用,例如某個協議POJO對象包含兩部分:消息頭和消息體,它們都是ByteBuf對象。當需要對消息進行編碼的時候需要進行整合,如果使用JDK的默認能力,有以下兩種方式:
(1)將某個ByteBuffer復制到另一個ByteBuffer中,或者創建一個新的ByteBuffer,將兩者復制到新建的ByteBuffer中;
(2)通過List或數組等容器,將消息頭和消息體放到容器中進行統一維護和處理。
上面的做法非常別扭,實際上我們遇到的問題跟數據庫中視圖解決的問題一致——緩沖區有多個,但是需要統一展示和處理,必須有存放它們的統一容器。為了解決這個問題,Netty提供了CompositeByteBuf。
它定義了一個Component類型的集合,實際上Component就是ByteBuf的包裝實現類,它聚合了ByteBuf對象,維護了在集合中的位置偏移量信息等
ByteBufUtil
ByteBufUtil是一個非常有用的工具類,它提供了一系列靜態方法用於操作ByteBuf對象。
其中最有用的方法就是對字符串的編碼和解碼,具體如下。
(1)encodeString(ByteBufAllocator alloc, CharBuffer src, Charset charset):對需要編碼的字符串src按照指定的字符集charset進行編碼,利用指定的ByteBufAllocator生成一個新的ByteBuf;
(2)decodeString(ByteBuffer src, Charset charset):使用指定的ByteBuffer和charset進行對ByteBuffer進行解碼,獲取解碼后的字符串。
還有一個非常有用的方法就是hexDump,它能夠將參數ByteBuf的內容以十六進制字符串的方式打印出來,用於輸出日志或者打印碼流,方便問題定位,提升系統的可維護性。hexDump包含了一系列的方法,參數不同,輸出的結果也不同。