【Netty权威指南】11-ByteBuf

1、ByteBuf功能说明

当我们进行数据传输的时候,往往需要使用到缓冲区,常用的缓冲区就是 JDK NIO类库提供的 java.nio.Buffer。
实际上,7种基础类型( Boolean除外)都有自己的缓冲区实现。对于NlO编程而言,我们主要使用的是 ByteBuffer。从功能角度而言, ByteBuffer完全可以满足NlO编程的需要,但是由于NIO编程的复杂性, ByteBuffer也有其局限性,它的主要缺点如下。
(1) ByteBuffer长度固定,一旦分配完成,它的容量不能动态扩展和收缩,当需要编码的POJO对象大于 ByteBuffer的容量时,会发生索引越界异常;
(2) ByteBuffer只有一个标识位置的指针 position,读写的时候需要手工调用flip()和rewind()等,使用者必须小心谨慎地处理这些API,否则很容易导致程序处理失败;
(3) ByteBuffer的API功能有限,一些高级和实用的特性它不支持,需要使用者自己编程实现
为了弥补这些不足, Netty提供了自己的 ByteBuffer实现— ByteBuf,下面我们一起学习 ByteBuf的原理和主要功能。

1.1、ByteBuf的工作原理

不同 ByteBuf实现类的工作原理不尽相同,本小节我们从 ByteBuf的设计原理出发,起探寻 Netty ByteBuf的设计理念。
首先, ByteBuf依然是个Byte数组的缓冲区,它的基本功能应该与JDK的 ByteBuffer致,提供以下几类基本功能。
◎7种Java基础类型、byte数组、ByteBuffer(ByteBuf)等的读写:
◎缓冲区自身的copy和 slice等;
◎设置网络字节序;
◎构造缓冲区实例;
◎操作位置指针等方法。
由于JDK的 ByteBuffer已经提供了这些基础能力的实现,因此, Netty ByteBuf的实现可以有两种策略
◎参考 JDK ByteBuffer的实现,增加额外的功能,解决原 Byte Buffer的缺点
◎聚合 JDK ByteBuffer,通过 Facade模式对其进行包装,可以减少自身的代码量,降低实现成本
JDK ByteBuffer于只有一个位置指针用于处理读写操作,因此每次读写的时候都需要额外调用flip()和 clear()等方法,否则功能将出错,它的典型用法如下。

ByteBuffer buffer = ByteBuffer.allocate(88);
String value="Netty权威指南";
buffer.put(value.getBytes());
buffer.flip();
byte[] vArray= new byte[buffer. remaining()];
buffer.get(vArray);
String decodeValue= new String(vArray);

我们看下调用flip()操作前后的对比。

如图15-2所示,如果不做flip()操作,读取到的将是 position到 capacity之间的错误内容,当执行flip()操作之后,它的limit被设置为 position, position设置为0, capacity不变。
由于读取的内容是从 position到 limit之间,因此,它能够正确地读取到之前写入缓冲区的内容。如图15-3所示。

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指针,读操作不修改 writerlndex指针,因此读写之间不再需要调整位置指针,这极大地简化了缓冲区的读写操作,避免了由于遗漏或者不熟悉flip()操作导致的功能异常初始分配的 ByteBuf如图15-4所示。

下面我们继续分析ByteBuf是如何实现动态扩展的。通常情况下,当我们对 ByteBuffer进行put操作的时候,如果缓冲区剩余可写空间不够,就会发生 BufferOverflowException异常。为了避免发生这个问题,通常在进行put操作的时候会对剩余可用空间进行校验如果剩余空间不足,需要重新创建一个新的 ByteBuffer,并将之前的 ByteBuffer复制到新创建的 ByteBuffer中,最后释放老的 ByteBuffer,代码示例如下。

从示例代码可以看出,为了防止 ByteBuffer溢出,每进行一次put操作,都需要对可用空间进行校验,这导致了代码冗余,稍有不慎,就可能引入其他问题。为了解决这个问题, ByteBuf对 write操作进行了封装,由 ByteBuf的 write操作负责进行剩余可用空间的校验。如果可用缓冲区不足, ByteBuf会自动进行动态扩展。对于使用者而言,不需要关心底层的校验和扩展细节,只要不超过设置的最大缓冲区容量即可。当可用空间不足时,ByteBuf会帮助我们实现自动扩展,这极大地降低了 ByteBuf的学习和使用成本,提升了开发效率。校验和扩展的相关代码如图15-9、15-10所示。

通过源码分析,我们发现当进行 write操作时,会对需要 write的字节进行校验。如果可写的字节数小于需要写入的字节数,并且需要写入的字节数小于可写的最大字节数,就对缓冲区进行动态扩展。无论缓冲区是否进行了动态扩展,从功能角度看使用者并不感知,这样就简化了上层的应用由于NIO的 Channel读写的参数都是 ByteBuffer,因此,Netty的 ByteBuf接口必须提供API,以方便地将 ByteBuf转换成 ByteBuffer,或者将 ByteBuffer包装成 ByteBuf。考虑到性能,应该尽量避免缓冲区的复制,内部实现的时候可以考虑聚合一个 ByteBuffer的私有指针用来代表 ByteBuffer。在后面的源码分析章节我们将详细介绍它的实现原理。
 

2、ByteBuf源码分析

2.1、ByteBuf的主要类继承关系

首先,我们通过主要功能类库的继承关系图(见图15-24),来看下ByteBuf接口的不同实现。

从内存分配的角度看, ByteBuf可以分为两类
(1)堆内存(HeapByteBuf)字节缓冲区:特点是内存的分配和回收速度快,可以被JVM自动回收;缺点就是如果进行 Socket的Io读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核 Channel中,性能会有一定程度的下降。
(2)直接内存(DirectByteBuf)字节缓冲区:非堆内存,它在堆外进行内存分配,相比于堆内存,它的分配和回收速度会慢一些,但是将它写入或者从 SocketChannel中读取时,由于少了一次内存复制,速度比堆内存快。
正是因为各有利弊,所以Netty提供了多种 ByteBuf供开发者使用,经验表明, ByteBuf的最佳实践是在IO通信线程的读写缓冲区使用 DirectByteBuf,后端业务消息的编解码模块使用 HeapByteBuf,这样组合可以达到性能最优。
从内存回收角度看, ByteBuf也分为两类:基于对象池的 ByteBuf和普通 ByteBuf。两者的主要区别就是基于对象池的 ByteBuf可以重用 ByteBuf对象,它自己维护了一个内存池,可以循环利用创建的 ByteBuf,提升内存的使用效率,降低由于高负载导致的频繁GC。
测试表明使用内存池后的Netty在高负载、大并发的冲击下内存和GC更加平稳。
尽管推荐使用基于内存池的 ByteBuf,但是内存池的管理和维护更加复杂,使用起来也需要更加谨慎,因此,Netty提供了灵活的策略供使用者来做选择。
下面我们对主要的功能类和方法的源码进行分析和解读,以便能够更加深刻地理解ByteBuf的实现,掌握其更加高级的功能。

2.2、AbstractByteBuf源码分析

继承自 ByteBuf, ByteBuf的一些公共属性和功能会在 AbstractByteBuf中实现,下面我们对其属性和重要代码进行分析解读。

2.2.1、主要成员变量

首先,像读索引、写索引、mark、最大容量等公共属性需要定义,具体定义如图15-25所示。
我们重点关注下 leakDetector,它被定义为 static,意味着所有的 ByteBuf实例共享同一个 ResourceLeakDetector对象。 ResourceLeakdetector用于检测对象是否泄漏,后面有专门章节进行讲解。

我们发现,在 AbstractByteBuf中并没有定义ByteBuf的缓冲区实现,例如byte数组或者 DirectByteBuffer.原因显而易见,因为 AbstractByteBuf并不清楚子类到底是基于堆内存还是直接内存,因此无法提前定义。
 

2.2.2、读操作簇

无论子类如何实现 ByteBuf,例如UnpooledHeapByteBuf使用byte数组表示字节缓冲区, UnpooledDirectByteBuf直接使用ByteBuffer,它们的功能都是相同的,操作的结果是等价的。
因此,读操作以及其他的一些公共功能都由父类实现,差异化功能由子类实现,这也就是抽象和继承的价值所在。

在读之前,首先对缓冲区的可用空间进行校验,校验的代码如图15-28所示。

如果读取的长度小于0,则抛出 IllegalArgumentException异常提示参数非法;如果可写的字节数小于需要读取的长度,则抛出 IndexOutOfBoundsException异常。由于异常中封装了详细的异常信息,所以使用者可以非常方便地进行问题定位校验通过之后,调用 getBytes方法,从当前的读索引开始,复制 length个字节到目标byte数组中。由于不同的子类复制操作的技术实现细节不同,因此该方法由子类实现。
如果读取成功,需要对读索引进行递增: readerlndex+= length。其他类型的读取操作与之类似,不再展开介绍,感兴趣的读者可以自行阅读相关代码。

2.2.3、写操作簇

与读取操作类似,写操作的公共行为在 AbstractByteBuf中实现。

首先对写入字节数组的长度进行合法性校验,校验代码如图15-32所示。
如果写入的字节数组长度小于0,则抛出 IllegalArgumentException异常;如果写入的字节数组长度小于当前 ByteBuf可写的字节数,说明可以写入成功,直接返回;如果写入的字节数组长度大于可以动态扩展的最大可写字节数,说明缓冲区无法写入超过其最大容量的字节数组,抛出 IndexOutOfBoundsException异常。
如果当前写入的字节数组长度虽然大于目前 ByteBuf的可写字节数,但是通过自身的动态扩展可以满足新的写入请求,则进行动态扩展。可能有读者会产生疑问,既然需要写入的字节数组长度大于当前缓冲区可写的空间,为什么不像JDK的 ByteBuffer那样抛出缓冲区越界异常呢?

在前面我们分析 JDK ByteBuffer缺点的时候已经有过介绍, ByteBuffer的一个最大的缺点就是一旦完成分配之后不能动态调整其容量。由于很多场景下我们无法预先判断需要编码和解码的POJO对象长度,因此只能根据经验数据给个估计值。如果这个值偏大,就
会导致内存的浪费;如果这个值偏小,遇到大消息编码的时候就会发生缓冲区溢出异常。
使用者需要自己捕获这个异常,并重新计算缓冲区的大小,将原来的内容复制到新的缓冲区中,然后重置指针。这种处理策略对用户非常不友好,而且稍有不慎,就会引入新的问题。
Netty的 ByteBuffer可以动态扩展,为了保证安全性,允许使用者指定最大的容量,在容量范围内,可以先分配个较小的初始容量,后面不够用再动态扩展,这样可以达到功能和性能的最优组合。
我们继续看 calculateNewCapacity方法的实现。首先需要重新计算下扩展后的容量,它有一个参数,等于 writerIndex+minWritableBytes,也就是满足要求的最小容量。如图15-33所示。


首先设置门限阈值为4MB,当需要的新容量正好等于门限阈值时,使用阈值作为新的缓冲区容量。如果新申请的内存空间大于阈值,不能采用倍增的方式(防止内存膨胀和浪费)扩张内存,而采用每次步进4MB的方式进行内存扩张。扩张的时候需要对扩张后的内存和最大内存(maxCapacity)进行比较,如果大于缓冲区的最大长度,则使用maxCapacity作为扩容后的缓冲区容量。
如果扩容后的新容量小于阈值,则以64为计数进行倍增,直到倍增后的结果大于或等于需要的容量值。采用倍增或者步进算法的原因如下:如果以 minNewCapacity作为目标容量,则本次扩容后的可写字节数刚好够本次写入使用。写入完成后,它的可写字节数会变为0,下次做写入操作的时候,需要再次动态扩张。这样就会形成第一次动态扩张后,每次写入操作都会进行动态扩张,由于动态扩张需要进行内存复制,频繁的内存复制会导致性能下降。采用先倍增后步进的原因如下:当内存比较小的情况下,倍增操作并不会带来太多的
内存浪费,例如64字节-->128字节->256字节,这样的内存扩张方式对于大多数应用系统是可以接受的。但是,当内存增长到一定阈值后,再进行倍增就可能会带来额外的内存浪费,例如10MB,采用倍增后变为20MB。但很有可能系统只需要12MB,则扩张到20MB后会带来8MB的内存浪费。由于每个客户端连接都可能维护自己独立的接收和发送缓冲区,这样随着客户读的线性增长,内存浪费也会成比例地增加,因此,达到某个阈值后就需要以步进的方式对内存进行平滑的扩张。
这个阈值是个经验值,不同的应用场景,这个值可能不同,此处, ByteBuf取值为4MB重新计算完动态扩张后的目标容量后,需要重新创建个新的缓冲区,将原缓冲区的内容复制到新创建的 ByteBuf中,最后设置读写索引和mark标签等。由于不同的子类会对应不同的复制操作,所以该方法依然是个抽象方法,由子类负责实现。如图15-34所示。

2.2.4、操作索引
与索引相关的操作主要涉及设置读写索引、mark和rest等。由于这部分代码非常简单,我们就以设置读索引为例进行分析,相关代码如图15-36所示。

在重新设置读索引之前需要对索引进行合法性判断,如果它小于0或者大于写索引,则抛出 IndexOutOfBoundsException异常,设置失败。校验通过之后,将索引设置为新的值,然后返回当前的 ByteBuf对象。
2.2.5、重用缓冲区
前面介绍功能的时候已经简单讲解了如何通过 discardReadBytes和 discardSomeReadBytes方法重用已经读取过的缓冲区,下面结合 discardReadBytes方法的实现进行分析,源码如图15-37所示。
首先对读索引进行判断,如果为0则说明没有可重用的缓冲区,直接返回。如果读索引大于0且读索引不等于写索引,说明缓冲区中既有已经读取过的被丢弃的缓冲区,也有尚未读取的可读缓冲区。调用 setBytes(0,this, readerIndex, writerIndex- readerIndex)方法进行字节数组复制。将尚未读取的字节数组复制到缓冲区的起始位置,然后重新设置读写索引,读索引设置为0,写索引设置为之前的写索引减去读索引(重用的缓冲区长度)。

在设置读写索引的同时,需要同时调整 markedReaderIndex和 markedWriterIndex,调整mark的代码如图15-38所示。
首先对备份的 markedReaderIndex和需要减少的 decrement进行判断,如果小于需要减少的值,则将 markedReaderIndex设置为0。注意,无论是 markedReaderIndex还是markedWriterIndex,它的取值都不能小于0。如果 markedWriterIndex也小于需要减少的值,
则 markedWriterIndex置为0,否则, markedWriterIndex减去 decrement之后的值就是新的markedWriterIndex。

如果需要减小的值小于 markedReaderlndex,则它也一定也小于 markedWriterlndex,markedReaderIndex和 markedWriterIndex的新值就是减去 decrement之后的取值。如果 readerIndex等于 writerIndex,则说明没有可读的字节数组,那就不需要进行内存复制,直接调整mark,将读写索引设置为0即可完成缓冲区的重用,代码如图15-39所示。

2.2.6、skipBytes
在解码的时候,有时候需要丢弃非法的数据报,或者跳跃过不需要读取的字节或字节数组,此时,使用 skipBytes方法就非常方便。它可以忽略指定长度的字节数组,读操作时直接跳过这些数据读取后面的可读缓冲区,详细的代码实现如图15-40所示。

首先判断跳过的长度是否大于当前缓冲区可读的字节数组长度,如果大于可读字节数组长度,则抛出 IndexOutOfBoundsException;如果参数本身为负数,则抛出 IllegalArgumentException异常。
如果校验通过,则设置新的读索引为旧的索引值与跳跃的长度之和,然后对新的读索引进行判断。如果大于写索引,则抛出 IndexOutOfBoundsException异常:如果合法,则将读索引设置为新的读索引。这样后续读操作的时候就会从新的读索引开始,跳过 length个字节。

2.3、AbstractReferenceCountedByteBuf源码分析

从类的名字就可以看出该类主要是对引用进行计数,类似于JVM内存回收的对象引用计数器,用于跟踪对象的分配和销毁,做自动内存回收。
下面通过源码来看它的具体实现
2.3.1、成员变量
AbstractReferenceCountedByteBuf成员变量列表如图15-41所示。

首先看第一个字段 refCntUpdater,它是 AtomiclIntegerFieldUpdater类型变量,通过原子的方式对成员变量进行更新等操作,以实现线程安全,消除锁。第二个字段是 REFCNT_FIELD_OFFSET,它用于标识 refcnt字段在 AbstractReferenceCountedByteBuf中的内存地
址。该内存地址的获取是JDK实现强相关的,如果使用SUN的JDK,它通过 sun.misc.Unsafe的objectFieldoffset接口来获得, ByteBuf的实现子类 UnpooledUnsafeDirectByteBuf和PooledUnsafeDirectByteBuf会使用到这个偏移量。
最后定义了一个 volatile修饰的refCnt字段用于跟踪对象的引用次数,使用 volatile是为了解决多线程并发访问的可见性问题,此处不对 volatile的用法展开说明,后续多线程章节会有详细介绍。

2.3.2、对象引用计数器

每调用一次 retain方法,引用计数器就会加一,由于可能存在多线程并发调用的场景,所以它的累加操作必须是线程安全的,下面我们一起看下它的具体实现细节,如图15-42所示。

通过自旋对引用计数器进行加一操作,由于引用计数器的初始值为1,如果申请和释放操作能够保证正确使用,则它的最小值为1。当被释放和被申请的次数相等时,就调用回收方法回收当前的 ByteBuf对象。如果为0,说明对象被意外、错误地引用,抛出IllegalReferenceCountException。如果引用计数器达到整型数的最大值,抛出引用越界的异常IllegalReferenceCountException。最后通过 compareAndSet进行原子更新,它会使用自己获取的值跟期望值进行对比。如果其间已经被其他线程修改了,则比对失败,进行自旋,重新获取引用计数器的值再次比对:如果比对成功则对其加一。注意: compareAndSet是由操作系统层面提供的原子操作,这类原子操作被称为CAS,感兴趣的读者可以看下Java CAS的原理。
下面看下释放引用计数器的代码,如图15-43所示与 retain方法类似,它也是在一个自旋循环里面进行判断和更新的。需要注意的是:
当 refCnt==1时意味着申请和释放相等,说明对象引用已经不可达,该对象需要被释放和垃圾回收掉,则通过调用 deallocate方法来释放 ByteBuf对象。

2.4、UnpooledHeapByteBuf源码分析

UnpooledHeapByteBuf是基于堆内存进行内存分配的字节缓冲区,它没有基于对象池技术实现,这就意味着每次IO的读写都会创建一个新的 UnpooledHeapByteBuf,频繁进行大块内存的分配和回收对性能会造成一定影响,但是相比于堆外内存的申请和释放,它的成本还是会低一些。
相比于 PooledHeapByteBuf, UnpooledHeapByteBuf的实现原理更加简单,也不容易出现内存管理方面的问题,因此在满足性能的情况下,推荐使用 UnpooledHeapByteBuf。
下面我们就一起来看下 UnpooledHeapByteBuf的代码实现。
2.4.1、成员变量
首先看下 UnpooledHeapByteBuf的成员变量定义,如图15-44所示

首先,它聚合了一个 ByteBufAllocator,用于 UnpooledHeapByteBuf的内存分配,紧接着定义了一个byte数组作为缓冲区,最后定义了一个 ByteBuffer类型的 tmpNioBuf变量用于实现 Netty ByteBuf到 JDK NIO ByteBuffer的转换。
事实上,如果使用JDK的 ByteBuffer替换byte数组也是可行的,直接使用byte数组的根本原因就是提升性能和更加便捷地进行位操作。JDK的 ByteBuffer底层实现也是byte数组,代码如图15-45所示。

2.4.2、动态扩展缓冲区
在前一章介绍 AbstractByteBuf的时候,我们讲到 ByteBuf在最大容量范围内能够实现自动扩张,下面我们一起看下缓冲区的自动扩展在 UnpooledHeapByteBuf中的实现,如图15-46所示。
方法入口首先对新容量进行合法性校验,如果大于容量上限或者小于0,则抛出IllegalArgument Exception异常。
判断新的容量值是否大于当前的缓冲区容量,如果大于则需要进行动态扩展,通过byte] new Array= new byte[new Capacity]创建新的缓冲区字节数组,然后通过System.arraycopy进行内存复制,将旧的字节数组复制到新创建的字节数组中,最后调用setArray替换旧的字节数组。如图15-47所示。
需要指出的是,当动态扩容完成后,需要将原来的视图 tmpNioBuf设置为空。

如果新的容量小于当前的缓冲区容量,不需要动态扩展,但是需要截取当前缓冲区创建一个新的子缓冲区,具体的算法如下:首先判断下读索引是否小于新的容量值,如果小于进一步判断写索引是否大于新的容量值,如果大于则将写索引设置为新的容量值(防止越界)。更新完写索引之后通过内存复制 System.arraycopy将当前可读的字节数组复制到新创建的子缓冲区中,代码如下。
System.arraycopy(array, readerIndex, newArray, readerIndex, writerIndex, readerindex);
如果新的容量值小于读索引,说明没有可读的字节数组需要复制到新创建的缓冲区中,将读写索引设置为新的容量值即可。最后调用 setArray方法替换原来的字节数组。
2.4.3、字节数组复制
在前一章节里我们介绍 setBytes(int index,byte]sre, int srcIndex, int length)方法的时候说它有子类实现,下面我们看看UnpooledHeapByteBuf如何进行字节数组的复制。如图15-48所示。

校验 index和 length的值,如果它们小于0,则抛出 IllegalArgumentException,然后对两者之和进行判断;如果大于缓冲区的容量,则抛出 IndexOutOfBoundsException。srcIndex和 srcCapacity的校验与 index类似,不再赘述。校验通过之后,调用System.arraycopy(sre,Index, array, index, length)方法进行字节数组的复制。
需要指出的是, ByteBuf以set和get开头读写缓冲区的方法并不会修改读写索引。
2.4.4、转换成 JDK ByteBuffer
熟悉 JDK NIO Byte uffer的读者可能会想到转换非常简单,因为 ByteBuf基于byte数组实现,NO的 ByteBuffer提供了wrap方法,可以将byte数组转换成 ByteBuffer对象,JDK的相关源码实现如图15-50所示。
大家的猜想是对的,下面我们一起看下 UnpooledHeapByteBuf的实现,如图15-51所示。
我们发现,唯一不同的是它还调用了 ByteBuffer的 slice方法, slice的功能前面已经介绍过了,此处不再展开说明。由于每次调用 nio Buffer都会创建一个新的 ByteBuffer,因此此处的 slice方法起不到重用缓冲区内容的效果,只能保证读写索引的独立性。

2.4.5、子类实现相关的方法

ByteBuf中的一些接口是跟具体子类实现相关的,不同的子类功能是不同的,本小节我们将列出这些不同点
◎ isDirect方法:如果是基于堆内存实现的 ByteBuf,它返回 false,相关的代码实现如图15-52所示。
◎ hasArray方法:由于 UnpooledHeapByteBuf基于字节数组实现,所以它的返回值是true。
◎ array方法:由于 Unpooled Heap Byte Buf基于字节数组实现,所以它的返回值是内部的字节数组成员变量。如图15-53所示。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值