在Java NIO中各类Buffer主要用于和NIO Channel进行交互,数据从Channel中读取到Buffer中,从Buffer写入到Channel中。
channel-buffer数据交互
我们可以将Buffer看做内存中的一块区域,我们可以在这块区域上写数据,然后在从中读取。这块内存区域被包装成NIO Buffer对象,提供了一系列的方法使我们操作这块内存变得更简单一些。
Buffer的基本使用
使用Buffer进行读写数据一般会通过下边四个步骤处理:
- 将数据写到Buffer中
- 调用buffer.flip()切换为读模式
- 从Buffer中读取数据
- 调用buffer.clear()或者buffer.compact()清空或压缩buffer
下边是个简单的Buffer使用的例子
public class FileChannelExam {
public static void main(String[] args){
try {
String path = FileChannelExam.class.getResource("/data/nio-data.txt").getPath();
// 创建一个文件通道
RandomAccessFile file = new RandomAccessFile(path, "rw");
FileChannel channel = file.getChannel();
// 创建一个字节buffer
ByteBuffer buffer = ByteBuffer.allocate(1024);
// 读取数据到buffer
int len = channel.read(buffer);
while (len != -1){
System.out.println("Read " + len);
// 将写模式转变为读模式,
// 将写模式下的buffer内容最后位置设为读模式下的limit位置,作为读越界位,同时将读位置设为0
// 表示转换后重头开始读,同时消除写模式的mark标记
buffer.flip();
// 判断当前读取位置是否到达越界位(position < limit)
while (buffer.hasRemaining()){
// 读取当前position的字节(position++)
System.out.println(buffer.get());
}
// 清空当前buffer内容
buffer.clear();
len = channel.read(buffer);
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}
}
当我们将数据写入buffer时,buffer会记录我们写入了多少数据,当需要读取数据的时候,需要调用flip()方法将buffer从写模式切换到读模式,在读模式下,buffer允许用户读取已经写入buffer的所有数据。
Buffer 以下实现
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
想获取一个Buffer对象的话首先要进行buffer对象的分配,每一个Buffer类都有一个allocate的方法。具体用法:
ByteBuffer byteBuffer1 = ByteBuffer.allocate(1024);
观察如上的代码,创建一个具体的Buffer对象都要使用相关的Buffer类的allocate方法。到这里,可能就会有同学有疑问了,为什么不能直接new出来。下面,进入ByteBuffer源码里看一眼。
public abstract class ByteBuffer
extends Buffer
implements Comparable<ByteBuffer>
public static ByteBuffer allocate(int capacity) {
if (capacity < 0)
throw new IllegalArgumentException();
return new HeapByteBuffer(capacity, capacity);
}
估计可以看出一些门道来了,ByteBuffer是一个抽象类,allocate是抽象类中的静态方法,通过allocate方法构造的是HeapByteBuffer对象。
接下来,为了理解buffer的工作原理,看下buffer的三个属性
- capacity【容量】
- position【位置】
- limit【界限】
关于这三个属性之间的关系,找了一个比较易懂的图片来解释下
左边的代表写入的时候,右边的代表读取的时候
当写入的时候,capacity的大小代表利用allocate初始化时候的大小,代表这个缓存块的容量,最多只能向这个缓存块中放入capacity个char,long,int,byte等等。当缓存块满了的时候需要将其清空,才能继续往里面写数据
position:代表当前位置,初始化值为0,当一个数据写入buffer中的时候,position会移动到下一个可插入的buffer单元,因此,position的最大值为capacity-1,
limit:在写的模式下面,limit表示你最多可以向buffer里面写入多少个buffer数据,在写模式下面,limit=capacity。
当切换到读模式下面,代表最多能读取到多少数据。因此当切换到读模式下面,limit会被置为写模式下面的position值。因此,你能读取到在写模式下面写入的所有值。
以上的话,就是这三个属性的具体介绍。理解了这三个属性之后,对于理解buffer的一些方法有很大的用处。
Buffer常用方法
1.申请一个Buffer
在使用Buffer之前,你必须为它申请一块内存空间,每个Buffer的实现类都实现了它自己的allocate()方法来完成内存申请的工作,下面的代码展示了如何创建一个Buffer对象。
// 创建一个1024字节的ByteBuffer对象
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 创建一个1024字符的CharBuffer对象
CharBuffer charBuffer = CharBuffer.allocate(1024);
// 以上都是堆上分配内存
// 如果在直接内存分配
ByteBuffer byteBuffer = CharBuffer.allocateDirect(1024);
2.写入数据到buffer中
向buffer写入数据有两种方法:
- 通过Channel向Buffer中写入数据
- 直接写入数据到Buffer
// 通过Channel写入,即将Channel数据读取到buffer中
int len = channel.read(buffer);
// 直接写入,调用put方法
buffer.put(127);
需要注意的是,put()方法有多重实现,你可以使用不同的方式写入数据,例如:写入到特定的位置,写入一个字节数组等。
3.flip()写切换到读
flip()方法是将buffer由写模式切换到读模式的方法,flip()方法将position重置为0,将limit设置为已经写入的最大位置,也就是position从标记写入位置改变为标记都区位置;源码中flip()方法的实现如下:
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
4.从buffer中读取数据
从buffer中读取数据同样有两种方法:
- 通过Channel从Buffer中读取数据
- 直接从Buffer中读取数据
// 使用Channel读取数据,即将数据写入Channel
int len = channel.write(buffer);
// 直接读取数据
byte data = buffer.get();
同样get()方法也有很多重载实现,允许我们使用不同的方法读取数据,可以参考Buffer实现类文档查看更多细节。
5.倒回rewind()
rewind()倒回方法只是将position重置为0,limit仍保持原值;一般在读模式下使用可以让我们重复读取buffer中的数据;在写模式下则会导致重新写入数据(类似于置空了buffer)。源码:
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
6.clear()和compact()
一旦完成读操作,我们需要让buffer重新改变为写模式,以便可以重新向buffer写入新的数据,buffer通过clear()和compact()来完成。
当调用clear的时候position会重置为0,limit设置为capacity,虽然buffer中的数据未被擦除,但逻辑上相当于buffer被清空了,因为新写入的数据会覆盖旧数据,如果buffer中还有未被读取的数据,这些数据依然会被覆盖!
clear源码实现,可以和rewind的比较一下,看有什么区别:
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
如果希望保留buffer中还未读取的数据,只是清理已读取的数据来腾出写入空间,则可以通过compact()方法实现;compact()方法会拷贝未读入的数据到buffer内存空间的起始位置,然后将position设置到未读取数据元素的最后位置,limit值仍然为buffer的capacity,现在buffer就有了更多的空间供写入数据。我们可以看一下HeapByteBuffer的源代码实现:
public ByteBuffer compact() {
//复制数据
System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
// 重置position位置
position(remaining());
// limit设置为capacity
limit(capacity());
discardMark();
return this;
}
7.mark()和reset()
mark和reset方法是配合使用的一组方法,你可以通过mark()方法标记buffer中的一个位置,经过读写操作后position位置会改变,然后你就可以使用reset()方法使position位置回到mark()方法标记的位置。
buffer.mark();
...; // 读或写操作
buffer.reset(); // 回到标记位置
8.equals()
可以通过equals和compareTo()方法来比较两个buffer,equals判断条件:
- 1. 两个buffer是否同一类型;
- 2. 是否持有相同数量的数据;
- 3. 持有的数据是否每个元素都相同。
9.Scatter和Gather
Java NIO内置支持分散(Scatter)和聚集(Gather),Scatter和Gather是用于读取和写入Channel的概念。
Scatter是指从一个Channel中分散读取数据到一个或多个Buffer的操作,因此Channel将数据分散到多个Buffer中;
Gather是指将一个或多个Buffer中的数据写入一个Channel的操作,一次Channel可以从多个Buffer中收集数据。
Scatter和Gather在解决传输数据拥有多个部分需要进行分离的场景下有很大的用处;比如,一个消息数据中包含消息头(header)和消息体(body)两部分,我们就可以将消息头和消息体分别读入不同的Buffer保存,使得消息的分离处理更加方便。