1.什么是NIO
NIO即non-blocking,有的人称为NEW IO,在 Java1.4之前的I/O系统中,提供的都是面向流的I/O系统,系统一次一个字节地处理数据,一个输入流产生一个字节的数据,一个输出流消费一个字节 的数据,面向流的I/O速度非常慢,而在Java 1.4中推出了NIO,这是一个面向块的I/O系统,系统以块的方式处理处理,每一个操作在一步中产生或者消费一个数据块,按块处理要比按字节处理数据快的多。这也是NIO出现的原因。NIO将最耗时的IO活动(即填充缓冲区和排空缓冲区)移回到操作系统中,从而大大提高速度。
2.缓冲区(Buffer)、通道(Channel)
在NIO中有几个核心对象需要掌握:缓冲区(Buffer)、通道(Channel)、选择器(Selector)(本文不做论述)。
缓冲区Buffer
缓冲区是一个容器对象,其实就是一个数组,在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的; 在写入数据时,它也是写入到缓冲区中的;任何时候访问 NIO 中的数据,都是将它放到缓冲区中。而在面向流I/O系统中,所有数据都是直接写入或者直接将数据读取到Stream对象中。
在NIO中,所有的缓冲区类型都继承于抽象类Buffer,最常用的就是ByteBuffer。但是ByteBuffer不是NIO中唯一的缓冲区类型。实际上,每种基本Java类型都有一个缓冲区类型(boolean没有):
- ByteBuffer
- CharBuffer
- ShortBuffer
- IntBuffer
- LongBuffer
- FloatBuffer
- DoubleBuffer
通道Channel
通道是一个对象,可以从中读取数据并向其写入数据。比较NIO和原始IO,一个通道就像一个流。所有数据都是通过Buffer对象处理的。从不直接写一个字节到一个通道;而是写入包含一个或多个字节的缓冲区。同样,也不直接从一个通道读取一个字节;而是从通道读入缓冲区,然后从缓冲区获取字节。
3.理论与实践的结合:NIO中的读和写
在NIO系统中执行读取操作时,都是从channel读取的,但不直接从channel读取。由于所有数据最终都驻留在缓冲区中,因此可以从通道读入缓冲区。
因此从一个文件读取涉及三个步骤:
- 从FileInputStream获取Channel;
- 创建缓冲区;
- 从通道读入缓冲区。
从文件中读取数据
我们的第一步是获得一个渠道。我们从FileInputStream获得通道:
FileInputStream fin = new FileInputStream( "E://tmp//nicopy.txt" );
FileChannel fc = fin.getChannel();
下一步是创建一个缓冲区:
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
最后,我们需要从通道读入缓冲区,如下所示:
fc.read( buffer );
写入数据到文件
第一步:获取一个通道
FileOutputStream fout = new FileOutputStream( "E://tmp//copy.txt" );
FileChannel fc = fout.getChannel();
第二步:创建缓冲区,将数据放入缓冲区
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
for (int i=0; i<1000; ++i) {
buffer.put( i );
}
buffer.flip();
第三步:把缓冲区数据写入通道中
fc.write( buffer );
读写
String infile = "E://tmp//nicopy.txt";
String outfile = "E://tmp//result.txt";
FileInputStream fin = null;
FileOutputStream fout = null;
try {
fin = new FileInputStream(infile);
fout= new FileOutputStream(outfile);
} catch (FileNotFoundException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
// 获取读的通道
FileChannel fcin = fin.getChannel();
// 获取写的通道
FileChannel fcout = fout.getChannel();
// 定义缓冲区,并指定大小
ByteBuffer buffer = ByteBuffer.allocate(512);
while (true) {
// 清空缓冲区
buffer.clear();
//从通道读取一个数据到缓冲区
int r = 0;
try {
r = fcin.read(buffer);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
//判断是否有从通道读到数据
if (r == -1) {
break;
}
//将buffer指针指向头部
buffer.flip();
//把缓冲区数据写入通道
try {
fcout.write(buffer);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
4.缓冲区内部细节
状态变量
可以用三个值指定缓冲区在任意时刻的状态:
position
limit
capacity
这三个变量一起可以跟踪缓冲区的状态和它所包含的数据。
Position
缓冲区实际上就是美化了的数组。在从通道读取时,所读取的数据放到底层的数组中。 position
变量跟踪已经写了多少数据。更准确地说,它指定了下一个字节将放到数组的哪一个元素中。因此,如果从通道中读三个字节到缓冲区中,那么缓冲区的 position
将会设置为3,指向数组中第四个元素。
同样,在写入通道时,从缓冲区中获取数据。 position
值跟踪从缓冲区中获取了多少数据。更准确地说,它指定下一个字节来自数组的哪一个元素。因此如果从缓冲区写了5个字节到通道中,那么缓冲区的 position
将被设置为5,指向数组的第六个元素。
Limit
limit
变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。
position
总是小于或者等于 limit
。
Capacity
缓冲区的 capacity
表明可以储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小 ― 或者至少是指定了准许我们使用的底层数组的容量。
limit
决不能大于 capacity
。
观察变量
我们首先观察一个新创建的缓冲区。出于本例子的需要,我们假设这个缓冲区的 总容量
为8个字节。 Buffer
的状态如下所示:
回想一下 ,limit
决不能大于 capacity
,此例中这两个值都被设置为 8。我们通过将它们指向数组的尾部之后(如果有第8个槽,则是第8个槽所在的位置)来说明这点。
position
设置为0。如果我们读一些数据到缓冲区中,那么下一个读取的数据就进入 slot 0 。如果我们从缓冲区写一些数据,从缓冲区读取的下一个字节就来自 slot 0 。 position
设置如下所示:
由于 capacity
不会改变,所以我们在下面的讨论中可以忽略它。
第一次读取
现在我们可以开始在新创建的缓冲区上进行读/写操作。首先从输入通道中读一些数据到缓冲区中。第一次读取得到三个字节。它们被放到数组中从 position
开始的位置,这时 position 被设置为 0。读完之后,position 就增加到 3,如下所示:
limit
没有改变。
第二次读取
在第二次读取时,我们从输入通道读取另外两个字节到缓冲区中。这两个字节储存在由 position
所指定的位置上, position
因而增加 2:
limit
没有改变。
flip
现在我们要将数据写到输出通道中。在这之前,我们必须调用 flip()
方法。这个方法做两件非常重要的事:
- 它将
limit
设置为当前position
。 - 它将
position
设置为 0。
前一小节中的图显示了在 flip 之前缓冲区的情况。下面是在 flip 之后的缓冲区:
我们现在可以将数据从缓冲区写入通道了。 position
被设置为 0,这意味着我们得到的下一个字节是第一个字节。 limit
已被设置为原来的 position
,这意味着它包括以前读到的所有字节,并且一个字节也不多。
第一次写入
在第一次写入时,我们从缓冲区中取四个字节并将它们写入输出通道。这使得 position
增加到 4,而 limit
不变,如下所示:
第二次写入
我们只剩下一个字节可写了。 limit
在我们调用 flip()
时被设置为 5,并且 position
不能超过 limit
。所以最后一次写入操作从缓冲区取出一个字节并将它写入输出通道。这使得 position
增加到 5,并保持 limit
不变,如下所示:
clear
最后一步是调用缓冲区的 clear()
方法。这个方法重设缓冲区以便接收更多的字节。 Clear
做两种非常重要的事情:
- 它将
limit
设置为与capacity
相同。 - 它设置
position
为 0。
下图显示了在调用 clear()
后缓冲区的状态:
缓冲区现在可以接收新的数据了。
访问方法
到目前为止,我们只是使用缓冲区将数据从一个通道转移到另一个通道。然而,程序经常需要直接处理数据。例如,您可能需要将用户数据保存到磁盘。在这种情况下,您必须将这些数据直接放入缓冲区,然后用通道将缓冲区写入磁盘。
或者,您可能想要从磁盘读取用户数据。在这种情况下,您要将数据从通道读到缓冲区中,然后检查缓冲区中的数据。
在本节的最后,我们将详细分析如何使用 ByteBuffer
类的 get()
和 put()
方法直接访问缓冲区中的数据。
get() 方法
ByteBuffer
类中有四个 get()
方法:
byte get();
ByteBuffer get( byte dst[] );
ByteBuffer get( byte dst[], int offset, int length );
byte get( int index );
第一个方法获取单个字节。第二和第三个方法将一组字节读到一个数组中。第四个方法从缓冲区中的特定位置获取字节。那些返回 ByteBuffer
的方法只是返回调用它们的缓冲区的 this
值。
此外,我们认为前三个 get()
方法是相对的,而最后一个方法是绝对的。 相对 意味着 get()
操作服从 limit
和 position
值 ― 更明确地说,字节是从当前 position
读取的,而 position
在 get
之后会增加。另一方面,一个 绝对 方法会忽略 limit
和 position
值,也不会影响它们。事实上,它完全绕过了缓冲区的统计方法。
上面列出的方法对应于 ByteBuffer
类。其他类有等价的 get()
方法,这些方法除了不是处理字节外,其它方面是是完全一样的,它们处理的是与该缓冲区类相适应的类型。
put()方法
ByteBuffer
类中有五个 put()
方法:
ByteBuffer put( byte b );
ByteBuffer put( byte src[] );
ByteBuffer put( byte src[], int offset, int length );
ByteBuffer put( ByteBuffer src );
ByteBuffer put( int index, byte b );
第一个方法 写入(put)
单个字节。第二和第三个方法写入来自一个数组的一组字节。第四个方法将数据从一个给定的源 ByteBuffer
写入这个 ByteBuffer
。第五个方法将字节写入缓冲区中特定的 位置
。那些返回 ByteBuffer
的方法只是返回调用它们的缓冲区的 this
值。
与 get()
方法一样,我们将把 put()
方法划分为 相对 或者 绝对 的。前四个方法是相对的,而第五个方法是绝对的。
上面显示的方法对应于 ByteBuffer
类。其他类有等价的 put()
方法,这些方法除了不是处理字节之外,其它方面是完全一样的。它们处理的是与该缓冲区类相适应的类型。
类型化的 get() 和 put() 方法
除了前些小节中描述的 get()
和 put()
方法, ByteBuffer
还有用于读写不同类型的值的其他方法,如下所示:
getByte()
getChar()
getShort()
getInt()
getLong()
getFloat()
getDouble()
putByte()
putChar()
putShort()
putInt()
putLong()
putFloat()
putDouble()
事实上,这其中的每个方法都有两种类型 ― 一种是相对的,另一种是绝对的。它们对于读取格式化的二进制数据(如图像文件的头部)很有用。
您可以在例子程序 TypesInByteBuffer.java 中看到这些方法的实际应用。
缓冲区的使用:一个内部循环
下面的内部循环概括了使用缓冲区将数据从输入通道拷贝到输出通道的过程。
1 2 3 4 5 6 7 8 9 10 11 |
|
read()
和 write()
调用得到了极大的简化,因为许多工作细节都由缓冲区完成了。 clear()
和 flip()
方法用于让缓冲区在读和写之间切换。