当我们进行数据传输的时候,往往需要使用到缓冲区,常用的缓冲区就是JDK NIO类库提供的java.nio.Buffer。
7种基础类型(Boolean除外)都有自己的缓冲区实现,对于NIO编程而言,我们主要使用的是ByteBuffer。从功能角度而言,ByteBuffer完全可以满足NIO编程的需要,但是由于NIO编程的复杂性,ByteBuffer也有其局限性,它的主要缺点如下。
(1)ByteBuffer长度固定,一旦分配完成,它的容量不能动态扩展和收缩,当需要编码的对象大于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会帮助我们实现自动扩展。
当进行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。
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包含了一系列的方法,参数不同,输出的结果也不同。