结构
Buffer是“缓冲区”的意思。在Java NIO中,所有的数据都要经过Buffer,下图是Buffer内部的基本结构。
它其实就是一个数组,里面有三个指针:position, limit, capacity。
capacity
capacity
为这个数组的容量,是不可变的。
limit
limit
是Buffer中第一个不可读写的元素的下标,也即limit后的数据不可进行读写。limit不能为负,也不能大于capacity。
limit初始的时候是与capacity值是一样的。
position
position
表示下一个元素即将读或者写的下标。position不能为负也不能大于limit。position初始的时候为0。
类关系
Buffer是一个抽象类,它有许多子抽象类,对应7种Java的基本类型(除了boolean
)。如下图:
以ByteBuffer
为例,它有两种实现,一种是HeapByteBuffer
,另一种是DirectByteBuffer
,分别对应堆内存和直接内存。
堆内存会把这个对象分配在JVM堆里,就跟普通对象一样。而直接内存又被称为堆外内存,在使用IO的时候,我们更推荐使用直接内存。
为什么推荐使用直接内存呢?其实这跟JVM的垃圾回收机制有关。IO往往会占用一个比较大的内存空间,如果分配到JVM堆里面,会被认为是一个大对象,影响JVM垃圾回收效率。
堆外内存如果满了(达到系统内存的界限),也会抛出OOM异常。
初始化
Buffer有什么用?Buffer一般是与Channel配合起来用,Channel读数据的时候,会先读到Buffer里,写数据的时候,也会先写到Buffer里。
下面介绍一下具体是怎么使用Buffer的。
一般来说,是直接使用第二级类,比如ByteBuffer
。它们有两个工厂方法allocate
和allocateDirect
,用于初始化和申请内存。前面提到了在操作IO时,通常使用直接内存,所以一般是这样初始化:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
可以用isDirect()
方法来判断当前Buffer对象是否使用了直接内存。
写数据
往Buffer中写数据主要有两种方式:
- 从Channel写到Buffer
- 从数组写到Buffer
从Channel写到Buffer用的是Channel的read(Buffer buffer)
方法,而从数组写到Buffer,主要用的是Buffer的put
方法。
// 获取Channel里面的数据并写到buffer
// 返回的是读的位置,也就是buffer的position
int readBytes = socketChannel.read(buffer);
// 从byte数组写到Buffer
buffer.put("hi, 这是client".getBytes(StandardCharsets.UTF_8));
我们假设Buffer申请了1024字节,这个字符串占用16字节,那写入数据以后三个指针就是这样的:
- position = 16
- limit = 1024
- capacity = 1024
切换模式
Buffer分为读模式和写模式,可以通过flip()
方法转换模式。事实上,查看这个方法源码,发现flip方法也只是对三个指针进行了操作而已。
public Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
mark指针用于reset()方法,如果reset()方法被调用,position就会被重置到mark位置。如果mark没有被定义,调用reset()方法会抛出InvalidMarkException异常。一旦mark被定义,就一定不能为负数,并且小于等于position的位置。
mark()方法的作用相当于可以“暂时记录position”的位置,这样以后可以通过reset()方法回到这个位置。
切换模式后,三个指针变成了这样:
- position = 0
- limit = 16
- capacity = 1024
读数据
与写数据对应,读数据也有两种方式:
- 从Buffer读到Channel
- 从Buffer读到数组
读数据会从position读到limit的位置。
示例代码:
// 读取buffer的数据并写入channel
socketChannel.write(buffer);
// 把buffer里面的数据读到byte数组
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
String body = new String(bytes, StandardCharsets.UTF_8);
这里用到了Buffer的remaining()
方法。这个方法是告诉我们需要读多少字节,方法源码:
public final int remaining() {
return limit - position;
}
清空
一般来说,一个Channel用一个Buffer,但Buffer可以重复使用,尤其是对于一些比较大的IO传输内容来说(比如文件),clear()
与compact()
方法可以重置Buffer。它们有一些微小的区别。
对于clear方法来说,position将被设回0,limit被设置成 capacity的值。
compact方法将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素后面一位。limit属性依然像clear方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。
一般来说,用clear方法的场景会多一点。
源码:
public Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
public ByteBuffer compact() {
int pos = position();
int lim = limit();
assert (pos <= lim);
int rem = (pos <= lim ? lim - pos : 0);
try {
UNSAFE.copyMemory(ix(pos), ix(0), (long)rem << 0);
} finally {
Reference.reachabilityFence(this);
}
position(rem);
limit(capacity());
discardMark();
return this;
}
Buffer还有其它一些操作那三个指针的方法,不过使用频率没有上述方法高,所以本文不做详细介绍,感兴趣的读者可以去看一下源码。
使用
这里贴一下读和写的使用的案例代码:
从字符串到Channel:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// 从字符串写到Buffer
buffer.put("hi, 这是client".getBytes(StandardCharsets.UTF_8));
buffer.flip(); // 转换模式
// 从Buffer写到Channel
socketChannel.write(buffer);
从Channel到字符串:
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
// 从Channel写到Buffer
int readBytes = socketChannel.read(buffer);
if (readBytes > 0) {
buffer.flip(); // 转换模式
byte[] bytes = new byte[buffer.remaining()];
// 从Buffer写到字节数组
buffer.get(bytes);
String body = new String(bytes, StandardCharsets.UTF_8);
System.out.println("server 收到:" + body);
}