Netty基础必备知识,ByteBuffer和ByteBuf底层原理

发布时间:2022-03-01 10:05:44 作者:yexindonglai@163.com 阅读(814)

前言

本文章只讨论ByteBufferByteBuf的底层结构的区别,如果想要了解堆内内存和堆外内存的区别,请看我的另一篇文章:java堆外内存详解(又名直接内存)和ByteBuffer

什么是Buffer

中文称为缓冲区,指的是从网络或者文件读写数据的时候,在他们中间多了个缓冲区,应用程序只需要对着缓冲区 进行读写即可;然后缓冲区在将数据复制到内核或者从内核读取数据;这种方式加快读写速度,减少了IO次数;小文件的读写用不用缓冲区速度都没有多大区别,但是当我们进行大文件进行读写的时候一般都会使用到缓冲区;读写效率会以倍数增长;

为什么需要Buffer

在我们刚学习IO的时候,写入文件都是使用FileInputStream或者FileOutputStream类来读取/写入,但是这种方式是你每调用一次write()或者read()方法都是直接将数据写到到内核中,再由内核复制到磁盘中,每次都需要在内核态和用户态频繁切换,这些切换的工作都是需要系统资源开销的,特别是切换太频繁的话,读写效率就会下降;所以这边会推荐大家使用BufferedOutputStream,当缓冲区的数据大小到达8KB时才会写入文件;
在这里插入图片描述

ByteBuffer

当我们在文件或者网络进行数据传输的时候,往往需要使用到缓冲区,常用的缓冲区就是JDK NIO类库提供的java.nio.Buffer;基本上每个基本的数据类型都有缓冲区(Boolean除外)

  1. java.nio.ByteBuffer;
  2. java.nio.CharBuffer;
  3. java.nio.DoubleBuffer;
  4. java.nio.FloatBuffer;
  5. java.nio.IntBuffer;
  6. java.nio.LongBuffer;
  7. java.nio.ShortBuffer;

一般来说,ByteBuffer 就已经能够满足IO的编程需要了,ByteBuffer 是java NIO(new IO)自带的类,主要有以下特点:

  1. 长度一旦设定,不可扩容或收缩,要扩容只能创建一个新的ByteBuffer 对象;
  2. ByteBuffer 内部有一个指针位置position,通过移动指针可实现灵活的读写功能,读写时可通过调用flip()方法进行翻转指针位置;
  3. 支持堆内和堆外分配;
  4. 使用者必须小心谨慎地处理这些API,否则很容易导致程序处理失败;

ByteBuffer 内部结构

ByteBuffer 内部有一个byte[]数组,我们添加进去的字节就是加入到这个数组里面的,除此之外,内部还维护了4个指针

  1. position :默认为0;当前下标的位置,表示下一个读/写的起始位置,每写一个字节 或者每读一个字节 position就 + 1;
  2. capacity:缓冲区大小,也就是数组的大小,一旦指定,不可修改;
  3. limit:结束标记位置,表示进行下一个读写操作时的结束位置;
  4. mark : 用户可通过调用mark()方法标记position的当前位置,标记后,在后面的读写发生问题时可通过调用reset() 方法回退到标记位置;

代码示例

  1. @Test
  2. public void main() {
  3. // 如果添加的元素超过buffer大小,会抛出BufferOverflowException异常
  4. ByteBuffer buffer = ByteBuffer.allocate(10);
  5. showPosition(buffer);
  6. // 将2个字节的数据写入缓冲区
  7. buffer.put((byte) 34);
  8. buffer.put((byte) 78);
  9. showPosition(buffer);
  10. buffer.flip();// 翻转后可进行读取
  11. //初始化字节数组,用来读取内存
  12. byte[] bytes = new byte[buffer.limit()];
  13. // 进行读取
  14. buffer.get(bytes);
  15. // 讲读取到的内容打印出来
  16. System.out.println(Arrays.toString(bytes));
  17. showPosition(buffer);
  18. // 清除缓冲区,此方法并不是直接清楚buffer内的数组内容,而是将position和limit复位
  19. buffer.clear();
  20. showPosition(buffer);
  21. }
  22. // 显示位置
  23. public void showPosition(ByteBuffer buffer) {
  24. // position 默认为0;当前下标的位置,表示下一个读/写的起始位置,每写一个字节 position就+1;
  25. System.out.println("position 当前位置:" + buffer.position());
  26. // capacity 缓冲区的大小,一旦指定,不可修改;
  27. System.out.println("capacity 缓冲区大小:" + buffer.capacity());
  28. // limit 结束标记位置,表示进行下一个读写操作时的结束位置;
  29. System.out.println("limit 结束标记位置:" + buffer.limit());
  30. try {
  31. // 打印mark 标记位置,mark在Buffer抽象类中,且是私有属性,所以通过反射获取
  32. Field mark = Buffer.class.getDeclaredField("mark");
  33. mark.setAccessible(true);
  34. System.out.println("mark 标记位置:" + mark.get(buffer));
  35. } catch (Exception e) {
  36. e.printStackTrace();
  37. }
  38. System.out.println();
  39. }

运行后,打印结果如下,这边就可以看到每走一步后具体的位置下标了,mark标记的值为-1,是因为在代码中并没有调用mark()进行标记了所以为-1

  1. position 当前位置:0
  2. capacity 缓冲区大小:10
  3. limit 结束标记位置:10
  4. mark 标记位置:-1
  5. position 当前位置:2
  6. capacity 缓冲区大小:10
  7. limit 结束标记位置:10
  8. mark 标记位置:-1
  9. [34, 78]
  10. position 当前位置:2
  11. capacity 缓冲区大小:10
  12. limit 结束标记位置:2
  13. mark 标记位置:-1
  14. position 当前位置:0
  15. capacity 缓冲区大小:10
  16. limit 结束标记位置:10
  17. mark 标记位置:-1

什么?看不懂? 没关系,我画图给你看,走了每一行代码之后内部结构的变化

1、初始化:ByteBuffer buffer = ByteBuffer.allocate(10)

创建一个堆内内存的ByteBuffer 实例,缓冲区大小为10,此时数组内还没有数据,position 指针在0的位置,所以目前数组内的数据都为0;
在这里插入图片描述

2、写入:buffer.put(byte)

在这一环节中往缓冲区写入了2个字节;

  1. buffer.put((byte) 34);
  2. buffer.put((byte) 78);

写完后,position向右移动了2个位置,表示写到了某位置,下次写一个字节时就会往当前的position位置上写入;
在这里插入图片描述

3、翻转:buffer.flip()

如果需要进行读取了,就可以调用翻转方法,翻转后,position的位置又回到了第一个位置,并且limit结束符也到了第2个位置(从0开始算),<font color="red">需要注意的是:如果现在读取或者写入超过了2个字节,将会抛出异常:BufferOverflowException,因为不管在任何情况下,都不能写入或读取超过(limit - position)个字节</font>
在这里插入图片描述

4、读取:buffer.get(bytes)

此时position的位置已经在第一个上面了,所以读取也是从第一个进行读取的,<font color="red">注意:如果现在写入新的字节,将会覆盖之前写入的数据;</font>

  1. byte[] bytes = new byte[buffer.limit()];
  2. buffer.get(bytes);
  3. System.out.println(Arrays.toString(bytes));

在这里插入图片描述

5、清除缓冲区:buffer.clear()

清除缓冲区,clear()方法并不是直接清楚buffer内的数组内容,而是将position和limit复位,position会回到0的位置,limit也会回到数组末尾位置;刚刚加入的数据还是存在数组内部的;
在这里插入图片描述

拷贝 duplicate()

内部还提供了一个方法可以讲缓冲区进行拷贝,但是这个拷贝后内部的数组和源对象的数组其实是共享的,只是重新包装了一下,也就是位置变量(position、limit)不同而已,

  1. ByteBuffer buffer = ByteBuffer.allocate(10);
  2. buffer.put((byte) 34);
  3. buffer.put((byte) 78);
  4. // 拷贝
  5. ByteBuffer duplicate = buffer.duplicate();
  6. buffer.put((byte) 77);
  7. buffer.put((byte) 77);

执行后看下图,两个数组的地址是一样的;
在这里插入图片描述

flip()rewind()的区别

看源码就可以得知,flip()只是多了一个结束位的配置,因为limit是限制位,也就是说调用了flip()后可以写入或者读取的数据是根据当前的position来决定的,而rewind()方法则可以写完或者读完数组中的所有内容;

  1. public final Buffer flip() {
  2. limit = position;
  3. position = 0;
  4. mark = -1;
  5. return this;
  6. }
  7. public final Buffer rewind() {
  8. position = 0;
  9. mark = -1;
  10. return this;
  11. }

ByteBuf

ByteBuf是Netty通过ByteBuffer的原理自己封装的一个类,使用时必须先加入netty依赖才可使用;

  1. <dependency>
  2. <groupId>io.netty</groupId>
  3. <artifactId>netty-all</artifactId>
  4. <version>4.1.49.Final</version>
  5. </dependency>

ByteBuf 和 ByteBuffer的区别

  • 和ByteBuffer最大的区别就是ByteBuf的读写指针是分开的,也就是说ByteBuf内部有一个读指针(readerIndex)和一个写指针(writerIndex),因此读写时不需要翻转指针;而ByteBuffer只有一个position指针,读写需要调用flip()或者rewind()方法进行翻转;
  • 和ByteBuffer一样,ByteBuf也支持堆内内存和直接内存的分配,且直接内存都是用Unsafe类实现的;
  • 和ByteBuffer最大的不同,就是ByteBuf支持内存池,了解过数据库连接池和线程池的童鞋肯定不陌生,内存池的设计可以加快效率和提高减少资源消耗;

初始化ByteBuf
实例化ByteBuf有四种方式,分别是

  • 堆内非池化
  • 堆内池化
  • 堆外非池化
  • 堆外池化

在java代码种实例化方式如下

  1. // 堆内非池化
  2. public ByteBuf heapInnerUnpool(){
  3. return UnpooledByteBufAllocator.DEFAULT.heapBuffer(10,100);
  4. }
  5. // 堆内池化
  6. public ByteBuf heapInnerPool(){
  7. return PooledByteBufAllocator.DEFAULT.heapBuffer(10,100);
  8. }
  9. //堆外非池化
  10. public ByteBuf heapOutUnpool(){
  11. return UnpooledByteBufAllocator.DEFAULT.buffer(10,100);
  12. }
  13. //堆外池化
  14. public ByteBuf heapOutPool(){
  15. return PooledByteBufAllocator.DEFAULT.buffer(10,100);
  16. }

ByteBuf 内存池

什么是内存池
从netty 4开始,netty加入了内存池管理,采用内存池管理比普通的ByteBuf性能提高了数十倍;这也是为什么netty快的原因,ByteBuf 支持2种模式,池化和非池化, 池化就是使用内存池,非池化就是不使用内存池,这个很好理解。

为什么要使用内存池

  • 在未使用池化之前,每次创建一个ByteBuf 都都需要先向操作系统申请一块内存,并且为这个对象进行实例化初始化引用赋值;这些过程都是需要消耗CPU资源的;
  • 将ByteBuf池化之后,只有第首次创建对象会进行实例化初始化引用赋值,默认大小16MB,以后使用的时候就直接使用首次创建的对象就可以了;

验证内存池

现在我们来做一个试验,创建2个池化的ByteBuf对象,看看内部是否使用同一块内存空间

  1. ByteBuf byteBuf_one = PooledByteBufAllocator.DEFAULT.heapBuffer(10, 20);
  2. ByteBuf byteBuf_two = PooledByteBufAllocator.DEFAULT.heapBuffer(20, 40);

在idea上使用debug功能后发现,在memory这个属性里面存放就是byte[]数组,而byteBuf_onebyteBuf_two 使用的内存地址都是相同的,这足以证明它们使用的是同一块内存地址;
在这里插入图片描述

除此之外,在上图种我们还看到一个offset的属性,这个属性就是偏移量,在一个内存池中默认给每个ByteBuf 分配了8192byte的空间,也就是说内存池中0 - 8191 是分配给 byteBuf_one的,而 8192 - 16383 是分配给byteBuf_two的;

读写示例

我们将测试以下代码,并且画出内部结构图,并且分析每一行代码的走向,准备好了吗?

  1. @Test
  2. public void test(){
  3. // 使用内存池
  4. ByteBuf buffer = PooledByteBufAllocator.DEFAULT.buffer(10,10);
  5. print(buffer);
  6. buffer.writeBytes(new byte[]{1,2,3,4,5});
  7. print(buffer);
  8. // 读取2个字节
  9. byte[] bytes = new byte[2];
  10. buffer.readBytes(bytes, buffer.readerIndex(), 2);
  11. System.out.println(Arrays.toString(bytes));
  12. print(buffer);
  13. // 丢弃已读字节;
  14. buffer.discardReadBytes();
  15. print(buffer);
  16. // 设置读取位置,从0开始,相当于设置ByteBUffer的position值
  17. buffer.readerIndex(2);
  18. print(buffer);
  19. // 释放内存空间
  20. buffer.release();
  21. }
  22. //打印 ByteBuf 信息
  23. public void print(ByteBuf buf){
  24. System.out.println("默认大小:"+buf.capacity());
  25. System.out.println("最大值:"+buf.maxCapacity());
  26. System.out.println("是否可读:"+buf.isReadable());
  27. System.out.println("可读的字节数:"+buf.readableBytes());
  28. System.out.println("读的位置:"+buf.readerIndex());
  29. System.out.println("是否可写:"+buf.isWritable());
  30. System.out.println("可写字节的字节数:"+buf.writableBytes());
  31. System.out.println("写的位置:"+buf.writerIndex());
  32. System.out.println("是否堆外分配:"+buf.isDirect());
  33. System.out.println("-------------------------");
  34. }

打印结果如下

  1. 默认大小:10
  2. 最大值:10
  3. 是否可读:false
  4. 可读的字节数:0
  5. 读的位置:0
  6. 是否可写:true
  7. 可写字节的字节数:10
  8. 写的位置:0
  9. 是否堆外分配:true
  10. -------------------------
  11. 默认大小:10
  12. 最大值:10
  13. 是否可读:true
  14. 可读的字节数:5
  15. 读的位置:0
  16. 是否可写:true
  17. 可写字节的字节数:5
  18. 写的位置:5
  19. 是否堆外分配:true
  20. -------------------------
  21. [1, 2]
  22. 默认大小:10
  23. 最大值:10
  24. 是否可读:true
  25. 可读的字节数:3
  26. 读的位置:2
  27. 是否可写:true
  28. 可写字节的字节数:5
  29. 写的位置:5
  30. 是否堆外分配:true
  31. -------------------------
  32. 默认大小:10
  33. 最大值:10
  34. 是否可读:true
  35. 可读的字节数:3
  36. 读的位置:0
  37. 是否可写:true
  38. 可写字节的字节数:7
  39. 写的位置:3
  40. 是否堆外分配:true
  41. -------------------------
  42. 默认大小:10
  43. 最大值:10
  44. 是否可读:true
  45. 可读的字节数:1
  46. 读的位置:2
  47. 是否可写:true
  48. 可写字节的字节数:7
  49. 写的位置:3
  50. 是否堆外分配:true
  51. -------------------------

接下来我们开始分析ByteBuf内部结构走向

1、实例化:ByteBuf buffer = PooledByteBufAllocator.DEFAULT.buffer(10,10)

因为我们用到了PooledByteBufAllocator,所以这里使用的是内存池;效率更快,这行代码是实例化了ByteBuf,创一个堆外分配的对象;虽然我们只用到了10个字节,但是内存池给这个实例分配了8192byte的字节空间;所以 0 ~ 8191 的字节是给ByteBuf 占用了的;
在这里插入图片描述

2、写入缓冲区:buffer.writeBytes(new byte[]{1,2,3,4,5})

这行代码很简单,就是往缓冲区写入了5个字节,写入后,结构如下
在这里插入图片描述

3、读取2字节:buffer.readBytes(bytes, buffer.readerIndex(), 2)

读取2个字节内容,并打印出来;这边读取到的内容为 1和2,也就是前2个元素

  1. byte[] bytes = new byte[2];
  2. // 将读取到的内容放入bytes,第二个参数是读取的起始位置,第三个参数是你需要读取几个字节的数据;注意不要超过最大容量;
  3. buffer.readBytes(bytes, buffer.readerIndex(), 2);
  4. System.out.println(Arrays.toString(bytes));

在这里插入图片描述

4、丢弃已读字节:buffer.discardReadBytes();

这个方法会将已读的字节删除,过程中需要的开销应该会比较大,基于数组的特性,插入删除比较慢,因为得需要移动比较多的元素指针,删除后结构如下图:
在这里插入图片描述

5、自定义读取位置:buffer.readerIndex(2);

这种方法相当于设置ByteBUffer的position值,这边将读取位置指向了2的位置,所以2之前的位置就会被认为是已经读取过了;
在这里插入图片描述

6、释放缓冲区:buffer.release()

因为是内存池堆外分配的,所以每次用完之后都需要手动释放,释放后,内部的memory数组就是空的了,表示已经被释放成功了,这时候这个变量就不能在使用了,会等待垃圾回收将其清理;
在这里插入图片描述

动态扩容

ByteBuf 在实例化时有2个参数,初始容量(initialCapacity)和 最大容量(maxCapacity),也就是说,实例化后,缓冲区的容量就是10,当你写入的字节数超过10个时(比如11)就会进行扩容;

  1. int initialCapacity = 10;
  2. int maxCapacity = 20;
  3. UnpooledByteBufAllocator.DEFAULT.heapBuffer(initialCapacity ,maxCapacity );

如何扩容?

知道ByteBuf会扩容,那它是什么时候进行扩容呢?每次扩多少呢?其实啊,ByteBuf 没有负载因子一说,只有当容量不足时才会扩容;如果你的容量为10,而你写入的字节数也是10,那么这种情况不会进行扩容,当你的字节数到达11个时才会扩容;如果你的最大容量是20,那么它就会扩到20;
在这里插入图片描述
如果我的最大容量有511呢?

当容量不足64时,会扩容到64,以后开始从64字节每次增加2倍,以下面的代码为例

  1. ByteBuf byteBuf = UnpooledByteBufAllocator.DEFAULT.heapBuffer(10, 511);
  2. System.out.println("初始容量:"+byteBuf.capacity() + ",当前已写入字节数:"+byteBuf.writerIndex());
  3. byteBuf.writeBytes( new byte[64]);
  4. System.out.println("第一次扩容,写入64字节 ,当前容量:"+byteBuf.capacity() + ",当前已写入字节数: "+byteBuf.writerIndex());
  5. byteBuf.writeBytes( new byte[64]);
  6. System.out.println("第二次扩容,写入64字节 ,当前容量:"+byteBuf.capacity() + ",当前已写入字节数:"+byteBuf.writerIndex());
  7. byteBuf.writeBytes( new byte[128]);
  8. System.out.println("第三次扩容,写入128字节,当前容量:"+byteBuf.capacity() + ",当前已写入字节数:"+byteBuf.writerIndex());
  9. byteBuf.writeBytes( new byte[1]);
  10. System.out.println("第四次扩容,写入1字节 ,当前容量:"+byteBuf.capacity() + ",当前已写入字节数:"+byteBuf.writerIndex());

打印结果如下

  1. 初始容量:10,当前已写入字节数:0
  2. 第一次扩容,写入64字节 ,当前容量:64,当前已写入字节数: 64
  3. 第二次扩容,写入64字节 ,当前容量:128,当前已写入字节数:128
  4. 第三次扩容,写入128字节,当前容量:256,当前已写入字节数:256
  5. 第四次扩容,写入1字节 ,当前容量:511,当前已写入字节数:257

扩容时序图如下
在这里插入图片描述

mark标记和回退

ByteBuffer和ByteBuf都支持标记,只是用法不同而已,进行标记后,不管你下一步是读还是写,执行reset()方法后都能回到标记位置;

ByteBuffer标记

  1. ByteBuffer buffer = ByteBuffer.allocate(10);
  2. // 将数据写入缓冲区
  3. buffer.put((byte) 34);
  4. buffer.put((byte) 78);
  5. // 标记当前位置
  6. buffer.mark();
  7. // 继续写入
  8. buffer.put((byte) 96);
  9. // 回退到标记位置
  10. buffer.reset();

ByteBuf标记

  1. ByteBuf byteBuf = UnpooledByteBufAllocator.DEFAULT.heapBuffer(10, 511);
  2. // 标记读的位置
  3. byteBuf.markReaderIndex();
  4. // 标记写的位置
  5. byteBuf.markWriterIndex();
  6. // 回退到读的标记位置
  7. byteBuf.resetReaderIndex();
  8. //回退到写的标记位置
  9. byteBuf.resetWriterIndex();

关键字Netty