您的位置:首页 > 其它

NIO核心概念及基本读写及缓冲区的内部实现机制

2014-12-12 11:22 615 查看
1. 引言

I/O流或者输入/输出流指的是计算机与外部世界或者一个程序与计算机的其余部分的之间的接口。新的输入/输出(NIO)库是在JDK 1.4中引入的。NIO弥补了原来的I/O的不足,它在标准Java代码中提供了高速的、面向块的I/O。

原来的I/O库与NIO最重要的区别是数据打包和传输的方式的不同,原来的 I/O 以流 的方式处理数据,而 NIO 以块 的方式处理数据。

面向流的I/O系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的I/O通常相当慢。

NIO与原来的I/O有同样的作用和目的,但是它使用块I/O的处理方式。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的I/O缺少一些面向流的I/O所具有的优雅性和简单性。

2. 从一个例子开始

下面我们从一个简单的使用IO和NIO读取一个文件中的内容为例,来进入NIO的学习之旅。

使用IO来读取指定文件中的前1024字节并打印出来:

Java代码
/**
* 使用IO读取指定文件的前1024个字节的内容。
* @param file 指定文件名称。
* @throws java.io.IOException IO异常。
*/
public void ioRead(String file) throws IOException {
FileInputStream in = new FileInputStream(file);
byte[] b = new byte[1024];
in.read(b);
System.out.println(new String(b));
}

/**
* 使用NIO读取指定文件的前1024个字节的内容。
* @param file 指定文件名称。
* @throws java.io.IOException IO异常。
*/
public void nioRead(String file) throws IOException {
FileInputStream in = new FileInputStream(file);
FileChannel channel = in.getChannel();

ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
byte[] b = buffer.array();
System.out.println(new String(b));
}


从上面的例子中可以看出,NIO以通道Channel和缓冲区Buffer为基础来实现面向块的IO数据处理。下面将讨论并学习NIO 库的核心概念以及从高级的特性到底层编程细节的几乎所有方面。

3. 核心概念:通道和缓冲区

1) 概述:

通道和缓冲区是NIO中的核心对象,几乎在每一个I/O操作中都要使用它们。

通道Channel是对原I/O包中的流的模拟。到任何目的地(或来自任何地方)的所有数据都必须通过一个Channel对象。

缓冲区Buffer实质上是一个容器对象。发送给一个通道的所有对象都必须首先放到缓冲区中;同样地,从通道中读取的任何数据都要读到缓冲区中。

2) 缓冲区:

Buffer是一个容器对象,它包含一些要写入或者刚读出的数据。在NIO中加入Buffer对象,体现了新库与原I/O的一个重要区别。在面向流的I/O中,您将数据直接写入或者将数据直接读到Stream对象中。

在NIO库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问NIO中的数据,您都是将它放到缓冲区中。

缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

最常用的缓冲区类型是ByteBuffer。 一个ByteBuffer可以在其底层字节数组上进行get/set操作(即字节的获取和设置)。

ByteBuffer不是NIO中唯一的缓冲区类型。事实上,对于每一种基本Java类型都有一种缓冲区类型:

ByteBuffer

CharBuffer

ShortBuffer

IntBuffer

LongBuffer

FloatBuffer

DoubleBuffer

每一个Buffer类都是Buffer接口的一个实例。 除了ByteBuffer, 每一个Buffer类都有完全一样的操作,只是它们所处理的数据类型不一样。因为大多数标准I/O操作都使用ByteBuffer,所以它具有所有共享的缓冲区操作以及一些特有的操作。

下面的UseFloatBuffer列举了使用类型化的缓冲区FloatBuffer的一个应用例子:

Java代码
/**
* 使用 float 缓冲区。
* @version 1.00 2010-5-19, 10:30:59
* @since 1.5
* @author ZhangShixi
*/
public class UseFloatBuffer {

public static void main(String[] args) {
// 分配一个容量为10的新的 float 缓冲区
FloatBuffer buffer = FloatBuffer.allocate(10);
for (int i = 0; i < buffer.capacity(); i++) {
float f = (float) Math.sin((((float) i) / 10) * (2 * Math.PI));
buffer.put(f);
}
// 反转此缓冲区
buffer.flip();

// 告知在当前位置和限制之间是否有元素
while (buffer.hasRemaining()) {
float f = buffer.get();
System.out.println(f);
}
}
}


3) 通道:

Channel是对原I/O包中的流的模拟,可以通过它读取和写入数据。拿NIO与原来的I/O做个比较,通道就像是流。

正如前面提到的,所有数据都通过Buffer对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道 读入缓冲区,再从缓冲区获取这个字节。

通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是InputStream或者OutputStream的子类), 而通道可以用于读、写或者同时用于读写。

因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在UNIX模型中,底层操作系统通道是双向的。

4. 从理论到实践:NIO中的读和写

1) 概述:

读和写是I/O的基本过程。从一个通道中读取很简单:只需创建一个缓冲区,然后让通道将数据读到这个缓冲区中。写入也相当简单:创建一个缓冲区,用数据填充它,然后让通 道用这些数据来执行写入操作。

2) 从文件中读取:

如果使用原来的I/O,那么我们只需创建一个FileInputStream并从它那里读取。而在NIO中,情况稍有不同:我们首先从FileInputStream获取一个FileChannel对象,然后使用这个通道来读取数据。

在NIO系统中,任何时候执行一个读操作,您都是从通道中读取,但是您不是直接从通道读取。因为所有数据最终都驻留在缓冲区中,所以您是从通道读到缓冲区中。

因此读取文件涉及三个步骤:

(1) 从FileInputStream获取Channel。

(2) 创建Buffer。

(3) 将数据从Channel读到Buffer 中。

现在,让我们看一下这个过程。

Java代码
// 第一步是获取通道。我们从 FileInputStream 获取通道:
FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();
// 下一步是创建缓冲区:
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
// 最后,需要将数据从通道读到缓冲区中:
fc.read( buffer );


您会注意到,我们不需要告诉通道要读多少数据到缓冲区中。每一个缓冲区都有复杂的内部统计机制,它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据。我们将在缓冲区内部细节中介绍更多关于缓冲区统计机制的内容。

3) 写入文件:

在 NIO 中写入文件类似于从文件中读取。

Java代码
// 首先从 FileOutputStream 获取一个通道:
FileOutputStream fout = new FileOutputStream( "writesomebytes.txt" );
FileChannel fc = fout.getChannel();
// 下一步是创建一个缓冲区并在其中放入一些数据,这里,用message来表示一个持有数据的数组。
ByteBuffer buffer = ByteBuffer.allocate( 1024 );
for (int i=0; i<message.length; ++i) {
buffer.put( message[i] );
}
buffer.flip();
// 最后一步是写入缓冲区中:
fc.write( buffer );


注意在这里同样不需要告诉通道要写入多数据。缓冲区的内部统计机制会跟踪它包含多少数据以及还有多少数据要写入。

4) 读写结合:

下面的示例将展示使用读写结合,将一个文件的所有内容拷贝到另一个文件中。

Java代码
/**
* 将一个文件的所有内容拷贝到另一个文件中。
*
* CopyFile.java 执行三个基本操作:
* 首先创建一个 Buffer,然后从源文件中将数据读到这个缓冲区中,然后将缓冲区写入目标文件。
* 程序不断重复 — 读、写、读、写 — 直到源文件结束。
*
* @version 1.00 2010-5-19, 10:49:46
* @since 1.5
* @author ZhangShixi
*/
public class CopyFile {

public static void main(String[] args) throws Exception {
String infile = "C:\\copy.sql";
String outfile = "C:\\copy.txt";

// 获取源文件和目标文件的输入输出流
FileInputStream fin = new FileInputStream(infile);
FileOutputStream fout = new FileOutputStream(outfile);

// 获取输入输出通道
FileChannel fcin = fin.getChannel();
FileChannel fcout = fout.getChannel();

// 创建缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);

while (true) {
// clear方法重设缓冲区,使它可以接受读入的数据
buffer.clear();

// 从输入通道中将数据读到缓冲区
int r = fcin.read(buffer);

// read方法返回读取的字节数,可能为零,如果该通道已到达流的末尾,则返回-1
if (r == -1) {
break;
}

// flip方法让缓冲区可以将新读入的数据写入另一个通道
buffer.flip();

// 从输出通道中将数据写入缓冲区
fcout.write(buffer);
}
}
}


5. 缓冲区内部实现

从上面对NIO的学习中,我们知道每一个缓冲区都有复杂的内部统计机制,它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据,以便我们对缓冲区的操作。在本节我们就将学习NIO的两个重要的缓冲区组件:状态变量和访问方法。虽然NIO的内部统计机制初看起来可能很复杂,但是您很快就会看到大部分的实际工作都已经替您完成了。您只需像平时使用字节数组和索引变量一样进行操作即可。

1) 状态变量:

状态变量是前一节中提到的"内部统计机制"的关键。 每一个读/写操作都会改变缓冲区的状态。通过记录和跟踪这些变化,缓冲区就可能够内部地管理自己的资源。

每一种Java基本类型的缓冲区都是抽象类Buffer的子类,从Buffer的源代码中可以发现,它定义了三个私有属性:

Java代码
private int position = 0;
private int limit;
private int capacity;


实际上,这三个属性值可以指定缓冲区在任意时刻的状态和它所包含的数据。

我们知道,每一个基本类型的缓冲区底层实际上就是一个该类型的数组。如在ByteBuffer中,有:

Java代码
final byte[] hb;


在从通道读取时,所读取的数据将放被到底层的数组中;同理,向通道中写入时,将从底层数组中将数据写入通道。下面我们来具体介绍这三个变量的作用:

a) position

position变量跟踪了向缓冲区中写入了多少数据或者从缓冲区中读取了多少数据。

更确切的说,当您从通道中读取数据到缓冲区中时,它指示了下一个数据将放到数组的哪一个元素中。比如,如果您从通道中读三个字节到缓冲区中,那么缓冲区的position将会设置为3,指向数组中第4个元素。反之,当您从缓冲区中获取数据进行写通道时,它指示了下一个数据来自数组的哪一个元素。比如,当您从缓冲区写了5个字节到通道中,那么缓冲区的 position 将被设置为5,指向数组的第六个元素。

b) limit

limit变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。

position总是小于或者等于limit。

c) capacity

capacity变量表明可以储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小—或者至少是指定了准许我们使用的底层数组的容量。

limit总是小于或者等于capacity。

d) 举例说明:

下面我们就以数据从一个输入通道拷贝到一个输出通道为例,来详细分析每一个变量,并说明它们是如何协同工作的:

初始变量:

我们首先观察一个新创建的缓冲区,以ByteBuffer为例,假设缓冲区的大小为8个字节,ByteBuffer初始状态如下:



回想一下 ,limit决不能大于capacity,此例中这两个值都被设置为8。我们通过将它们指向数组的尾部之后(第8个槽位)来说明这点。



我们再将position设置为0。表示如果我们读一些数据到缓冲区中,那么下一个读取的数据就进入 slot 0。如果我们从缓冲区写一些数据,从缓冲区读取的下一个字节就来自slot 0。position设置如下所示:



由于缓冲区的最大数据容量capacity不会改变,所以我们在下面的讨论中可以忽略它。

第一次读取:

现在我们可以开始在新创建的缓冲区上进行读/写操作了。首先从输入通道中读一些数据到缓冲区中。第一次读取得到三个字节。它们被放到数组中从position开始的位置,这时position被设置为0。读完之后,position就增加到了3,如下所示,limit没有改变。



第二次读取:

在第二次读取时,我们从输入通道读取另外两个字节到缓冲区中。这两个字节储存在由position所指定的位置上, position因而增加2,limit没有改变。



flip:

现在我们要将数据写到输出通道中。在这之前,我们必须调用flip()方法。 其源代码如下:

Java代码
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}


这个方法做两件非常重要的事:

i 它将limit设置为当前position。

ii 它将position设置为0。

上一个图显示了在flip之前缓冲区的情况。下面是在flip之后的缓冲区:



我们现在可以将数据从缓冲区写入通道了。position被设置为0,这意味着我们得到的下一个字节是第一个字节。limit已被设置为原来的position,这意味着它包括以前读到的所有字节,并且一个字节也不多。

第一次写入:

在第一次写入时,我们从缓冲区中取四个字节并将它们 写入输出通道。这使得position增加到4,而limit不变,如下所示:



第二次写入:

我们只剩下一个字节可写了。limit在我们调用flip()时被设置为5,并且position不能超过limit。 所以最后一次写入操作从缓冲区取出一个字节并将它写入输出通道。这使得position增加到5,并保持limit不变,如下所示:



clear:

最后一步是调用缓冲区的clear()方法。这个方法重设缓冲区以便接收更多的字节。其源代码如下:

Java代码
public final Buffer clear() {
osition = 0;
limit = capacity;
mark = -1;
return this;
}


clear做两种非常重要的事情:

i 它将limit设置为与capacity相同。

ii 它设置position为0。

下图显示了在调用clear()后缓冲区的状态, 此时缓冲区现在可以接收新的数据了。



2) 访问方法:

到目前为止,我们只是使用缓冲区将数据从一个通道转移到另一个通道。然而,程序经常需要直接处理数据。例如,您可能需要将用户数据保存到磁盘。在这种情况下,您必须将这些数据直接放入缓冲区,然后用通道将缓冲区写入磁盘。 或者,您可能想要从磁盘读取用户数据。在这种情况下,您要将数据从通道读到缓冲区中,然后检查缓冲区中的数据。

实际上,每一个基本类型的缓冲区都为我们提供了直接访问缓冲区中数据的方法,我们以ByteBuffer为例,分析如何使用其提供的get()和put()方法直接访问缓冲区中的数据。

a) get()

ByteBuffer类中有四个get()方法:

Java代码
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()方法,这些方法除了不是处理字节外,其它方面是是完全一样的,它们处理的是与该缓冲区类相适应的类型。

b) put()

ByteBuffer类中有五个put()方法:

Java代码
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()方法,这些方法除了不是处理字节之外,其它方面是完全一样的。它们处理的是与该缓冲区类相适应的类型。

c) 类型化的 get() 和 put() 方法

除了前些小节中描述的get()和put()方法, ByteBuffer还有用于读写不同类型的值的其他方法,如下所示:

getByte()

getChar()

getShort()

getInt()

getLong()

getFloat()

getDouble()

putByte()

putChar()

putShort()

putInt()

putLong()

putFloat()

putDouble()

事实上,这其中的每个方法都有两种类型:一种是相对的,另一种是绝对的。它们对于读取格式化的二进制数据(如图像文件的头部)很有用。

3) 如何使用?

下面的内部循环概括了使用缓冲区将数据从输入通道拷贝到输出通道的过程。

Java代码
while (true) {
buffer.clear();
int r = fcin.read( buffer );

if (r==-1) {
break;
}

buffer.flip();
fcout.write( buffer );
}


read()和write()调用得到了极大的简化,因为许多工作细节都由缓冲区完成了。clear()和flip()方法用于让缓冲区在读和写之间切换。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: