3. NIO Buffer
以 Buffer 类开始我们对 java.nio 软件包的浏览历程。这些类是 java.nio 的构造基础。在本章中,我们将深入研究缓冲区, 了解各种不同的类型,并学会怎样使用。
一个Buffer对象是固定数量的数据的容器。其作用是一个存储器,或者分段运输区,在这里数据可被存储并在之后用于检索。缓冲区如我们在第一章所讨论的那样被写满和释放。对于每个非布尔原始数据类型都有一个缓冲区类。尽管缓冲区作用于它们存储的原始数据类型,但缓冲区十分倾向于处理字节。
缓冲区的工作与通道紧密联系。通道是 I/O 传输发生时通过的入口,而缓冲区是这些数据传输的来源或目标。对于离开缓冲区的传输,您想传递出去的数据被置于一个缓冲区,被传送到通道。对于传回缓冲区的传输,一个通道将数据放置在您所提供的缓冲区中。这种在协同对象(通常是您所写的对象以及一到多个 Channel 对象)之间进行的缓冲区数据传递是高效数据处理的关键。通道将在第三章被详细涉及。
下图是 Buffer 的类层次图。在顶部是通用 Buffer 类。 Buffer 定义所有缓冲区类型共有的操作,无论是它们所包含的数据类型还是可能具有的特定行为。这一共同点将会成为我们的出发点。
Image.png关于这么多Buffer类型,我们只关注ByteBuffer,因为在NIO网络编程中,通道直接从ByteBuffer中读取数据。不过好处在于,不同的Buffer实现类,api都是相似的,当我们学会了ByteBuffer,其他的类型的Buffer自然而言也就会了。
1 缓冲区基础
概念上,缓冲区是包在一个对象内的基本数据元素数组。
Buffer 类相比一个简单数组的优点 是它将关于数据的数据内容和信息包含在一个单一的对象中。 Buffer 类以及它专有的子类定义了 一个用于处理数据缓冲区的 API。
Buffer类定义了所有的缓冲区都具有四个属性来提供关于其所包含的数据元素的信息。
public abstract class Buffer {
...
// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
...
}
容量( Capacity)
缓冲区能够容纳的数据元素的最大数量,可以理解为数组的长度。 这一容量在缓冲区创建时被设定,并且永远不能被改变。
上界( Limit)
缓冲区的第一个不能被读或写的元素。或者说,缓冲区中现存元素的计数。
位置( Position)
下一个要被读或写的元素的索引。Buffer类提供了get( )和 put( )函数 来读取或存入数据,position位置会自动进行相应的更新。
标记( Mark)
一个备忘位置。调用 mark( )来设定 mark = postion。调用 reset( )设定 position = mark。标记在设定前是未定义的(undefined)。
这四个属性之间总是遵循以下关系:
0 <= mark <= position <= limit <= capacity
让我们来看看这些属性在实际应用中的一些例子。下图展示了一个新创建的容量为 10 的 ByteBuffer 逻辑视图。
Image.png
position被设为 0,而且capacity和limit被设为 10。mark最初未定义。capacity是固定的,但另外的三个属性可以在使用缓冲区时改变。
2 缓冲区API
Buffer类定义了所有缓冲区实现类需要实现的方法,以下列出的只是这些方法的签名,不包含实现
public abstract class Buffer {
//JDK1.4时,引入的api
public final int capacity( )//返回此缓冲区的容量
public final int position( )//返回此缓冲区的位置
public final Buffer position (int newPositio)//设置此缓冲区的位置
public final int limit( )//返回此缓冲区的限制
public final Buffer limit (int newLimit)//设置此缓冲区的限制
public final Buffer mark( )//在此缓冲区的位置设置标记
public final Buffer reset( )//将此缓冲区的位置重置为以前标记的位置
public final Buffer clear( )//清除此缓冲区
public final Buffer flip( )//反转此缓冲区
public final Buffer rewind( )//重绕此缓冲区
public final int remaining( )//返回当前位置与限制之间的元素数
public final boolean hasRemaining( )//告知在当前位置和限制之间是否有元素
public abstract boolean isReadOnly( );//告知此缓冲区是否为只读缓冲区
//JDK1.6时引入的api
public abstract boolean hasArray();//告知此缓冲区是否具有可访问的底层实现数组
public abstract Object array();//返回此缓冲区的底层实现数组
public abstract int arrayOffset();//返回此缓冲区的底层实现数组中第一个缓冲区元素的偏移量
public abstract boolean isDirect();//告知此缓冲区是否为直接缓冲区
}
聪明的读者会注意到:
1、Buffer类的七种基本数据类型的缓冲区实现也都是抽象的,这些类没有一种能够直接实例化。
2、上文所列出的的 Buffer API 并没有包括存取函数。
这些方法都定义在Buffer类的子类中。子类包含了静态工厂方法用来创建相应类的新实例。以及get和put等操作来实现缓存区的存取。
我们以ByteBuffer类为例进行说明:
public abstract class ByteBuffer {
//缓冲区创建相关api
public static ByteBuffer allocateDirect(int capacity)
public static ByteBuffer allocate(int capacity)
public static ByteBuffer wrap(byte[] array)
public static ByteBuffer wrap(byte[] array,int offset, int length)
//缓存区存取相关API
public abstract byte get( );//从当前位置position上get,get之后,position会自动+1
public abstract byte get (int index);//从绝对位置get
public abstract ByteBuffer put (byte b);//从当前位置上普通,put之后,position会自动+1
public abstract ByteBuffer put (int index, byte b);//从绝对位置上put
}
新的缓冲区是由分配(allocate)或包装(wrap)操作创建的。allocate操作创建一个缓冲区对象并分配一个私有的空间来储存容量大小的数据元素。wrap操作创建一个缓冲区对象但是不分配任何空间来储存数据元素。它使用您所提供的数组作为存储空间来储存缓冲区中的数据元素。
存储操作是通过get和put操作进行的,get 和 put 可以是相对的或者是绝对的。在前面的程序列表中,相对方案是不带有索引参数的函数。当相对函数被调用时,位置在返回时前进一。如果位置前进过多,相对运算就会抛 出 异 常 。 对 于 put() , 如 果 运 算 会 导 致 位 置 超 出 上 界 , 就 会 抛 出BufferOverflowException 异常。对于 get(),如果位置不小于上界,就会抛出BufferUnderflowException 异常。绝对存取不会影响缓冲区的位置属性,但是如果您所提供的索引超出范围(负数或不小于上界),也将抛出 IndexOutOfBoundsException 异常。
下面我们通过详细的案例说明,如何创建缓冲区,以及对缓存区进行操作。
2.1 创建缓冲区
对于这一讨论,我们将以 ByteBuffer 类为例,但是对于其它六种主要的缓冲区类也是适用的: IntBuffer, DoubleBuffer, ShortBuffer, LongBuffer, FloatBuffer,和 CharBuffer。下面是创建一个缓冲区的关键函数,对所有的缓冲区类通用(要按照需要替换类名):
public class BufferCreateDemo {
public static void main(String[] args) {
//方式1:allocate方式直接分配,内部将隐含的创建一个数组
ByteBuffer allocate = ByteBuffer.allocate(10);
//方式2:通过wrap根据一个已有的数组创建
byte[] bytes=new byte[10];
ByteBuffer wrap = ByteBuffer.wrap(bytes);
//方式3:通过wrap根据一个已有的数组指定区间创建
ByteBuffer wrapoffset = ByteBuffer.wrap(bytes,2,5);
//打印出刚刚创建的缓冲区的相关信息
print(allocate,wrap,wrapoffset);
}
private static void print(Buffer... buffers) {
for (Buffer buffer : buffers) {
System.out.println("capacity="+buffer.capacity()
+",limit="+buffer.limit()
+",position="+buffer.position()
+",hasRemaining:"+buffer.hasArray()
+",remaining="+buffer.remaining()
+",hasArray="+buffer.hasArray()
+",isReadOnly="+buffer.isReadOnly()
+",arrayOffset="+buffer.arrayOffset());
}
}
}
运行程序,输出:
capacity=10,limit=10,position=0,hasRemaining:true,remaining=10,hasArray=true,isReadOnly=false,arrayOffset=0
capacity=10,limit=10,position=0,hasRemaining:true,remaining=10,hasArray=true,isReadOnly=false,arrayOffset=0
capacity=10,limit=7,position=2,hasRemaining:true,remaining=5,hasArray=true,isReadOnly=false,arrayOffset=0
ByteBuffer allocate = ByteBuffer.allocate(10);
这段代码隐含地从堆空间中分配了一个 byte 型数组作为备份存储器来储存 10 个 byte变量
byte[] bytes=new byte[10];
ByteBuffer wrap = ByteBuffer.wrap(bytes);
这段代码构造了一个新的缓冲区对象,但数据元素会存在于数组中。这意味着通过调用put()函数造成的对缓冲区的改动会直接影响这个数组,而且对这个数组的任何改动也会对这个缓冲区对象可见。
ByteBuffer wrapoffset = ByteBuffer.wrap(bytes,2,5);
带有 offset 和 length 作为参数的 wrap()函数版本则会构造一个按照您提供的 offset 和 length 参数值初始化位置(position)和上界的缓冲区(limit)。这个函数并不像您可能认为的那样,创建了一个只占用了一个数组子集的缓冲区。这个缓冲区可以存取这个数组的全部范围; offset 和 length 参数只是设置了初始的状态。
最后一个函数, arrayOffset(),返回缓冲区数据在数组中存储的开始位置的偏移量(从数组头 0 开始计算)。如果您使用了带有三个参数的版本的 wrap()函数来创建一个缓冲区,对于这个缓冲区, arrayOffset()会一直返回 0,像我们之前讨论的那样。
2.2 缓冲区存取
让我们看一个例子。 我们将代表“Hello”字符串的 ASCII 码载入一个名为 buffer 的ByteBuffer 对象中。
public class BufferPut {
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
print(buffer);
byte H=0x48;
byte e=0x65;
byte l=0x6C;
byte o=0x6F;
buffer.put(H).put(e).put(l).put(l).put(o);
print(buffer);
}
private static void print(Buffer... buffers) {
for (Buffer buffer : buffers) {
System.out.println("capacity="+buffer.capacity()
+",limit="+buffer.limit()
+",position="+buffer.position()
+",hasRemaining:"+buffer.hasArray()
+",remaining="+buffer.remaining()
+",hasArray="+buffer.hasArray()
+",isReadOnly="+buffer.isReadOnly()
+",arrayOffset="+buffer.arrayOffset());
}
}
}
运行程序,输出
capacity=10,limit=10,position=0,hasRemaining:true,remaining=10,hasArray=true,isReadOnly=false,arrayOffset=0
capacity=10,limit=10,position=5,hasRemaining:true,remaining=5,hasArray=true,isReadOnly=false,arrayOffset=0
五次调用 put()之后的缓冲区
注意本例中我们存储都是字节。
当然这样做是比较麻烦的,也有批量存储的方法,来取代面上一个一个字节的put
buffer.put("Hello".getBytes());
既然我们已经在 buffer 中存放了一些数据,如果我们想在不丢失位置的情况下进行一些更改该怎么办呢? put()的绝对方案可以达到这样的目的。假设我们想将缓冲区中的内容从“Hello”的 ASCII 码更改为“ Mellow”。我们可以这样实现:
byte M=0x4D;
byte w=0x77;
buffer.put(0,M).put(w);
这里通过进行一次绝对方案的 put 将 0 位置的字节代替为十六进制数值 0x4d,将 0x77放入当前位置(当前位置不会受到绝对 put()的影响)的字节,并将位置属性加一。结果如下所示
翻转flip函数
我们已经往缓冲区中存储了一些数据,现在我们想把它读取出来。但如果通道现在在缓冲区上执行 get(),那么它将从当前position位置开始读取,也就是我们刚刚插入的有用数据之外取出未定义数据。
如果我们将position值重新设为 0,就可以从正确位置开始获取,但是它是怎样知道何时到达我们所插入数据末端的呢?这就是limit上界属性被引入的目的。上界属性指明了缓冲区有效内容的末端。我们需要将上界属性设置为当前位置,然后将位置重置为 0。我们可以人工用下面的代码实现:
buffer.limit(buffer.position()).position(0);
但这种从填充到释放状态的缓冲区翻转是 API 设计者预先设计好的,他们为我们提供了一个非常便利的函数:
Buffer.flip();
flip()函数将一个能够继续添加数据元素的填充状态的缓冲区翻转成一个准备读出元素的释放状态。在翻转之后,缓冲区的逻辑试图变成如下所示:
Image.png
rewind()函数与 flip()相似,但不影响上界属性。它只是将位置值设回 0。您可以使用 rewind()后退,重读已经被翻转的缓冲区中的数据。
如果将缓冲区翻转两次会怎样呢?它实际上会大小变为 0。按照上图 的相同步骤对缓冲区进行操作;把上界设为位置的值,并把位置设为 0。上界和位置都变成 0。尝试对缓冲区上位置和上界都为 0 的 get()操作会导致 BufferUnderflowException
异常。而 put()则会导致 BufferOverflowException
异常。
现在我们读取数据时,从position位置开始直到limit结束就可以了,布尔函数 hasRemaining()会在释放缓冲区时告诉您是否已经达到缓冲区的上界。以下是一种将数据元素从缓冲区释放到一个数组的方法
for (int i = 0; buffer.hasRemaining( ), i++) {
myByteArray [i] = buffer.get( );
}
作为选择, remaining()函数将告知您从当前位置到上界还剩余的元素数目。 您也可以通过下面的循环来释放缓冲区。
int count = buffer.remaining( );
for (int i = 0; i < count; i++) {
myByteArray [i] = buffer.get( );
}
如果您对缓冲区有专门的控制,这种方法会更高效,因为上界不会在每次循环重复时都被检查。
类似的put,get也有对应的批量操作,我们可以通过以下方式直接读取出,当前buffer中的所有元素
buffer.flip();
int count = buffer.remaining( );
byte[] content=new byte[count];//构造一个与剩余可读元素大小相同的数组
buffer.get(content);
System.out.println(new String(content));
当读取完成之后,缓冲区试图如下所示:
清空clear函数
当我们读取完了缓冲区的数据,为了重复利用缓冲区,我们可以通过clear函数来让缓冲区恢复到初始状态,它并不改变缓冲区中的任何数据元素,而是仅仅将上界设为容量的值,并把位置设回 0,即position=0,limit=capacity,mark=-1。
以下是Buffer类的clear方法的源码
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
清空后的缓冲区视图如下所示
标记 mark函数与reset函数
在本章节的开头,我们已经涉及了缓冲区四种属性中的三种。第四种,mark,使缓冲区能够记住一个position并在之后将其返回。缓冲区的标记在 mark( )函数被调用之前是未定义的,值为-1,调用时mark被设为当前position的值。 reset( )函数将position设为当前的mark值。如果mark值未定义,调用 reset( )将导致 InvalidMarkException 异常。一些缓冲区函数rewind()、clear()、以及 flip()会抛弃已经设定的标记。
让我们看看这是如何进行的。对于'Mellow'放入ByteBuffer之后,并且执行了flip函数之后,如果执行以下代码片段
buffer.position(2).mark().position(4);
那么缓冲区逻辑试图如下所示:
Image.png如果现在从这个缓冲区读取数据,两个字节(“ ow”)将会被发送,而position会前进到 6。如果我们此时调用 reset( ),position将会被设为mark,如下图所示。再次将读取缓冲区的值将导致四个字节(“ llow”)被发送。
Image.png
mark可能没有什么实际意义,但你了解了概念。
压缩(compact)
有时,您可能只想从缓冲区中释放一部分数据,而不是全部,然后重新填充。为了实现这一点,未读的数据元素需要下移以使第一个元素索引为 0。尽管重复这样做会效率低下,但这有时非常必要,而 API 对此为您提供了一个 compact()函数。
下图显示了一个已经读取了前2个元素,并且现在我们想要对其进行压缩的缓冲区。
Image.png调用
buffer.compact();
会导致缓冲区如下图所示
Image.png这里发生了几件事。您会看到数据元素 2-5 被复制到 0-3 位置。位置 4 和 5 不受影响,但现在正在或已经超出了当前position,因此是“死的”。它们可以被之后的 put()调用重写。还要注意的是,position已经被设为被复制的数据元素的数目(4)。也就是说,缓冲区现在被定位在缓冲区中最后一个“存活”元素后插入数据的位置。最后,limit属性被设置为capacity的值,因此缓冲区可以被再次填满。调用 compact()的作用是丢弃已经释放的数据,保留未释放的数据,并使缓冲区对重新填充容量准备就绪。
如果您想在压缩后读取数据,缓冲区会像之前所讨论的那样需要被翻转(flip)。无论您之后是否要向缓冲区中添加新的数据,这一点都是必要的。
压缩对于使缓冲区与您从端口中读入的数据(包)逻辑块流的同步来说也许是一种便利的方法(处理粘包、解包的问题)。
compact的作用
该方法的作用是将 position 与 limit之间的数据复制到buffer的开始位置,复制后 position = limit -position,limit = capacity,但如果position 与limit 之间没有数据的话发,就不会进行复制
下面举个例子:
将数据从一个channel 读取出来,然后写入另外一个channel
ByteBuffer buffer = ByteBuffer.allocate(4);
while((len=channelSrc.read(buffer))>0) {
buffer.flip();
channelDes.write(buffer);
// 此时write并不一定一次把buffer中的数据全部发送出去,再次写数据的时候,我们
//要将 postion
System.out.println("read "+len+" bytes");
}
如果上面写的,channel 是一种非阻塞 io 操作,write操作并不能一次将buffer 中的数据全部写入到指定的 channel 中去,但如果一次写不完的话,那么第二次再读取的时候,我们就要将 position = limit ,limit = capacity ,然后再读取,不然第二次读取的数据会把第一次没有write 完的数据覆盖掉,
等设置后第二次读取完成后,我们还是要向channel 中write 数据,然后这次写入数据还要从上一次没有写完的地方开始写,我们还要将position 还原到上一步记录的地方
然后将limit 设置成 最后一次 position 的位置,这样做太复杂,因此提供了一个 compact 操作,我们在 write 后,执行 buffer.compact()将没有发出的数据复制到 buffer 的开始位置,posittion = limit-position,limit = capacity,这样在下一次read(buffer)的时候,数据就会继续添加到缓冲的后面了
因此标准的从一个channel 的数据到另一个 channel 的操作是这样的
while(channelread.read(buffer)>0 || buffer.position ==0){
buffer.flip();
channelwriter.write(buffer);
buffer.compact();
}
复制缓冲区duplicate()函数
缓冲区的复制有分两种:
1、完全复制:调用duplicate()函数或者asReadOnlyBuffer()函数
2、部分复制:调用slice函数
duplicate()函数创建了一个与原始缓冲区相似的新缓冲区。两个缓冲区共享数据元素,拥有同样的容量,但每个缓冲区拥有各自的位置,上界和标记属性。对一个缓冲区内的数据元素所做的改变会反映在另外一个缓冲区上。这一副本缓冲区具有与原始缓冲区同样的数据视图。如果原始的缓冲区为只读,或者为直接缓冲区,新的缓冲区将继承这些属性。
可以通过以下代码来复制一个缓冲区
CharBuffer buffer = CharBuffer.allocate (8);
buffer.position (3).limit (6).mark( ).position (5);
CharBuffer dupeBuffer = buffer.duplicate( );
buffer.clear( );
此时缓冲区的逻辑试图如下所示
Image.png可以使用asReadOnlyBuffer()函数来生成一个只读的缓冲区视图 。 这与duplicate()相同,除了这个新的缓冲区不允许使用put(),并且其 isReadOnly()函数将 会 返 回 true 。对这一只读缓冲区put()函数的调用尝试会导致抛出ReadOnlyBufferException 异常。
直接缓冲区(direct byte buffer)
直接字节缓冲区通常是 I/O 操作最好的选择。在设计方面,它们支持 JVM 可用的最高效I/O 机制。非直接字节缓冲区可以被传递给通道,但是这样可能导致性能损耗。通常非直接缓冲不可能成为一个本地 I/O 操作的目标。如果您向一个通道中传递一个非直接 ByteBuffer 对象用于写入,通道可能会在每次调用中隐含地进行下面的操作:
1.建一个临时的直接 ByteBuffer 对象。
2.将非直接缓冲区的内容复制到临时缓冲中。
3.使用临时缓冲区执行低层次 I/O 操作。
4.临时缓冲区对象离开作用域,并最终成为被回收的无用数据。
这可能导致缓冲区在每个 I/O 上复制并产生大量对象,而这种事都是我们极力避免的。不过,依靠工具,事情可以不这么糟糕。运行时间可能会缓存并重新使用直接缓冲区或者执行其他一些聪明的技巧来提高吞吐量。如果您仅仅为一次使用而创建了一个缓冲区,区别并不是很明显。另一方面,如果您将在一段高性能脚本中重复使用缓冲区,分配直接缓冲区并重新使用它们会使您游刃有余。
直接缓冲区是 I/O 的最佳选择,但可能比创建非直接缓冲区要花费更高的成本。直接缓冲区使用的内存是通过调用本地操作系统方面的代码分配的,绕过了标准 JVM 堆栈。建立和销毁直接缓冲区会明显比具有堆栈的缓冲区更加破费,这取决于主操作系统以及 JVM 实现。直接缓冲区的内存区域不受无用存储单元收集支配,因为它们位于标准 JVM 堆栈之外。
使用直接缓冲区或非直接缓冲区的性能权衡会因JVM,操作系统,以及代码设计而产生巨大差异。通过分配堆栈外的内存,您可以使您的应用程序依赖于JVM未涉及的其它力量。当加入其他的移动部分时,确定您正在达到想要的效果。我以一条旧的软件行业格言建议您:先使其工作,再加快其运行。不要一开始就过多担心优化问题;首先要注重正确性。 JVM实现可能会执行缓冲区缓存或其他的优化, 这会在不需要您参与许多不必要工作的情况下为您提供所需的性能。
直接 ByteBuffer 是通过调用具有所需容量的 ByteBuffer.allocateDirect()函数产生的
,就像我们之前所涉及的 allocate()函数一样。注意用一个 wrap()函数所创建的被包装的缓冲区总是非直接的。
所有的缓冲区都提供了一个叫做 isDirect()的 boolean 函数,来测试特定缓冲区是否为直接缓冲区。虽然 ByteBuffer 是唯一可以被直接分配的类型,但如果基础缓冲区是一个直接 ByteBuffer,对于非字节视图缓冲区, isDirect()可以是 true。
回顾我们之前讲解UNIX 五种IO模型中的读取数据的过程,读取数据总是需要通过内核空间传递到用户空间,而往外写数据总是要通过用户空间到内核空间。JVM堆栈属于用户空间。 而我们这里提到的直接缓冲区,就是内核空间的内存。内核空间的内存在java中是通过Unsafe这个类来调用的。
而Netty中所提到的零拷贝(通常是指计算机在网络上发送文件时,不需要将文件内容拷贝到用户空间而直接在内核空间中传输到网络的方式),无非就是使用了这里的直接缓冲区。没有什么神奇的。
内存映射缓冲区
映射缓冲区是带有存储在文件,通过内存映射来存取数据元素的字节缓冲区。映射缓冲区通常是直接存取内存的,只能通过 FileChannel 类创建。映射缓冲区的用法和直接缓冲区类似,但是 MappedByteBuffer 对象可以处理独立于文件存取形式的的许多特定字符。
MappedByteBuffer在大文件处理方面性能比较高,如果你在做一个文件存储服务器,可以考虑使用MappedByteBuffer。