bboyjing's blog

Java NIO之【缓冲区】

平时写业务代码不一定会直接涉及到底层IO的使用,但是作为一个有点思想的码农,就算用不到,至少也应该知道。所以准备系统地学习下Java NIO,为日后看框架源码打下点基础。本系列博客学习的依据是前人翻译的《Java NIO》中文版,网友对该书褒贬不一,可能各个人群的需求不一样,确实重口难调。但是,没有经历过,怎么知道对自己是否合适。废话不多说了,摆好姿势,开始学习了。先建个项目,用于存放测试用力,本人的项目位于Github

Buffer类是java.nio的构造基础,简单来说,Buffer对象就是用来存放数据的。先看下,Buffer的直接继承关系:
nio_1
从图中可以看出,Byffer的子类基本上是跟Java基本数据类型密切相关的。打开IntBuffer.java看下源码,其中有一个成员变量final int[] hb;,这就是存放数据的数组。所以Buffer类的底层实现可以认为就是数组,但是同时做了一些扩展。

属性

所有的缓冲区都包含四个重要的属性。

  • 容量(capacity):缓冲区能够容纳的数据元素的最大数量,这一容量在缓冲区创建时被设定,并且永远不能被改变
  • 上限(limit):缓冲区的第一个不能被读或写的元素,或者说,缓冲区存储元素个数的上限
  • 位置(position):下一个要被读或写的元素的索引,初始值为0
  • 标记(mark):备忘位置,初始值为-1,表示未定义。通过调用某些方法给该标记设值

这四个属性之间的关系是:mark <= position <= limit <= capacity,下面展示一个新创建的容量为10的ByteBuffer:
nio_2
从图中可以看出,初始时mark未被定义,position被设成0,limit和capacity被设为10,由于数组下标从0开始,所以该buffer可访问的地址为0~9,limit和capacity指向的10号位是不可访问的。

缓冲区API

Buffer类定义了一些公共的API,看了下源码,这些API基本上都是对属性的设值。可想而知,对位置的操作是使用Buffer类的关键,下面挑一个来看下:

1
2
3
4
5
6
7
8
9
10
11
// 清空缓冲区
public final Buffer clear() {
// 将起始位置设置成0
position = 0;
// 将上限设置成容量最大值
limit = capacity;
// 将mark设置成未定义
mark = -1;
// 返回当前Buffer实例
return this;
}

从上述API可以看出两个特点:

  1. 对于缓冲区的某些操作不是直接操作数据,而是通过标志位来实现。比如clear()方法,不是清空数组中的数据,而是将position设成0等,来表示该缓冲区已经清空。
  2. API的返回值为Buffer引用,这样可以通过级联调用的方式来使用,能产生简洁、优美、易读的代码。比如:buffer.mark().position(5).reset( )

另外一些常用的API如下:

存取

存取方法不在Buffer类中,因为涉及到数据类型,所以get()、put()方法被置于Buffer的子类中。比如说IntBuffer的get()方法最终是在HeapIntBuffer中实现:

1
2
3
4
// 获取缓冲区中下一个位置的值
public int get() {
return hb[ix(nextGetIndex())];
}

填充

我们先看一个例子,在项目中新建个类,该示例位于HelloBuffer.java中。我们将代表“Hello”字符串的ASCII码载入一个名为buffer的ByteBuffer对象中:

1
2
3
4
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'o');
}

这里有两点点需要注意下:

  1. 上例中每个字符都必须强转成byte
  2. 该缓冲区存的单位是字节,而char在java中占2个字节,这里强转会丢掉前八位,仅做测试用。

5次调用put()之后缓冲区的状态如下图:
nio_3
这类图以后就不画了,太烦了。通过如下代码可以修改buffer中的值:

1
2
3
4
// 将位置0的值替换为M,不会影响当前position
buffer.put(0, (byte)'M');
// 继续操作buffer
buffer.put((byte)'w');

翻转

要读取写完的buffer,需要先对buffer进行翻转动作,Buffer类提供flip()函数正是提供该功能,我们先来看下该函数实现代码:

1
2
3
4
5
6
7
8
9
10
public final Buffer flip() {
// 将limit设置成当前position所在的位置,也就是缓冲区的第一个不能被读或写的元素
limit = position;
// 将position指向第0位
position = 0;
// 将mark设置成未定义
mark = -1;
// 返回buffer引用
return this;
}

经过翻转之后,表示该缓冲区从0~limit-1是可读的。

释放

Buffer类提供hasRemaining()函数,用于查看缓冲区是否已达上限,该函数实现很简单,只是判断下position是否小于上限:

1
2
3
public final boolean hasRemaining() {
return position < limit;
}

综上所述,一个简单的、完整的填充、读取缓冲区的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args) {
ByteBuffer buffer = ByteBuffer.allocate(10);
buffer.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'o');
// 将位置0的值替换为M,不会影响当前position
buffer.put(0, (byte)'M');
// 继续操作buffer
buffer.put((byte)'w');
// 翻转Buffer
buffer.flip();
while(buffer.hasRemaining()){
System.out.println(buffer.get());
}
}

压缩

有时候我们可能只想从缓冲区中释放一部分数据,然后接着填充,为了实现这一功能,未读的数据元素的第一个的下标要移到第0位,看源码是通过复制的方式实现的:

1
2
3
4
5
6
7
8
9
public ByteBuffer compact() {
// 复制未读的元素
System.arraycopy(hb, ix(position()), hb, ix(0), remaining());
// 设置新标记位
position(remaining());
limit(capacity());
discardMark();
return this;
}

标记

也就是mark属性起作用的时候,通过reset()方法,可以将buffer重置到标记位,我们来看下reset()方法:

1
2
3
4
5
6
7
8
public final Buffer reset() {
int m = mark;
if (m < 0)
throw new InvalidMarkException();
// 很简单,就是将position位置设置成mark
position = m;
return this;
}

比较

通过equals()函数可以测试两个缓冲区是否相等,下面是达成两个缓冲区相等的条件:

  • 两个缓冲区中的对象类型相同。
  • 两个缓冲区都剩余相同数量的元素。Buffer的容量不需要相同,而且缓冲区中剩余元素的索引也不必相同。
  • 在每个缓冲区应被get()函数返回的剩余数据元素序列必须一致。

还提供compareTo()函数让两个缓冲区进行比较,这里不多说了,后面如果用到再详细看。

批量移动

缓冲区的设计目的就是为了能够高效地传输数据,所以buffer API提供了向缓冲区内外批量移动数据元素的函数。get()和put()都有提供,我们先了解下有这个功能,用的时候知道尽量调用高效API就是了。

创建缓冲区

上面的一个小例子中,我们已经知道了如何创建缓冲区,这里我们以CharBuffer为例,再来详细说明下。创建缓冲区有两种方式:

  1. 通过静态工厂方法allocate()来创建实例。比如要分配一个容量为100个char变量的Charbuffer:CharBuffer charBuffer = CharBuffer.allocate(100),这段代码隐含地从堆空间中分配了一个char型数组作为备份存储器来存储100个char变量。
  2. 通过wrap()函数来创建实例。比如CharBuffer charBuffer = CharBuffer.wrap(myArray);,warp()函数中没有对myArray进行深拷贝,所以程序对myArray的操作和对缓冲去的操作会相互影响。

通过allocate()和wrap()函数创建的缓冲区都是间接的,是JVM托管的。从CharBuffer的子类HeapCharBuffer的名字也可以猜出来,是分配在JVM堆中的。

复制缓冲区

缓冲区提供duplicate()函数,来复制一个缓冲区。需要注意的是,复制一个缓冲区会创建一个新的Buffer对象,但不复制数据。原始缓冲区和副本都会操作同样的数据元素。

字节缓冲区

字节是操作系统及其I/O设备使用的基本数据类型,当在JVM和操作系统间传递数据时,将其他的数据类型拆分成字节是很有必要的。所以我们着重来看下字节缓冲区。

字节序

非字节类型的基本数据,都是以连续字节序列的形式存储在内存中的。例如32位int值0x037fb4c7,可能以下面两种方式存储在内存中(假设内存从左往右地址递增):

  • 大端字节序:c7 b4 7f 03,高位存储在内存地址高位。
  • 小端字节序:03 7f b4 c7,高位存储在内存地址低位。

在java.nio中,字节顺序由ByteOrder类封装,Java默认的字节序是ByteOrder.BIG_ENDIAN,也就是大端序。

直接缓冲区

简单来说,直接缓冲区使用的内存是通过调用本地操作系统方面的代码分配的,绕过了标准的JVM堆栈。直接缓冲区是I/O的最佳选择,但可能比创建非直接缓冲区要花费更高的成本。直接缓冲区的内存区域不受GC支配,因为它们位于标准的JVM堆栈之外。使用直接缓冲区或间接缓冲区的性能权衡会因JVM,操作系统,以及代码设计而产生巨大差异。关于两者的选择,作者给出了一条建议:先使其工作,再加快其运行。言外之意就是,没事儿别瞎折腾,先使用间接缓冲区就可以了。但还是要知道下直接缓冲区是怎么创建的,通过ByteBuffer.allocateDirect()创建即可。

视图缓冲区

视图缓冲区通过已存在的缓冲区对象实例的工厂方法来创建。这种视图对象维护它自己的属性、容量、位置、上界和标记,但是和原来的缓冲区共享数据元素。但是ByteBuffer类允许创建视图来将byte型缓冲区字节数据映射为其它原始数据类型。例如,asLongBuffer()函数创建一个将八个字节(64位)数据当成一个long型数据来存取的视图缓冲区。我们用一个小例子来演示下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class BufferCharView {
public static void main(String[] args) {
// 创建容量为7的ByteBuffer实例,并手动指定字节序
ByteBuffer byteBuffer = ByteBuffer.allocate (7).order(ByteOrder.BIG_ENDIAN);
// 创建CharBuffer类型的视图缓冲区
CharBuffer charBuffer = byteBuffer.asCharBuffer( );
// 填充原始缓冲区
byteBuffer.put (0, (byte)0);
byteBuffer.put (1, (byte)'H');
byteBuffer.put (2, (byte)0);
byteBuffer.put (3, (byte)'i');
byteBuffer.put (4, (byte)0);
byteBuffer.put (5, (byte)'!');
byteBuffer.put (6, (byte)0);
println (byteBuffer);
println (charBuffer);
}
private static void println (Buffer buffer){
System.out.println ("pos=" + buffer.position( )
+ ", limit=" + buffer.limit( )
+ ", capacity=" + buffer.capacity( )
+ ": '" + buffer.toString( ) + "'");
}
}
输出如下(基础ByteBuffer对象对象中的两个字节映射成CHarBuffer对象的一个字符):
pos=0, limit=7, capacity=7: 'java.nio.HeapByteBuffer[pos=0 lim=7 cap=7]'
pos=0, limit=3, capacity=3: 'Hi!'

数据元素视图

ByteBuffer类提供一个不太重要的机制来以多字节数据类型的形式存取byte数据组。比如说getInt()函数被调用,从当前位置开始的四个字节会被包装成一个int类型的变量,然后作为函数的返回值返回。

内存映射缓冲区

映射缓冲区是带有存储在文件,通过内存映射来存取数据元素的字节缓冲区。映射缓冲区通常是直接存取内存的,只能通过 FileChannel 类创建。映射缓冲区的用法和直接缓冲区类似,但是MappedByteBuffer对象可以处理独立于文件存取形式的的许多特定字符。(MappedByteBuffer是ByteBuffer专门用于内存映射文件的一种特例,是ByteBuffer的子类)。这个具体后面再看。

缓冲区到这儿就算学完了,当然有些API并没有完全列出来,但是我们已经知道了其实现原理,针对性地去看源码也就不是什么事儿了。