最近在写一些中间件底层的模块,经常需要和netty相关的内容打交道,偶尔会回顾到一些关于nio的知识点,今天正好有空,抽空整理了一些关于nio和netty经常要用到的两个类:ByteBuff 和 ByteBuffer。
ByteBuffer
ByteBuffer是属于nio的一款字节缓冲区类,属于原生jdk内置的。常用到的方式为:
1.首先创建基本对象
2.调用filp方法切换为读模式
3.读取之前写入的数据
4.调用clear或者compact进行缓冲区的数据清除工作
代码案例:
public static void main(String[] args) {
ByteBuffer byteBuffer = ByteBuffer.allocate(20);
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
byteBuffer.put((byte) 4);
//1
byteBuffer.flip();
System.out.println(Arrays.toString(byteBuffer.array()));
byteBuffer.put((byte) 0);
System.out.println(Arrays.toString(byteBuffer.array()));
//2
byteBuffer.compact();
byteBuffer.put((byte) 5);
byteBuffer.put((byte) 6);
System.out.println(Arrays.toString(byteBuffer.array()));
}
最终打印出来的结果为:
[1, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[0, 2, 3, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
[2, 3, 4, 5, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
这里解释一下1和2处的差异点:
1:调用了flip函数之后,程序自身会切换为读模式,position指针会切换到第一位0,因此此时bytebuffer内部的存储数据是正常的1,2,3,4,5。
简单点来理解“读模式”,就是通过变换position指针来实现读取byte数组中未读取过的数据信息。byteBuffer.get()可以按照数组下标的顺序依次获取到byte数组中的每个元素。
2: compact函数的调用其实就是将已经读过的元素给清空,然后从未写入的位置开始继续写入数据。
ByteBuffer底层结构
关于ByteBuffer的底层结构部分,主要需要关心以下几个要点:
position, limit, capactiy
这三个关键字属性分别代表的含义可以通过下方程序来查看:
public static void showByteBuffer(){
ByteBuffer byteBuffer = ByteBuffer.allocate(5);
byteBuffer.put((byte) 1);
byteBuffer.put((byte) 2);
byteBuffer.put((byte) 3);
//1
print(byteBuffer);
//2
byteBuffer.flip();
print(byteBuffer);
//3
byteBuffer.get();
print(byteBuffer);
//4
byteBuffer.compact();
print(byteBuffer);
//5
byteBuffer.clear();
print(byteBuffer);
}
public static void print(ByteBuffer byteBuffer){
System.out.println("byteBuffer limit is:"+byteBuffer.limit()+" position is:"+byteBuffer.position()+" capacity is:"+byteBuffer.capacity()+" bytes[] is :"+ Arrays.toString(byteBuffer.array()));
}
程序执行之后打印的结果为:
byteBuffer limit is:5 position is:3 capacity is:5 bytes[] is :[1, 2, 3, 0, 0]
byteBuffer limit is:3 position is:0 capacity is:5 bytes[] is :[1, 2, 3, 0, 0]
byteBuffer limit is:3 position is:1 capacity is:5 bytes[] is :[1, 2, 3, 0, 0]
byteBuffer limit is:5 position is:2 capacity is:5 bytes[] is :[2, 3, 3, 0, 0]
byteBuffer limit is:5 position is:0 capacity is:5 bytes[] is :[2, 3, 3, 0, 0]
这里我画几张图来解释一下bytebuffer对于数据存储时候的处理机制:
首先来看到1号程序执行的位置:
代码里面往byteBuffer中put了三个数值,分别是1,2,3。而byteBuffer实际上存储的时候是将数据存储为了byte[]数组的格式,此时position会在每一次put操作之后自增+1操作,因此此时position位于第四个索引下标,打印的时候也就显示为3。而capacity和limit两个属性则是在一开始初始化操作的时候就定义好了的,目前依旧保持为5不变。
接着是来看到2号程序位:
由于byteBuffer切换为了读模式,flip之后position会发生变动,并且limit也会做调整。
接着是再看看3号程序位:
光是调整为了读模式还不足,在触发了get函数之后(可以理解为就是调用了一次读取字节内存一个数组的含义),position会在get之后发生自增,因此此时的byteBuffer结果如下:
4号程序位模块:
执行了一次compact函数之后,byteBuffer中会在进行一轮模式的切换,变为写模式,此时会将已经读取过的数据给清空。其实本质就是通过移动数据内部的数据,然后从新的位置写入:
5号程序位执行分析:
网上有些资料讲解说clear函数和compact函数的左右是相似的,但是其实本质上还是有些差异。clear函数也是会对byteBuffer内部进行一轮清空操作,但是只是将position的位置做挪动,调整到0的下标位置,内部存储的元素数值并不会做调整。而调用compact则会将已读的数据进行覆盖。
ByteBuffer不足之处
在对byteBuffer内部构造有了一定原理了解之后,我们可以分析得出它相关的不足之处:
1.内存分配需要预先得知,如果内存不足会在写入到达阈值的时候出现异常( java.nio.BufferOverflowException )。
2.对于各种filp,clear,compact之间的切换,如果不了解底层机制,很容易出现异常,灵活性不足。
针对这些问题,netty框架中提出了一款ByteBuff进行了二次封装,将其进行了优化开发,下边我们来看看这么一段代码:
public static void main(String[] args) {
//DirectByteBuffer 使用堆外内存
ByteBuf byteBuf = Unpooled.buffer(10);
byteBuf.writeByte(1);
byteBuf.writeByte(2);
byteBuf.writeByte(3);
byteBuf.writeByte(4);
byteBuf.writeByte(5);
printByteBuf(byteBuf);
System.out.println(byteBuf.readByte());
System.out.println(byteBuf.readByte());
printByteBuf(byteBuf);
}
private static void printByteBuf(ByteBuf byteBuf){
System.out.println("byteBuf is :"+ Arrays.toString(byteBuf.array())+" rix is :"+byteBuf.readerIndex()+" wix is :"+byteBuf.writerIndex());
}
相关的程序结果为:
byteBuf is :[1, 2, 3, 4, 5, 0, 0, 0, 0, 0] rix is :0 wix is :5
1
2
byteBuf is :[1, 2, 3, 4, 5, 0, 0, 0, 0, 0] rix is :2 wix is :5
在netty封装的ByteBuf里面,包含了两个特殊的指针,分别是writeIndex和readIndex,这两个属性可以帮助我们进行内存的动态扩张,同时也完善了ByteBuffer内部繁琐的模式切换问题。
堆外缓存
在java程序当中,我们比较常见到的就是使用堆来进行内存存储,但是在nio出现之后,ByteBuffer提供了堆外内存分配的机制帮助我们实现对外内存的存储。
先来解释一下几个名词:
堆内存:也就是我们常常说的jvm内部的堆内存模块,这部分的内存是自带有回收机制的,当内存不足的时候会自动进行gc回收,从而释放掉不需要的内存空间。
堆外内存:该模块的内存都没有受到jvm的垃圾回收进行管理,由于不受到gc的影响,堆外内存有了一个比较常见的落地场景—缓存。JDK的ByteBuffer类提供了一个接口allocateDirect(int capacity)进行堆外内存的申请,底层通过unsafe.allocateMemory(size)实现。
堆外内存的代码案例
public static void test(){
//分配128MB直接内存
ByteBuffer bb = ByteBuffer.allocateDirect(1024*1024*128);
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("ok");
}
这段代码在启动的时候可以尝试加入以下参数,并且进行参数值的灵活调整。
-Xmx100m
-XX:MaxDirectMemorySize=150M
java应用在本身运行的时候,如果没有设置MaxDirectMemorySize参数,那么就会将该参数设置为和-Xmx一致。如果堆外内存的分配过多,超过了设置阈值,就会抛出java.lang.OutOfMemoryError: Direct buffer memory的异常信息。
ps:熟悉kafka的小伙伴应该会有接触过类似的异常问题排查。
堆外缓存的落地
有一个叫做Ehcache的缓存组件就有使用过堆外内存进行实现,感兴趣的小伙伴可以后边研究一下。