网络编程中,当我们要进行数据传输时,往往需要使用缓冲区,常用的缓冲区就是Java NIO类库提供的java.io.buffer,在NIO编程时,我们主要使用的是ByteBuffer。从功能角度而言,ByteBuffer完全满足NIO编程的需要,但是由于NIO编程的复杂性,ByteBuffer也有其局限性,它的主要缺点如下:
- ByteBuffer长度固定,一旦分配完成,它的容量不能动态扩展和收缩,当需要编码的POJO对象大于ByteBuffer的容量时,会发生索引越界异常;
- ByteBuffer只有一个标识位置的指针position,读写的时候需要手工调用flip()和rewind()等方法,使用者必须小心谨慎的处理这些API,否则很容易导致程序处理失败;
- ByteBuffer的API功能有限,一些高级和实用的特性它并不支持,需要使用者自己编程实现。
为了弥补这些不足,Netty提供了自己的ByteBuffer实现—ByteBuf,下边看一下ByteBuf的原理和主要功能。
一、ByteBuf的工作原理
首先,ByteBuf依然是Byte数组的缓冲区,它的基本功能与JDK的ByteBuffer基本一致,提供以下几类基本功能:
- 7种Java基本类型、byte数组、ByteBuffer等的读写;
- 缓冲区自身的copy与slice等;
- 设置网络字节序;
- 构造缓冲区实例;
- 操作位置指针等方法。
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 如下图1所示:
图1:初始分配的ByteBuf
写入N个字节之后的 ByteBuf 如图2所示:
图2:写入N个字节之后的ByteBuf
读取M(<N)个字节之后的 ByteBuf 如图3所示:
图3:读取M(<N)个字节之后的ByteBuf
调用discardReadBytes操作之后的 ByteBuf 如图4所示:
图4:调用discardReadBytes操作之后的ByteBuf
调用clear操作之后的 ByteBuf 如图5所示:
图5:调用clear操作之后的ByteBuf
下面我们分析ByteBuf是如何实现动态扩展的。通常情况下,我们对ByteBuffer进行put操作时,如果缓冲区剩余可写空间不足,就会发生BufferOverflowException异常。需要重新创建一个新的ByteBuffer,并将之前的ByteBuffer复制到新的ByteBuffer中,然后释放老的ByteBuffer。
ByteBuf对write操作进行了封装,由ByteBuf的write操作负责进行剩余可用空间的校验。如果可用缓冲区不足,ByteBuf会自动进行动态扩展。对于使用者而言,不需要关心底层的校验和扩展细节,只要不超过设置的最大缓冲区容量即可。当可用空间不足时,ByteBuf会帮助我们实现自动扩展,这极大地降低了ByteBuf的学习和使用成本,提升了开发效率。
使用建议:为了保证安全性,可指定ByteBuf的最大容量,在容量范围内,可以先分配一个较小的初始容量,不够用时可自动扩展,这样可以达到功能和性能的最佳组合。
Mark和Rest
当对缓冲区进行读操作时,由于某种原因,可能需要对之前的操作进行回滚。读操作并不会改变缓冲区的内容,回滚操作主要就是重新设置索引信息。
对于JDK的ByteBuffer,调用mark操作会将当前的位置指针备份到mark变量中,当调用rest操作之后,重新将指针的当前位置恢复为备份在mark中的值。
Netty的ByteBuf也有也有类似的mark和rest接口,因为ByteBuf有读索引和写索引,因此,它公有4个相关的方法,分别如下:
- markReaderIndex:将当前的readerIndex备份到markedReaderIndex中;
- resetReaderIndex:将当前的readerIndex设置为markedReaderIndex;
- markWriterIndex:将当前的writerIndex备份到markedWriterIndex中;
- resetWriterIndex:将当前的writerIndex设置为markedWriterIndex;
二、ByteBuf的内存分配和回收
从内存分配的角度看,ByteBuf可以分为两类:
- 堆内存(HeapByteBuf)字节缓冲区:特点是内存的分配和回收速度快,可以被JVM自动回收,缺点就是如果进行Socket的I/O读写,需要额外做一次内存复制,将堆内存对应的缓冲区复制到内核Channel中,性能会有一定程度的下降。
- 直接内存(DirectByteBuf)字节缓冲区:非堆内存,它在堆外进行内存分配,相比于堆内存,它的分配和回收速度相对会慢一些,但是它写入或是从Socket Channel中读取时,由于少了一次内存复制,速度比堆内存块。
正是由于各有利弊,Netty提供了多种ByteBuf供开发者使用,经验表明,ByteBuf的最佳实践是在I/O通信线程的读写缓冲区使用DirectByteBuf,后端业务消息的编解码模块使用HeapByteBuf,这样组合可以达到性能最优。
从内存回收角度看,ByteBuf也分为两类:基于对象池的ByteBuf和普通ByteBuf。两者的主要区别就是基于对象池的ByteBuf可以复用ByteBuf对象,它自己维护了一个内存池,可以循环利用创建的ByteBuf,提升内存的使用效率,降低由于高负载导致的频繁GC。测试表明使用内存池后的Netty在高负载、大并发的冲击下内存和GC更加平稳。
尽管推荐使用基于内存池的ByteBuf,但是内存池的管理和维护更加复杂,使用起来也需要更加谨慎,因此,Netty提供了灵活的策略供使用者来做选择。
小提示:相比于PooledHeapByteBuf,UnpooledHeapByteBuf的实现原理更加简单,也不容易出现内存管理方面的问题,因此在满足性能的前提下,推荐使用UnpooledHeapByteBuf。