NIO 入门

在开始以前

关于本教程

新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的。NIO 弥补了原来的 I/O 的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。经过定义包含数据的类,以及经过以块的形式处理这些数据,NIO 不用使用本机代码就能够利用低级优化,这是原来的 I/O 包所没法作到的。 html

在本教程中,咱们将讨论 NIO 库的几乎全部方面,从高级的概念性内容到底层的编程细节。除了学习诸如缓冲区和通道这样的关键 I/O 元素外,您还有机会看到在更新后的库中标准 I/O 是如何工做的。您还会了解只能经过 NIO 来完成的工做,如异步 I/O 和直接缓冲区。 java

在本教程中,咱们将使用展现 NIO 库的不一样方面的代码示例。几乎每个代码示例都是一个大的 Java 程序的一部分,您能够在 参考资料 中找到这个 Java 程序。在作这些练习时,咱们推荐您在本身的系统上下载、编译和运行这些程序。在您学习了本教程之后,这些代码将为您的 NIO 编程努力提供一个起点。程序员

本教程是为但愿学习更多关于 JDK 1.4 NIO 库的知识的全部程序员而写的。为了最大程度地从这里的讨论中获益,您应该理解基本的 Java 编程概念,如类、继承和使用包。多少熟悉一些原来的 I/O 库(来自 java.io.* 包)也会有所帮助。 shell

虽然本教程要求掌握 Java 语言的工做词汇和概念,可是不须要有不少实际编程经验。除了完全介绍与本教程有关的全部概念外,我还保持代码示例尽量短小和简单。目的是让即便没有多少 Java 编程经验的读者也能容易地开始学习 NIO。 编程

如何运行代码

源代码归档文件(在 参考资料 中提供)包含了本教程中使用的全部程序。每个程序都由一个 Java 文件构成。每个文件都根据名称来识别,而且能够容易地与它所展现的编程概念相关联。 api

教程中的一些程序须要命令行参数才能运行。要从命令行运行一个程序,只需使用最方便的命令行提示符。在 Windows 中,命令行提供符是 “Command” 或者 “command.com” 程序。在 UNIX 中,可使用任何 shell。 数组

须要安装 JDK 1.4 并将它包括在路径中,才能完成本教程中的练习。若是须要安装和配置 JDK 1.4 的帮助,请参见 参考资料安全

输入/输出:概念性描述

I/O 简介

I/O ? 或者输入/输出 ? 指的是计算机与外部世界或者一个程序与计算机的其他部分的之间的接口。它对于任何计算机系统都很是关键,于是全部 I/O 的主体其实是内置在操做系统中的。单独的程序通常是让系统为它们完成大部分的工做。 服务器

在 Java 编程中,直到最近一直使用 的方式完成 I/O。全部 I/O 都被视为单个的字节的移动,经过一个称为 Stream 的对象一次移动一个字节。流 I/O 用于与外部世界接触。它也在内部使用,用于将对象转换为字节,而后再转换回对象。 网络

NIO 与原来的 I/O 有一样的做用和目的,可是它使用不一样的方式? 块 I/O。正如您将在本教程中学到的,块 I/O 的效率能够比流 I/O 高许多。

为何要使用 NIO?

NIO 的建立目的是为了让 Java 程序员能够实现高速 I/O 而无需编写自定义的本机代码。NIO 将最耗时的 I/O 操做(即填充和提取缓冲区)转移回操做系统,于是能够极大地提升速度。

流与块的比较

原来的 I/O 库(在 java.io.*中) 与 NIO 最重要的区别是数据打包和传输的方式。正如前面提到的,原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

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

一个 面向块 的 I/O 系统以块的形式处理数据。每个操做都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。可是面向块的 I/O 缺乏一些面向流的 I/O 所具备的优雅性和简单性。

集成的 I/O

在 JDK 1.4 中原来的 I/O 包和 NIO 已经很好地集成了。 java.io.* 已经以 NIO 为基础从新实现了,因此如今它能够利用 NIO 的一些特性。例如, java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即便在更面向流的系统中,处理速度也会更快。

也能够用 NIO 库实现标准 I/O 功能。例如,能够容易地使用块 I/O 一次一个字节地移动数据。可是正如您会看到的,NIO 还提供了原 I/O 包中所没有的许多好处。

通道和缓冲区

概述

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

通道是对原 I/O 包中的流的模拟。到任何目的地(或来自任何地方)的全部数据都必须经过一个 Channel 对象。一个 Buffer 实质上是一个容器对象。发送给一个通道的全部对象都必须首先放到缓冲区中;一样地,从通道中读取的任何数据都要读到缓冲区中。

在本节中,您会了解到 NIO 中通道和缓冲区是如何工做的。

什么是缓冲区?

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.java,它包含了类型化的缓冲区的一个应用例子。

什么是通道?

Channel是一个对象,能够经过它读取和写入数据。拿 NIO 与原来的 I/O 作个比较,通道就像是流。

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

通道类型

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

由于它们是双向的,因此通道能够比流更好地反映底层操做系统的真实状况。特别是在 UNIX 模型中,底层操做系统通道是双向的。

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

概述

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

在本节中,咱们将学习有关在 Java 程序中读取和写入数据的一些知识。咱们将回顾 NIO 的主要组件(缓冲区、通道和一些相关的方法),看看它们是如何交互以进行读写的。在接下来的几节中,咱们将更详细地分析这其中的每一个组件以及其交互。

从文件中读取

在咱们第一个练习中,咱们将从一个文件中读取一些数据。若是使用原来的 I/O,那么咱们只需建立一个 FileInputStream 并从它那里读取。而在 NIO 中,状况稍有不一样:咱们首先从 FileInputStream 获取一个 Channel 对象,而后使用这个通道来读取数据。

在 NIO 系统中,任什么时候候执行一个读操做,您都是从通道中读取,可是您不是 直接 从通道读取。由于全部数据最终都驻留在缓冲区中,因此您是从通道读到缓冲区中。

所以读取文件涉及三个步骤:(1) 从 FileInputStream 获取 Channel,(2) 建立 Buffer,(3) 将数据从 Channel 读到 Buffer 中。

如今,让咱们看一下这个过程。

三个容易的步骤

第一步是获取通道。咱们从 FileInputStream 获取通道:

FileInputStream fin = new FileInputStream( "readandshow.txt" );
FileChannel fc = fin.getChannel();

下一步是建立缓冲区:

ByteBuffer buffer = ByteBuffer.allocate( 1024 );

最后,须要将数据从通道读到缓冲区中,以下所示:

fc.read( buffer );

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

写入文件

在 NIO 中写入文件相似于从文件中读取。首先从 FileOutputStream 获取一个通道:

FileOutputStream fout = new FileOutputStream( "writesomebytes.txt" );
FileChannel fc = fout.getChannel();

下一步是建立一个缓冲区并在其中放入一些数据 - 在这里,数据将从一个名为 message 的数组中取出,这个数组包含字符串
"Some bytes" 的 ASCII 字节(本教程后面将会解释 buffer.flip()
buffer.put() 调用)。

ByteBuffer buffer = ByteBuffer.allocate( 1024 );

for (int i=0; i<message.length; ++i) {
buffer.put( message[i] );
}
buffer.flip();

最后一步是写入缓冲区中:

fc.write( buffer );

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

读写结合

下面咱们将看一下在结合读和写时会有什么状况。咱们以一个名为 CopyFile.java
的简单程序做为这个练习的基础,它将一个文件的全部内容拷贝到另外一个文件中。CopyFile.java 执行三个基本操做:首先建立一个
Buffer,而后从源文件中将数据读到这个缓冲区中,而后将缓冲区写入目标文件。这个程序不断重复 ― 读、写、读、写 ―
直到源文件结束。

CopyFile 程序让您看到咱们如何检查操做的状态,以及如何使用 clear()
flip() 方法重设缓冲区,并准备缓冲区以便将新读取的数据写到另外一个通道中。

运行 CopyFile 例子

由于缓冲区会跟踪它本身的数据,因此 CopyFile 程序的内部循环 (inner loop) 很是简单,以下所示:

fcin.read( buffer );
fcout.write( buffer );

第一行将数据从输入通道 fcin 中读入缓冲区,第二行将这些数据写到输出通道 fcout

检查状态

下一步是检查拷贝什么时候完成。当没有更多的数据时,拷贝就算完成,而且能够在 read() 方法返回 -1
是判断这一点,以下所示:

int r = fcin.read( buffer );

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

重设缓冲区

最后,在从输入通道读入缓冲区以前,咱们调用 clear() 方法。一样,在将缓冲区写入输出通道以前,咱们调用
flip() 方法,以下所示:

buffer.clear();
int r = fcin.read( buffer );

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

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

clear() 方法重设缓冲区,使它能够接受读入的数据。 flip()
方法让缓冲区能够将新读入的数据写入另外一个通道。

缓冲区内部细节

概述

本节将介绍 NIO 中两个重要的缓冲区组件:状态变量和访问方法 (accessor)。

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

在从通道读取数据时,数据被放入到缓冲区。在有些状况下,能够将这个缓冲区直接写入另外一个通道,可是在通常状况下,您还须要查看数据。这是使用
访问方法 get() 来完成的。一样,若是要将原始数据放入缓冲区中,就要使用访问方法
put()

在本节中,您将学习关于 NIO 中的状态变量和访问方法的内容。咱们将描述每个组件,并让您有机会看到它的实际应用。虽然 NIO
的内部统计机制初看起来可能很复杂,可是您很快就会看到大部分的实际工做都已经替您完成了。您可能习惯于经过手工编码进行簿记 ―
即便用字节数组和索引变量,如今它已在 NIO 中内部地处理了。

状态变量

能够用三个值指定缓冲区在任意时刻的状态:

  • position
  • limit
  • capacity

这三个变量一块儿能够跟踪缓冲区的状态和它所包含的数据。咱们将在下面的小节中详细分析每个变量,还要介绍它们如何适应典型的读/写(输入/输出)进程。在这个例子中,咱们假定要将数据从一个输入通道拷贝到一个输出通道。

Position

您能够回想一下,缓冲区实际上就是美化了的数组。在从通道读取时,您将所读取的数据放到底层的数组中。 position
变量跟踪已经写了多少数据。更准确地说,它指定了下一个字节将放到数组的哪个元素中。所以,若是您从通道中读三个字节到缓冲区中,那么缓冲区的
position 将会设置为3,指向数组中第四个元素。

一样,在写入通道时,您是从缓冲区中获取数据。 position
值跟踪从缓冲区中获取了多少数据。更准确地说,它指定下一个字节来自数组的哪个元素。所以若是从缓冲区写了5个字节到通道中,那么缓冲区的
position 将被设置为5,指向数组的第六个元素。

Limit

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

position 老是小于或者等于 limit

Capacity

缓冲区的 capacity 代表能够储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小 ―
或者至少是指定了准许咱们使用的底层数组的容量。

limit 决不能大于 capacity

观察变量

咱们首先观察一个新建立的缓冲区。出于本例子的须要,咱们假设这个缓冲区的 总容量 为8个字节。
Buffer 的状态以下所示:

Buffer state
Buffer state

回想一下 ,limit 决不能大于 capacity,此例中这两个值都被设置为
8。咱们经过将它们指向数组的尾部以后(若是有第8个槽,则是第8个槽所在的位置)来讲明这点。

Array
Array

position 设置为0。若是咱们读一些数据到缓冲区中,那么下一个读取的数据就进入 slot 0
。若是咱们从缓冲区写一些数据,从缓冲区读取的下一个字节就来自 slot 0 。 position 设置以下所示:

Position setting
Position setting

因为 capacity 不会改变,因此咱们在下面的讨论中能够忽略它。

第一次读取

如今咱们能够开始在新建立的缓冲区上进行读/写操做。首先从输入通道中读一些数据到缓冲区中。第一次读取获得三个字节。它们被放到数组中从
position 开始的位置,这时 position 被设置为 0。读完以后,position 就增长到 3,以下所示:

Position increased to 3
Position increased to 3

limit 没有改变。

第二次读取

在第二次读取时,咱们从输入通道读取另外两个字节到缓冲区中。这两个字节储存在由 position 所指定的位置上,
position 于是增长 2:

Position increased by 2
Position increased by 2

limit 没有改变。

flip

如今咱们要将数据写到输出通道中。在这以前,咱们必须调用 flip() 方法。这个方法作两件很是重要的事:

  1. 它将 limit 设置为当前 position
  2. 它将 position 设置为 0。

前一小节中的图显示了在 flip 以前缓冲区的状况。下面是在 flip 以后的缓冲区:

Buffer after the flip
Buffer after the flip

咱们如今能够将数据从缓冲区写入通道了。 position 被设置为 0,这意味着咱们获得的下一个字节是第一个字节。
limit 已被设置为原来的
position,这意味着它包括之前读到的全部字节,而且一个字节也很少。

第一次写入

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

Position advanced to 4, limit unchanged
Position advanced to 4, limit unchanged

第二次写入

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

Position advanced to 5, limit unchanged
Position advanced to 5, limit unchanged

clear

最后一步是调用缓冲区的 clear() 方法。这个方法重设缓冲区以便接收更多的字节。 Clear
作两种很是重要的事情:

  1. 它将 limit 设置为与 capacity 相同。
  2. 它设置 position 为 0。

下图显示了在调用 clear() 后缓冲区的状态:

State of the buffer after clear() has been called
State of the buffer after clear() has been called

缓冲区如今能够接收新的数据了。

访问方法

到目前为止,咱们只是使用缓冲区将数据从一个通道转移到另外一个通道。然而,程序常常须要直接处理数据。例如,您可能须要将用户数据保存到磁盘。在这种状况下,您必须将这些数据直接放入缓冲区,而后用通道将缓冲区写入磁盘。

或者,您可能想要从磁盘读取用户数据。在这种状况下,您要将数据从通道读到缓冲区中,而后检查缓冲区中的数据。

在本节的最后,咱们将详细分析如何使用 ByteBuffer 类的 get()
put() 方法直接访问缓冲区中的数据。

get() 方法

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

  1. byte get();
  2. ByteBuffer get( byte dst[] );
  3. ByteBuffer get( byte dst[], int offset, int length );
  4. byte get( int index );

第一个方法获取单个字节。第二和第三个方法将一组字节读到一个数组中。第四个方法从缓冲区中的特定位置获取字节。那些返回
ByteBuffer 的方法只是返回调用它们的缓冲区的 this 值。

此外,咱们认为前三个 get() 方法是相对的,而最后一个方法是绝对的。 相对 意味着
get() 操做服从 limitposition 值 ―
更明确地说,字节是从当前 position 读取的,而 position
get 以后会增长。另外一方面,一个 绝对 方法会忽略 limit
position 值,也不会影响它们。事实上,它彻底绕过了缓冲区的统计方法。

上面列出的方法对应于 ByteBuffer 类。其余类有等价的 get()
方法,这些方法除了不是处理字节外,其它方面是是彻底同样的,它们处理的是与该缓冲区类相适应的类型。

put()方法

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

  1. ByteBuffer put( byte b );
  2. ByteBuffer put( byte src[] );
  3. ByteBuffer put( byte src[], int offset, int length );
  4. ByteBuffer put( ByteBuffer src );
  5. 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 中看到这些方法的实际应用。

缓冲区的使用:一个内部循环

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

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


}

if (r==-1) { break; } buffer.flip(); fcout.write( buffer );

read()write() 调用获得了极大的简化,由于许多工做细节都由缓冲区完成了。
clear()flip() 方法用于让缓冲区在读和写之间切换。

关于缓冲区的更多内容

概述

到目前为止,您已经学习了使用缓冲区进行平常工做所须要掌握的大部份内容。咱们的例子没怎么超出标准的读/写过程种类,在原来的 I/O 中能够像在 NIO
中同样容易地实现这样的标准读写过程。

本节将讨论使用缓冲区的一些更复杂的方面,好比缓冲区分配、包装和分片。咱们还会讨论 NIO 带给 Java
平台的一些新功能。您将学到如何建立不一样类型的缓冲区以达到不一样的目的,如可保护数据不被修改的 只读
缓冲区,和直接映射到底层操做系统缓冲区的 直接 缓冲区。咱们将在本节的最后介绍如何在 NIO 中建立内存映射文件。

缓冲区分配和包装

在可以读和写以前,必须有一个缓冲区。要建立缓冲区,您必须 分配 它。咱们使用静态方法 allocate()
来分配缓冲区:

ByteBuffer buffer = ByteBuffer.allocate( 1024 );

allocate() 方法分配一个具备指定大小的底层数组,并将它包装到一个缓冲区对象中 ― 在本例中是一个
ByteBuffer

您还能够将一个现有的数组转换为缓冲区,以下所示:

byte array[] = new byte[1024];
ByteBuffer buffer = ByteBuffer.wrap( array );

本例使用了 wrap()
方法将一个数组包装为缓冲区。必须很是当心地进行这类操做。一旦完成包装,底层数据就能够经过缓冲区或者直接访问。

缓冲区分片

slice() 方法根据现有的缓冲区建立一种 子缓冲区
。也就是说,它建立一个新的缓冲区,新缓冲区与原来的缓冲区的一部分共享数据。

使用例子能够最好地说明这点。让咱们首先建立一个长度为 10 的 ByteBuffer

ByteBuffer buffer = ByteBuffer.allocate( 10 );

而后使用数据来填充这个缓冲区,在第 n 个槽中放入数字 n

for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}

如今咱们对这个缓冲区 分片 ,以建立一个包含槽 3 到槽 6 的子缓冲区。在某种意义上,子缓冲区就像原来的缓冲区中的一个
窗口

窗口的起始和结束位置经过设置 positionlimit 值来指定,而后调用
Bufferslice() 方法:

buffer.position( 3 );
buffer.limit( 7 );
ByteBuffer slice = buffer.slice();

是缓冲区的 子缓冲区 。不过, 片断
缓冲区 共享同一个底层数据数组,咱们在下一节将会看到这一点。

缓冲区份片和数据共享

咱们已经建立了原缓冲区的子缓冲区,而且咱们知道缓冲区和子缓冲区共享同一个底层数据数组。让咱们看看这意味着什么。

咱们遍历子缓冲区,将每个元素乘以 11 来改变它。例如,5 会变成 55。

for (int i=0; i<slice.capacity(); ++i) {
byte b = slice.get( i );
b *= 11;
slice.put( i, b );
}

最后,再看一下原缓冲区中的内容:

buffer.position( 0 );
buffer.limit( buffer.capacity() );

while (buffer.remaining()>0) {
System.out.println( buffer.get() );
}

结果代表只有在子缓冲区窗口中的元素被改变了:

$ java SliceBuffer
0
1
2
33
44
55
66
7
8
9

缓冲区片对于促进抽象很是有帮助。能够编写本身的函数处理整个缓冲区,并且若是想要将这个过程应用于子缓冲区上,您只需取主缓冲区的一个片,并将它传递给您的函数。这比编写本身的函数来取额外的参数以指定要对缓冲区的哪一部分进行操做更容易。

只读缓冲区

只读缓冲区很是简单 ― 您能够读取它们,可是不能向它们写入。能够经过调用缓冲区的 asReadOnlyBuffer()
方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区彻底相同的缓冲区(并与其共享数据),只不过它是只读的。

只读缓冲区对于保护数据颇有用。在将缓冲区传递给某个对象的方法时,您没法知道这个方法是否会修改缓冲区中的数据。建立一个只读的缓冲区能够 保证
该缓冲区不会被修改。

不能将只读的缓冲区转换为可写的缓冲区。

直接和间接缓冲区

另外一种有用的 ByteBuffer 是直接缓冲区。 直接缓冲区 是为加快 I/O
速度,而以一种特殊的方式分配其内存的缓冲区。

实际上,直接缓冲区的准肯定义是与实现相关的。Sun 的文档是这样描述直接缓冲区的:

给定一个直接字节缓冲区,Java 虚拟机将尽最大努力直接对它执行本机 I/O 操做。也就是说,它会在每一次调用底层操做系统的本机 I/O
操做以前(或以后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区中(或者从一个中间缓冲区中拷贝数据)。

您能够在例子程序 FastCopyFile.java 中看到直接缓冲区的实际应用,这个程序是 CopyFile.java
的另外一个版本,它使用了直接缓冲区以提升速度。

还能够用内存映射文件建立直接缓冲区。

内存映射文件 I/O

内存映射文件 I/O 是一种读和写文件数据的方法,它能够比常规的基于流或者基于通道的 I/O 快得多。

内存映射文件 I/O
是经过使文件中的数据神奇般地出现为内存数组的内容来完成的。这其初听起来彷佛不过就是将整个文件读到内存中,可是事实上并非这样。通常来讲,只有文件中实际读取或者写入的部分才会送入(或者
映射 )到内存中。

内存映射并不真的神奇或者多么不寻常。现代操做系统通常根据须要将文件的部分映射为内存的部分,从而实现文件系统。Java
内存映射机制不过是在底层操做系统中能够采用这种机制时,提供了对该机制的访问。

尽管建立内存映射文件至关简单,可是向它写入多是危险的。仅只是改变数组的单个元素这样的简单操做,就可能会直接修改磁盘上的文件。修改数据与将数据保存到磁盘是没有分开的。

将文件映射到内存

了解内存映射的最好方法是使用例子。在下面的例子中,咱们要将一个 FileChannel
(它的所有或者部分)映射到内存中。为此咱们将使用 FileChannel.map() 方法。下面代码行将文件的前 1024
个字节映射到内存中:

MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE,
0, 1024 );

map() 方法返回一个 MappedByteBuffer,它是
ByteBuffer 的子类。所以,您能够像使用其余任何 ByteBuffer
同样使用新映射的缓冲区,操做系统会在须要时负责执行行映射。

分散和汇集

概述

分散/汇集 I/O 是使用多个而不是单个缓冲区来保存数据的读写方法。

一个分散的读取就像一个常规通道读取,只不过它是将数据读到一个缓冲区数组中而不是读到单个缓冲区中。一样地,一个汇集写入是向缓冲区数组而不是向单个缓冲区写入数据。

分散/汇集 I/O 对于将数据流划分为单独的部分颇有用,这有助于实现复杂的数据格式。

分散/汇集 I/O

通道能够有选择地实现两个新的接口: ScatteringByteChannel
GatheringByteChannel。一个 ScatteringByteChannel
是一个具备两个附加读方法的通道:

  • long read( ByteBuffer[] dsts );
  • long read( ByteBuffer[] dsts, int offset, int length );

这些 long read() 方法很像标准的 read
方法,只不过它们不是取单个缓冲区而是取一个缓冲区数组。

分散读取 中,通道依次填充每一个缓冲区。填满一个缓冲区后,它就开始填充下一个。在某种意义上,缓冲区数组就像一个大缓冲区。

分散/汇集的应用

分散/汇集 I/O
对于将数据划分为几个部分颇有用。例如,您可能在编写一个使用消息对象的网络应用程序,每个消息被划分为固定长度的头部和固定长度的正文。您能够建立一个恰好能够容纳头部的缓冲区和另外一个恰好能够容难正文的缓冲区。当您将它们放入一个数组中并使用分散读取来向它们读入消息时,头部和正文将整齐地划分到这两个缓冲区中。

咱们从缓冲区所获得的方便性对于缓冲区数组一样有效。由于每个缓冲区都跟踪本身还能够接受多少数据,因此分散读取会自动找到有空间接受数据的第一个缓冲区。在这个缓冲区填满后,它就会移动到下一个缓冲区。

汇集写入

汇集写入 相似于分散读取,只不过是用来写入。它也有接受缓冲区数组的方法:

  • long write( ByteBuffer[] srcs );
  • long write( ByteBuffer[] srcs, int offset, int length );

汇集写对于把一组单独的缓冲区中组成单个数据流颇有用。为了与上面的消息例子保持一致,您可使用汇集写入来自动将网络消息的各个部分组装为单个数据流,以便跨越网络传输消息。

从例子程序 UseScatterGather.java 中能够看到分散读取和汇集写入的实际应用。

文件锁定

概述

文件锁定初看起来可能让人迷惑。它 彷佛 指的是防止程序或者用户访问特定文件。事实上,文件锁就像常规的 Java 对象锁 ― 它们是
劝告式的(advisory) 锁。它们不阻止任何形式的数据访问,相反,它们经过锁的共享和获取赖容许系统的不一样部分相互协调。

您能够锁定整个文件或者文件的一部分。若是您获取一个排它锁,那么其余人就不能得到同一个文件或者文件的一部分上的锁。若是您得到一个共享锁,那么其余人能够得到同一个文件或者文件一部分上的共享锁,可是不能得到排它锁。文件锁定并不老是出于保护数据的目的。例如,您可能临时锁定一个文件以保证特定的写操做成为原子的,而不会有其余程序的干扰。

大多数操做系统提供了文件系统锁,可是它们并不都是采用一样的方式。有些实现提供了共享锁,而另外一些仅提供了排它锁。事实上,有些实现使得文件的锁定部分不可访问,尽管大多数实现不是这样的。

在本节中,您将学习如何在 NIO 中执行简单的文件锁过程,咱们还将探讨一些保证被锁定的文件尽量可移植的方法。

锁定文件

要获取文件的一部分上的锁,您要调用一个打开的 FileChannel 上的 lock()
方法。注意,若是要获取一个排它锁,您必须以写方式打开文件。

RandomAccessFile raf = new RandomAccessFile( "usefilelocks.txt", "rw" );
FileChannel fc = raf.getChannel();
FileLock lock = fc.lock( start, end, false );

在拥有锁以后,您能够执行须要的任何敏感操做,而后再释放锁:

lock.release();

在释放锁后,尝试得到锁的其余任何程序都有机会得到它。

本小节的例子程序 UseFileLocks.java
必须与它本身并行运行。这个程序获取一个文件上的锁,持有三秒钟,而后释放它。若是同时运行这个程序的多个实例,您会看到每一个实例依次得到锁。

文件锁定和可移植性

文件锁定多是一个复杂的操做,特别是考虑到不一样的操做系统是以不一样的方式实现锁这一事实。下面的指导原则将帮助您尽量保持代码的可移植性:

  • 只使用排它锁。
  • 将全部的锁视为劝告式的(advisory)。

连网和异步 I/O

概述

连网是学习异步 I/O 的很好基础,而异步 I/O 对于在 Java 语言中执行任何输入/输出过程的人来讲,无疑都是必须具有的知识。NIO 中的连网与
NIO 中的其余任何操做没有什么不一样 ― 它依赖通道和缓冲区,而您一般使用 InputStream
OutputStream 来得到通道。

本节首先介绍异步 I/O 的基础 ― 它是什么以及它不是什么,而后转向更实用的、程序性的例子。

异步 I/O

异步 I/O 是一种 没有阻塞地 读写数据的方法。一般,在代码进行 read()
调用时,代码会阻塞直至有可供读取的数据。一样, write() 调用将会阻塞直至数据可以写入。

另外一方面,异步 I/O 调用不会阻塞。相反,您将注册对特定 I/O 事件的兴趣 ―
可读的数据的到达、新的套接字链接,等等,而在发生这样的事件时,系统将会告诉您。

异步 I/O 的一个优点在于,它容许您同时根据大量的输入和输出执行 I/O。同步程序经常要求助于轮询,或者建立许许多多的线程以处理大量的链接。使用异步
I/O,您能够监放任何数量的通道上的事件,不用轮询,也不用额外的线程。

咱们将经过研究一个名为 MultiPortEcho.java 的例子程序来查看异步 I/O 的实际应用。这个程序就像传统的 echo
server
,它接受网络链接并向它们回响它们可能发送的数据。不过它有一个附加的特性,就是它能同时监听多个端口,并处理来自全部这些端口的链接。而且它只在单个线程中完成全部这些工做。

Selectors

本节的阐述对应于 MultiPortEcho 的源代码中的 go()
方法的实现,所以应该看一下源代码,以便对所发生的事情有个更全面的了解。

异步 I/O 中的核心对象名为 SelectorSelector 就是您注册对各类 I/O
事件的兴趣的地方,并且当那些事件发生时,就是这个对象告诉您所发生的事件。

因此,咱们须要作的第一件事就是建立一个 Selector

Selector selector = Selector.open();

而后,咱们将对不一样的通道对象调用 register() 方法,以便注册咱们对这些对象中发生的 I/O
事件的兴趣。register() 的第一个参数老是这个 Selector

打开一个 ServerSocketChannel

为了接收链接,咱们须要一个 ServerSocketChannel。事实上,咱们要监听的每个端口都须要有一个
ServerSocketChannel 。对于每个端口,咱们打开一个
ServerSocketChannel,以下所示:

ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking( false );

ServerSocket ss = ssc.socket();
InetSocketAddress address = new InetSocketAddress( ports[i] );
ss.bind( address );

第一行建立一个新的 ServerSocketChannel ,最后三行将它绑定到给定的端口。第二行将
ServerSocketChannel 设置为 非阻塞的
。咱们必须对每个要使用的套接字通道调用这个方法,不然异步 I/O 就不能工做。

选择键

下一步是将新打开的 ServerSocketChannels 注册到
Selector上。为此咱们使用 ServerSocketChannel.register() 方法,以下所示:

SelectionKey key = ssc.register( selector, SelectionKey.OP_ACCEPT );

register() 的第一个参数老是这个 Selector。第二个参数是
OP_ACCEPT,这里它指定咱们想要监听 accept
事件,也就是在新的链接创建时所发生的事件。这是适用于 ServerSocketChannel 的惟一事件类型。

请注意对 register() 的调用的返回值。 SelectionKey 表明这个通道在此
Selector 上的这个注册。当某个 Selector
通知您某个传入事件时,它是经过提供对应于该事件的 SelectionKey
来进行的。SelectionKey 还能够用于取消通道的注册。

内部循环

如今已经注册了咱们对一些 I/O 事件的兴趣,下面将进入主循环。使用 Selectors
的几乎每一个程序都像下面这样使用内部循环:

int num = selector.select();

Set selectedKeys = selector.selectedKeys();
Iterator it = selectedKeys.iterator();

while (it.hasNext()) {
SelectionKey key = (SelectionKey)it.next();
// ... deal with I/O event ...
}

首先,咱们调用 Selectorselect()
方法。这个方法会阻塞,直到至少有一个已注册的事件发生。当一个或者更多的事件发生时, select()
方法将返回所发生的事件的数量。

接下来,咱们调用 SelectorselectedKeys() 方法,它返回发生了事件的
SelectionKey 对象的一个 集合

咱们经过迭代 SelectionKeys 并依次处理每一个 SelectionKey
来处理事件。对于每个 SelectionKey,您必须肯定发生的是什么 I/O 事件,以及这个事件影响哪些 I/O
对象。

监听新链接

程序执行到这里,咱们仅注册了 ServerSocketChannel,而且仅注册它们“接收”事件。为确认这一点,咱们对
SelectionKey 调用 readyOps() 方法,并检查发生了什么类型的事件:

if ((key.readyOps() & SelectionKey.OP_ACCEPT)
== SelectionKey.OP_ACCEPT) {


}

// Accept the new connection // ...

能够确定地说, readOps() 方法告诉咱们该事件是新的链接。

接受新的链接

由于咱们知道这个服务器套接字上有一个传入链接在等待,因此能够安全地接受它;也就是说,不用担忧 accept() 操做会阻塞:

ServerSocketChannel ssc = (ServerSocketChannel)key.channel();
SocketChannel sc = ssc.accept();

下一步是将新链接的 SocketChannel
配置为非阻塞的。并且因为接受这个链接的目的是为了读取来自套接字的数据,因此咱们还必须将 SocketChannel 注册到
Selector上,以下所示:

sc.configureBlocking( false );
SelectionKey newKey = sc.register( selector, SelectionKey.OP_READ );

注意咱们使用 register()OP_READ 参数,将
SocketChannel 注册用于 读取 而不是 接受 新链接。

删除处理过的 SelectionKey

在处理 SelectionKey 以后,咱们几乎能够返回主循环了。可是咱们必须首先将处理过的
SelectionKey
从选定的键集合中删除。若是咱们没有删除处理过的键,那么它仍然会在主集合中以一个激活的键出现,这会致使咱们尝试再次处理它。咱们调用迭代器的
remove() 方法来删除处理过的 SelectionKey

it.remove();

如今咱们能够返回主循环并接受从一个套接字中传入的数据(或者一个传入的 I/O 事件)了。

传入的 I/O

当来自一个套接字的数据到达时,它会触发一个 I/O 事件。这会致使在主循环中调用
Selector.select(),并返回一个或者多个 I/O 事件。这一次,
SelectionKey 将被标记为 OP_READ 事件,以下所示:

} else if ((key.readyOps() & SelectionKey.OP_READ)
== SelectionKey.OP_READ) {
// Read the data
SocketChannel sc = (SocketChannel)key.channel();
// ...
}

与之前同样,咱们取得发生 I/O 事件的通道并处理它。在本例中,因为这是一个 echo
server,咱们只但愿从套接字中读取数据并立刻将它发送回去。关于这个过程的细节,请参见 参考资料 中的源代码 (MultiPortEcho.java)。

回到主循环

每次返回主循环,咱们都要调用 selectSelector()方法,并取得一组
SelectionKey。每一个键表明一个 I/O 事件。咱们处理事件,从选定的键集中删除
SelectionKey,而后返回主循环的顶部。

这个程序有点过于简单,由于它的目的只是展现异步 I/O 所涉及的技术。在现实的应用程序中,您须要经过将通道从
Selector
中删除来处理关闭的通道。并且您可能要使用多个线程。这个程序能够仅使用一个线程,由于它只是一个演示,可是在现实场景中,建立一个线程池来负责 I/O
事件处理中的耗时部分会更有意义。

字符集

概述

根据 Sun 的文档,一个 Charset 是“十六位 Unicode
字符序列与字节序列之间的一个命名的映射”。实际上,一个 Charset 容许您以尽量最具可移植性的方式读写字符序列。

Java 语言被定义为基于
Unicode。然而在实际上,许多人编写代码时都假设一个字符在磁盘上或者在网络流中用一个字节表示。这种假设在许多状况下成立,可是并非在全部状况下都成立,并且随着计算机变得对
Unicode 愈来愈友好,这个假设就日益变得不能成立了。

在本节中,咱们将看一下如何使用 Charsets
以适合现代文本格式的方式处理文本数据。这里将使用的示例程序至关简单,不过,它触及了使用 Charset
的全部关键方面:为给定的字符编码建立 Charset,以及使用该 Charset
解码和编码文本数据。

编码/解码

要读和写文本,咱们要分别使用 CharsetDecoder
CharsetEncoder。将它们称为 编码器 解码器 是有道理的。一个
字符 再也不表示一个特定的位模式,而是表示字符系统中的一个实体。所以,由某个实际的位模式表示的字符必须以某种特定的
编码 来表示。

CharsetDecoder 用于将逐位表示的一串字符转换为具体的 char 值。一样,一个
CharsetEncoder 用于将字符转换回位。

在下一个小节中,咱们将考察一个使用这些对象来读写数据的程序。

处理文本的正确方式

如今咱们将分析这个例子程序 UseCharsets.java。这个程序很是简单 ―
它从一个文件中读取一些文本,并将该文本写入另外一个文件。可是它把该数据看成文本数据,并使用 CharBuffer
来将该数句读入一个 CharsetDecoder 中。一样,它使用 CharsetEncoder
来写回该数据。

咱们将假设字符以 ISO-8859-1(Latin1) 字符集(这是 ASCII 的标准扩展)的形式储存在磁盘上。尽管咱们必须为使用 Unicode
作好准备,可是也必须认识到不一样的文件是以不一样的格式储存的,而 ASCII 无疑是很是广泛的一种格式。事实上,每种 Java
实现都要求对如下字符编码提供彻底的支持:

  • US-ASCII
  • ISO-8859-1
  • UTF-8
  • UTF-16BE
  • UTF-16LE
  • UTF-16

示例程序

在打开相应的文件、将输入数据读入名为 inputDataByteBuffer
以后,咱们的程序必须建立 ISO-8859-1 (Latin1) 字符集的一个实例:

Charset latin1 = Charset.forName( "ISO-8859-1" );

而后,建立一个解码器(用于读取)和一个编码器 (用于写入):

CharsetDecoder decoder = latin1.newDecoder();
CharsetEncoder encoder = latin1.newEncoder();

为了将字节数据解码为一组字符,咱们把 ByteBuffer 传递给
CharsetDecoder,结果获得一个 CharBuffer

CharBuffer cb = decoder.decode( inputData );

若是想要处理字符,咱们能够在程序的此处进行。可是咱们只想无改变地将它写回,因此没有什么要作的。

要写回数据,咱们必须使用 CharsetEncoder 将它转换回字节:

ByteBuffer outputData = encoder.encode( cb );

在转换完成以后,咱们就能够将数据写到文件中了。

结束语

结束语

正如您所看到的, NIO 库有大量的特性。在一些新特性(例如文件锁定和字符集)提供新功能的同时,许多特性在优化方面也很是优秀。

在基础层次上,通道和缓冲区能够作的事情几乎均可以用原来的面向流的类来完成。可是通道和缓冲区容许以 快得多
的方式完成这些相同的旧操做 ― 事实上接近系统所容许的最大速度。

不过 NIO 最强大的长度之一在于,它提供了一种在 Java 语言中执行进行输入/输出的新的(也是迫切须要的)结构化方式。随诸如缓冲区、通道和异步
I/O 这些概念性(且可实现的)实体而来的,是咱们从新思考 Java 程序中的 I/O过程的机会。这样,NIO 甚至为咱们最熟悉的 I/O
过程也带来了新的活力,同时赋予咱们经过和之前不一样而且更好的方式执行它们的机会。

参考资料

  • 下载 本教程中的例子的完整源代码。
  • 关于安装和配置 JDK 1.4 的更多信息,请参见 SDK 文档
  • Sun's guide to the new I/O APIs 提供了对 NIO
    的全面介绍,包括一些本教程没有涵盖的细节内容。
  • 在线 API 规范
    描述了 NIO 的类和方法,该规范使用的是您了解并喜欢的 autodoc 格式。
  • JSR 51 是 Java
    Community Process 文档,它最早规定了 NIO 的新特性。事实上,JDK 1.4 中实现的 NIO
    是该文档描述的特性的一个子集。
  • 想得到关于流 I/O(包括问题、解决方案和 NIO 的介绍)的全面介绍吗?再没有比 Merlin Hughes 的"Turning streams inside out "
    (developerWorks,2002年7月)更好的了。
  • 固然,还能够学习教程"Introduction to Java I/O"
    (developerWorks,2000年4月),它讨论了 JDK 1.4 以前的 Java I/O 的全部基础。
  • John Zukowski 在其 Merlin 的魔力 专栏中撰写了一些关于 NIO 的优秀文章:
  • 经过 Kalagnanam 和 Balu G 的 ""
    Merlin brings nonblocking I/O to the Java platform "

    (developerWorks,2002年3月)进一步了解 NIO。
  • Greg Travis 在他的 "JDK 1.4
    Tutorial”
    (Manning 出版社,2002年3月)一书中仔细研究了 NIO。
  • 您能够在 developerWorks Java 技术专区 找到数百篇关于 Java
    编程的各个方面的文章。


原文地址: https://www.ibm.com/developerworks/cn/education/java/j-nio/j-nio.html
相关文章
相关标签/搜索