关键词:ByteBuffer NIO
ByteBuffer是java.nio包下的类,看见nio就会想到,它与非阻塞IO是有关系的。ByteBuffer有很多兄弟姐妹,比如:CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer等。这些类都有共同的父类Buffer。见名思意,ByteBuffer是操作字节数据的,IntBuffer是操作整形数据的。在ByteBuffer维护了一个byte[] hb数组,在IntBuffer里维护了一个int[] hb数组。
█ 总体认识
类图:

相关概念:
ByteBuffer分两种,Direct和non-direct buffers。Direct buffer有MappedByteBuffer、DirectByteBuffer、DirectByteBufferR。non-direct buffer有HeapByteBuffer和HeapByteBufferR。Direct buffer即操作直接内存,直接内存指内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。通过调用方法allocateDirect(int)来获取直接内存buffer。non-direct buffer即操作堆内存。通过方法allocate(int)获取。(点击查看直接内存的解释)。HeapByteBufferR和DirectByteBufferR,在拼写上多了个R,这个R是read-only(只读)的意思。
ByteBuffer继承自Buffer,在Buffer中定义了下面四个int类型的属性:
capacity:一个buffer的capacity是指它所包含的元素数量。该值永远不会为负数,也永远不会改变。(给定了初始值之后就不会改变了)。对于non-direct buffers,在内部维护一个字节数组用于存放元素,这里的capacity大小也就是指定了这个数组的大小。
limit:是buffer中第一个不能够被读取或写入的元素的索引值(元素是存放在数组中的,元素的索引值即数组的下标),该值永远不会为负数,并且不能比capacity值大。
position:和limit相对,是buffer中第一个能够被读取或写入的元素的索引值。该值永远不会为负数,并且不能比limit值大。初始化的时候为0,每往buffer存放数据,position的值都会加1。
mark:定义了这样的一个元素索引值,当调用reset方法时,position的值就等于mark值。若没有定义,调用reset方法会报InvalidMarkException。这个值从来不会被定义,但是一旦被定义,该值永远不会为负数,并且不能比position的值大。当定义了mark,如果调整之后的position或limit的值小于了mark值,则mark值会失效(值为-1)。mark未被定义时的值是-1,一旦被定义,值就不能是负数了。
由此可见,上面四个属性的大小关系是:0<=mark<=position<=limit<=capacity。一个新创建的buffer,position初始化为0,mark不会被定义,即值为-1。从limit、position的定义可以看出,最大的数据读取或写入长度是limit减去position。若position=0,limit=5,则此时最大的读取存放数据的长度就是5。数组的下标范围其实是从0到4。可以理解成是一种左闭右开的关系,即包括position的位置,不包括limit的位置。也可以说是读取存放数据的数组下标是从position开始到limit-1的下标位置。记住position和limit的意义和关系,就方便理解ByteBuffer了。
还有一个属性:offset。当前与position与下一次position(position+1)的偏移量。是在position加1之后再进行相加的值,来确定元素的起始位置。默认值为0。可以认为是通过position和offset来确定元素的数组下标的。
若offect=0,添加元素时的情况是这样的:

若offect=2,添加元素时的情况是这样:

█ non-direct buffers
①创建初始化
// 通过方法创建堆内buffer,这里是创建一个capacity为10的buffer。
// buffer在内部维护了一个字节数组final byte[] hb,这里的10也就是初始化了数组的长度。
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
// 创建的是HeapByteBuffer对象,第一个参数是指定capacity的值,第二个参数是指定limit的值。
return new HeapByteBuffer(capacity, capacity);
}
HeapByteBuffer(int cap, int lim) {
// 注意这里的new byte[cap]
// supper调用了ByteBuffer的构造函数
super(-1, 0, lim, cap, new byte[cap], 0);
}
ByteBuffer(int mark, int pos, int lim, int cap,
byte[] hb, int offset)
{
super(mark, pos, lim, cap);
// 初始化了一个大小为capacity的字节数组,并赋给内部维护的字节数组。
this.hb = hb;
this.offset = offset;
}
如图:(0,1,2,3....表示数组的下标,不是元素的值)

下面程序的输出结果是什么呢?会输出10个0,可见Buffer初始化时,元素的默认值是0,因为内部维护的字节数组是基本类型byte类型的嘛。简单类比一下,ByteBuffer.allocate(10)就相当于byte[] hb = new byte[10];
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
// 通过array可获取到buffer内部维护的数组
byte[] array = byteBuffer.array();
for (byte b : array) {
System.out.println(b);
}
除了通过allocate创建一个没有元素的空的buffer,还可以在给定字节数组的基础上,创建buffer,即调用wrap方法。
byte[] bytes = "helloworld".getBytes();
ByteBuffer.wrap(bytes);
②存数据(操作单个byte)
byte[] bytes = "helloworld".getBytes();
byteBuffer.put(bytes[0]);
// put是一个抽象方法,具体看子类的实现
public abstract ByteBuffer put(byte b);
public ByteBuffer put(byte x) {
// 先查看当前position的位置,再确定元素的起始下标
hb[ix(nextPutIndex())] = x;
return this;
}
final int nextPutIndex() {
// 前置校验,position的值是不能大于limit的值的
if (position >= limit)
throw new BufferOverflowException();
// position的值加1,此时从初始化的0变成了1
// 这里是position先返回当前值,再自增,即返回值是0,而不是1
return position++;
}
// 确定添加进来的元素在字节数组中的起始下标
protected int ix(int i) {
// offset默认为0
return i + offset;
}
如图,position从0移动到了1。数组的第一个元素内容是h:

③取数据(操作单个byte)
byte result = byteBuffer.get();
System.out.println(result);
// get也是抽象方法,具体的看子类实现
public abstract byte get();
public byte get() {
// 也是通过两个方法,先确定position的位置,再来确定元素的下标位置
return hb[ix(nextGetIndex())];
}
final int nextGetIndex() {
if (position >= limit)
throw new BufferUnderflowException();
// 返回当前position的值,然后再自增。
return position++;
}
发现输出结果为0,而不是h的ascill码。这是为什么呢?
// 分析这个方法,通过存数据的操作后,position的位置就变成了1,nextGetIndex获取到的值就是1,然后通过ix还是1,此时拿的是hb[1]的值,而我们的
// h元素是放在了下标0的位置。这明显不对。因为是通过position来确定元素的位置的,那position的值是0就对了,所以需要将position的值置为0。
public byte get() {
return hb[ix(nextGetIndex())];
}
查看api,可以发现提供了一个position(int)方法能够改变position的值。
// 将position的值更改成0,即buffer数组的起始位置
byteBuffer.position(0);
byte result = byteBuffer.get();
System.out.println(result);
④存数据(操作byte数组)
put不仅可以一次put一个byte,还可以一次性put一个byte数组。
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "helloworldhahahha".getBytes();
// 直接放进整个数组,方法里面对数据进行for循环,调用了put(byte)方法。
byteBuffer.put(bytes);
结果会抛异常:java.nio.BufferOverflowException
查看put方法:
public final ByteBuffer put(byte[] src) {
return put(src, 0, src.length);
}
public ByteBuffer put(byte[] src, int offset, int length) {
checkBounds(offset, length, src.length);
// 此时的length是数组的长度,即helloworldhahahha的长度,为:17
// remaining()方法是:limit - position的值,limit是初始化的值为10,position的值是初始化的0,所有结果为10
if (length > remaining())
// 抛的是这里的异常
throw new BufferOverflowException();
int end = offset + length;
// 循环数组,依次调用put方法
for (int i = offset; i < end; i++)
this.put(src[i]);
return this;
}
下面的例子,也会抛同样的异常,回顾一下存数据(操作单个byte)中将的,在put(byte)中可以找到这样的代码:if (position >= limit) throw new BufferOverflowException(); 当调用put方法时,position的值会随着每一次调用后自增1,而limit的值是不会变化的,当调用10次put方法后,position的值就是10了,而limit的值始终没有变化,还是10,此时若继续调用put方法,就满足了position >= limit的条件,就会抛出异常了。
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "helloworldhahahha".getBytes();
//byteBuffer.put(bytes);
for (int i = 0; i < bytes.length; i++) {
byteBuffer.put(bytes[i]);
}
改动一下bytes数组的大小就不会出现异常了。
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "helloworld".getBytes();
//byteBuffer.put(bytes);
for (int i = 0; i < bytes.length; i++) {
byteBuffer.put(bytes[i]);
}
如图:

⑤取数据(操作byte数组)
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "helloworld".getBytes();
byteBuffer.put(bytes);
byte[] dest = new byte[bytes.length];
byteBuffer.get(dest);
System.out.println(new String(dest));
结果会抛出java.nio.BufferUnderflowException异常,查看源代码:
// length就是dest数组的长度,remaining是limit - position,此时limit是10,position也是10
if (length > remaining())
throw new BufferUnderflowException();
在调用get方法之前,先调用一下clear或flip方法,让position回到初始化0的位置即可,程序正确输出:helloworld
byteBuffer.position(0);
byteBuffer.get(dest);
关于读取或存放数据,最主要的就是操作position和limit的位置。
⑥flip()和clear()
api提供了position(int)和limit(int)来改变position和limit的位置,但如果读取存放数据的频率很频繁,不一定能够清晰地知道position和limit的确定的位置。好在api提供了方法能够一次性调节position和limit的位置,减轻了我们的麻烦。
先来看个例子,此时先不调整position的位置:
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "hello".getBytes();
byteBuffer.put(bytes);
byte[] dest = new byte[bytes.length];
byteBuffer.get(dest);
System.out.println(new String(dest));
buffer的长度是10,往里面存放了5个长度的数据,此时position的位置是5,limit还是10,调用get方法,取不到数据,看不见输出结果。(其实dest数组中的元素都是0)。如图:上面程序取值是从position的位置开始取,取dest数组长度个字节。即图中空白处为0的5个框框。

相信你知道该怎么做能够正确读取到hello的内容了。但仅仅移动position的位置的话,会有点问题。我们知道了,当读取数据的时候,是从position的下标位置开始读的,读取参数数组的长度或一直到limit的位置。position为0了,能够读取的数据的最大范围就是0-9。但从5-9的位置的数据都是无效的数据嘛,这明显不是我们需要的。那如果能够将limit的位置移动到有效数据的位置是不是会好一点,也就是将limit的位置移动到下标4的位置。查看代码,发现flip的方法实现可以满足我们的需求:(关于这里移动到5的位置,而不是到4。因为读取的时候,读取的长度是limit减去position,positon为0,若limit是4,则最大只能读取4个长度。其实可以理解成是从position的下标读取到limit的下标,左闭右开,即包括position的位置,不包括limit的位置)
public final Buffer flip() {
// 如上面的例子,limit的值就变成了5
limit = position;
position = 0;
mark = -1;
return this;
}

如图所示,从position到limit的位置就是有效数据了。
紧接着读取完数据之后,要是想继续往buffer里面存数据呢?分析下,此时的position的位置是0,limit位置是5,那最大只能存取5个长度的数据了,后面的5个就利用不到了。buffer的最大空间是capacity的大小,为了能够最大化利用空间,limit就该等于capacity。clear方法就实现了这样的功能。调用完clear之后,各个值的位置就回到了初始化的时候。
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
总结:关于什么时候调用clear合适,什么时候调用flip合适。其实无论读取数据还是存数据,调用两者都是可以的,只是有时候会出现问题,为了避免问题的出现,能够正确操作数据,两者的使用场景就要加以区分了。从上面的例子可以看出,当想从buffer中读取数据的时候,即将数据从buffer中拿出来,使用flip合适。当想往buffer中存放数据的时候,调用clear合适。
⑦mark()
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "hello".getBytes();
byteBuffer.put(bytes);
// 调用mark方法,将mark的值更改成position的值
byteBuffer.mark();
put操作,使得position=5,mark操作使得mark=position=5

继续添加数据,position的位置会继续移动3个长度:
byteBuffer.put("www".getBytes());

此时position要想回到mark所在的位置,可以直接调用reset方法:
// 将position回到mark标记的位置
byteBuffer.reset();

█ direct buffers
// 通过allocateDirect创建直接内存buffer
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(10);
public static ByteBuffer allocateDirect(int capacity) {
// 构造的是DirectByteBuffer
return new DirectByteBuffer(capacity);
}
相比较于,HeapByteBuffer是将元素存在内部的字节数组里面的,因为字节数组是在堆上开辟的空间内存,所以,非直接内存buffer就叫堆内存buffer。HeapByteBuffer通过offect和position来确定元素存取的位置。DirectByteBuffer是在堆外开辟的内存,内部没有维护一个字节数组。其是通过address和position来确定元素的存取位置的。
DirectByteBuffer通常比HeapByteBuffer的创建会有更大的分配开销成本。因为DirectByteBuffer是在堆内存之外开辟的空间,所以对应用程序的内存不会有明显的影响。在JAVA中,内存溢出等现象基本都是发生在堆空间里的,所以使用DirectByteBuffer进行一些比较大的IO操作会很方便。
█ 其他方法
以HeapByteBuffer为例,介绍其他方法的使用
①duplicate
复制buffer,复制出一个和调用者一样的全新的buffer。(注意:这里虽然是复制出一个新的buffer对象,但其内部是共享了同一个字节数组)
public ByteBuffer duplicate() {
// 从这里可以看出,新的buffer,元素内容,mark、position、limit、capacity、offect的值都和调用方的值相同
// 第一个参数传的是hb,hb指针指向堆中的一块区域存放着数组元素。这里会将新的buffer的内部数组指针也会指向同一个堆内存。
return new HeapByteBuffer(hb,
this.markValue(),
this.position(),
this.limit(),
this.capacity(),
offset);
}
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "hello".getBytes();
byteBuffer.put(bytes);
// 在byteBuffer的基础上,复制出一个新的buffer
ByteBuffer newBuffer = byteBuffer.duplicate();
// 输出结果为false,可见不是同一个对象
System.out.println(byteBuffer==newBuffer);
// 两个buffer的内容相同
for (byte b : newBuffer.array()) {
System.out.print(b);
}
System.out.println();
// 两个buffer的position值相同
System.out.println(byteBuffer.position());
System.out.println(newBuffer.position());

上图虽然画出了两个数组,但其实两个buffer是共享这个数组的堆内存地址的。来看例子验证下:
ByteBuffer srcBuffer = ByteBuffer.allocate(10);
byte[] bytes = "hello".getBytes();
srcBuffer.put(bytes);
// 在byteBuffer的基础上,复制出一个新的buffer
ByteBuffer newBuffer = srcBuffer.duplicate();
// 改变原来srcBuffer的元素内容
srcBuffer.put("hhh".getBytes());
// 查看newBuffer中的内容
System.out.println(new String(newBuffer.array()));
例子中输出内容为:hellohhh 。虽然我们改变的是原来buffer的内容,但复制出来的buffer的内容也改变了,所以两个buffer共享同一个数组堆内存。
②compact
将buffer中的在position和limit之间的数据复制到buffer数组下标从0开始的地方。原先在下标position位置的元素,被复制到下标0的位置,原先在下标position+1位置的元素,被复制到下标为1的位置,以此类推,一直到原先在下标limit-1位置的元素被复制到limit-1-position的位置。元素移动完成之后,position=limit-position,limit=capacity,如果定义了mark,mark将被清除。
public ByteBuffer compact() {
// 将hb,从position位置的元素开始一直到limit位置的元素(左闭右开),移动到hb的从0开始的位置上。
System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
// position位置变动
position(remaining());
// limit的位置变动
limit(capacity());
// mark恢复成-1
discardMark();
// 返回的还是当前对象本身
return this;
}
ByteBuffer byteBuffer = ByteBuffer.allocate(10);
byte[] bytes = "helloww".getBytes();
byteBuffer.put(bytes);
// 打开标记,mark=position
byteBuffer.mark();
ByteBuffer newBuffer = byteBuffer.compact();
// 输出结果:000108111119119000
for (byte b : newBuffer.array()) {
System.out.print(b);
}
System.out.println();
// 输出结果: loww
// loww前面有3个空格,后面有3个空格
System.out.println(new String(newBuffer.array()));

通过上面的例子,感受不到这个方法的作用,为啥要使用它。来看下面的例子。
// 分配一个足够容量的buffer,排除越界的异常
ByteBuffer byteBuffer = ByteBuffer.allocate(100);
// 写入13个长度的数据
byteBuffer.put("hellocomworld".getBytes());
byteBuffer.flip();
// 读数据分两个步骤,先读取5个长度的数据,然后再读取剩下的8个长度的数据
byte[] bytes = new byte[5];
byteBuffer.get(bytes);
System.out.println("第一次读取到的内容:"+new String(bytes));
// 但还没等来得及继续读剩下的8个数据时,这时,又进来一批数据
byteBuffer.put("laa".getBytes());
byteBuffer.flip();
// 这个时候继续第二步骤的读取8个长度的数据
bytes = new byte[8];
byteBuffer.get(bytes);
System.out.println("第二次读取到的内容:"+new String(bytes));
输出结果:发现comworld的内容丢失了:
第一次读取到的内容:hello
第二次读取到的内容:hellolaa
修改下代码:在第一次读取完数据之后,调用一下compact方法:
byte[] bytes = new byte[5];
byteBuffer.get(bytes);
System.out.println("第一次读取到的内容:"+new String(bytes));
byteBuffer.compact();
输出结果:
第一次读取到的内容:hello
第二次读取到的内容:comworld
可见,compact能够解决在读取buffer数据的过程中,如果有数据存入进来,这样不会造成以前数据的丢失。
// jdk源码提供的例子,会存在out.write的过程中,数据还没读取完,就进行下一次in.read()存入数据了。
// 这里的read是往buffer中存入数据
while (in.read(buf) >= 0 || buf.position != 0) {
buf.flip();
// 这里是读取buffer中的数据
out.write(buf);
buf.compact();
}
用图来描述下过程:

此时程序读出来buffer中的hello,还剩下comworld没有读取。但在还没有读取剩下的数据之前,又往buffer中继续添加了数据。因为此时的position=5,所以数据的插入会从数组下标为5的地方开始,一直存入参数数组的长度。如下图:

此时,程序先调用flip,再调用get继续读取数据,读取数据从position开始,一直读8个长度,如图可见,输出结果为:hellolla。第一次存取的数据出现了数据丢失,丢失了com。

回到第一次读取数据完成之后的状态,要想不造成数据的丢失,需要移动position和limit的位置来维护数组内容。有一种方法可以不断的往数组还没有数据的地方追加数据,此时将position=limit=13,
limit=capacity=100,移动完成之后开始存放数据。当读取数据的时候,将position的位置再回滚到上一次读取结束的地方,即下标为5。如何记录position上一次结束的地方呢,可以使用mark和reset来配合操作。想想,上面的这种操作是真的麻烦,在操作的过程中还容易出错。于是,jdk提供了compact,来实现另一种操作。

再结合源码和上图来看下这个方法的操作过程:
看方法调用前的图,ix(position()),因为offect为0,这里可以看做就是positon的位置,在例子中是5
ix(0),在例子中是0
remaining(),在例子中为limit-position=13-5=8
所以,这段代码的功能是从起始下标位置5的地方开始拷贝hb数据的元素,一直拷贝8个长度,将拷贝出来的元素放置到数组hb中,起始位置为下标0。
经过数组的拷贝,数组的元素内容就和方法调用后图中的一样了。
System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
remaining()=limit-position=13-5=8,所以将position的位置设置成8
position(remaining());
capacity()=capatity=100,将limit的值改成100
limit(capacity());
如果定义了mark的值,擦除mark标记,值恢复成-1
discardMark();
其实从上面内容可以发现,操作一个buffer,同时读取存储数据,会造成数据的不正确性。所以ByteBuffer是线程不安全的,在并发操作中,要做线程安全处理。
③rewind
这个方法比较简单,就是将position=0,mark=-1。
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
本文深入探讨了Java NIO中的ByteBuffer,包括它的两种类型:直接缓冲区和非直接缓冲区。详细阐述了ByteBuffer的属性,如capacity、limit、position和mark,并通过实例说明了如何创建、存取数据、调整缓冲区状态。此外,文章还讨论了非直接缓冲区的创建、put和get操作,以及flip、clear、mark和reset方法的用途。最后,对比了直接缓冲区与非直接缓冲区的差异,并强调了ByteBuffer在并发操作中的线程不安全性。

5651

被折叠的 条评论
为什么被折叠?



