- 掌握InputStream、OutputStream、Reader、Writer的继承体系.
- 掌握字节流(FileInputStream、DataInputStream、BufferedInputStream、FileOutputSteam、DataOutputStream、BufferedOutputStream)和 字符流(BufferedReader、InputStreamReader、FileReader、BufferedWriter、OutputStreamWriter、PrintWriter、FileWriter), 并熟练运用.
- 掌握NIO实现原理及使用方法.
Java IO包括:
文后的参考文献 Java NIO浅析 需要重点关注
Doug Lea《scalable IO in Java》
中文解读
引言
新的输入/输出(NIO)库是在JDK 1.4中引入的. NIO弥补了原来的I/O的不足, 它在标准java中提供了高速的、面向块的I/O.
NIO与 BIO 最重要的区别是数据打包和传输的方式的不同, 原来的 I/O 以流 的方式处理数据, 而 NIO 以块 的方式处理数据.
面向流的I/O系统一次一个字节地处理数据. 一个输入流产生一个字节的数据, 一个输出流消费一个字节的数据. 为流式数据创建过滤器非常容易. 链接几个过滤器, 以便每个过滤器只负责单个复杂处理机制的一部分, 这样也是相对简单的. 不利的一面是, 面向流的I/O通常相当慢.
NIO与 BIO 有同样的作用和目的, 但是它使用块I/O的处理方式. 每一个操作都在一步中产生或者消费一个数据块. 按块处理数据比按(流式的)字节处理数据要快得多. 但是面向块的I/O缺少一些面向流的I/O所具有的优雅性和简单性.
从一个例子开始
下面我们从一个简单的使用IO和NIO读取一个文件中的内容为例, 来进入NIO的学习之旅.
使用IO来读取指定文件中的前1024字节并打印出来:
1 | /** |
从上面的例子中可以看出, NIO以 通道Channel 和 缓冲区Buffer 为基础来实现面向块的IO数据处理. 下面将讨论并学习NIO 库的核心概念以及从高级的特性到底层编程细节的几乎所有方面.
核心概念:通道和缓冲区
通道和缓冲区是NIO中的核心对象, 几乎在每一个I/O操作中都要使用它们
- 通道Channel 是对原I/O包中的流的模拟. 到任何目的地(或来自任何地方)的所有数据都必须通过一个Channel对象.
- 缓冲区Buffer 实质上是一个容器对象. 发送给一个通道的所有对象都必须首先放到缓冲区中;同样地, 从通道中读取的任何数据都要读到缓冲区中
缓冲区
Buffer是一个容器对象, 它包含一些要写入或者刚读出的数据. 在NIO中加入Buffer对象, 体现了新库与原I/O的一个重要区别. 在面向流的I/O中, 您将数据直接写入或者将数据直接读到Stream对象中;
在NIO库中, 所有数据都是用缓冲区处理的. 在读取数据时, 它是直接读到缓冲区中的. 在写入数据时, 它是写入到缓冲区中的. 任何时候访问NIO中的数据, 您都是将它放到缓冲区中.
缓冲区实质上是一个数组. 通常它是一个字节数组, 但是也可以使用其他种类的数组. 但是一个缓冲区不仅仅是一个数组. 缓冲区提供了对数据的结构化访问, 而且还可以跟踪系统的读/写进程.
最常用的缓冲区类型是 ByteBuffer . 一个ByteBuffer可以在其底层字节数组上进行get/set操作(即字节的获取和设置).
ByteBuffer不是NIO中唯一的缓冲区类型. 事实上, 对于每一种基本Java类型都有一种缓冲区类型:
ByteBufferCharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBuffer
每一个Buffer类都是Buffer接口的一个实例. 除了ByteBuffer, 每一个Buffer类都有完全一样的操作, 只是它们所处理的数据类型不一样. 因为大多数标准I/O操作都使用 ByteBuffer , 所以它具有所有共享的缓冲区操作以及一些特有的操作.
下面的例子使用类型化的缓冲区FloatBuffer的一个应用例子:
1 | // 分配一个容量为10的新的 float 缓冲区 |
通道
Channel是对原I/O包中的流的模拟, 可以通过它读取和写入数据. 通道就像流, 所有数据都通过Buffer对象来处理. 您永远不会将字节直接写入通道中, 相反, 是将数据写入包含一个或者多个字节的缓冲区. 同样, 您不会直接从通道中读取字节, 而是将数据从通道 读入缓冲区, 再从缓冲区获取这个字节
**通道与流的不同之处在于通道是双向的. ** 而流只是在一个方向上移动(一个流必须是InputStream或者OutputStream的子类), 而通道可以用于读、写或者同时用于读写. 因为它们是双向的, 所以通道可以比流更好地反映底层操作系统的真实情况. 特别是在UNIX模型中, 底层操作系统通道是双向的.
从理论到实践:NIO中的读和写
读和写是I/O的基本过程. 从一个通道中读取很简单:只需创建一个缓冲区, 然后让通道将数据读到这个缓冲区中. 写入也相当简单:创建一个缓冲区, 用数据填充它, 然后让通道用这些数据来执行写入操作.
从文件中读取
首先从FileInputStream获取一个FileChannel对象, 然后使用这个通道来读取数据.
在NIO系统中, 任何时候执行一个读操作, 您都是从通道中读取, 但是您不是直接从通道读取. 因为所有数据最终都驻留在缓冲区中, 所以您是从通道读到缓冲区中
因此读取文件涉及三个步骤:
(1) 从 FileInputStream 获取 Channel
(2) 创建 Buffer
(3) 将数据从 Channel 读到 Buffer 中
现在, 让我们看一下这个过程.
1 | // 第一步是获取通道. 从 FileInputStream 获取通道: |
不需要告诉通道要读多少数据到缓冲区中. 每一个缓冲区都有复杂的内部统计机制, 它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据.
写入文件:
在 NIO 中写入文件类似于从文件中读取.
1 | // 首先从 FileOutputStream 获取一个通道: |
读写结合
将一个文件的所有内容拷贝到另一个文件中.
1 | /** |
缓冲区内部实现
每一个缓冲区都有复杂的内部统计机制, 它会跟踪已经读了多少数据以及还有多少空间可以容纳更多的数据, 以便我们对缓冲区的操作.
本节介绍两个重要的缓冲区组件:状态变量和访问方法. 虽然NIO的内部统计机制初看起来可能很复杂, 但是您很快就会看到大部分的实际工作都已经替您完成了. 您只需像平时使用字节数组和索引变量一样进行操作即可.
状态变量
状态变量是”内部统计机制”的关键. 每一个读/写操作都会改变缓冲区的状态. 通过记录和跟踪这些变化, 缓冲区就能够管理内部地自己的资源.
每一种Java基本类型的缓冲区都是抽象类Buffer的子类, 从Buffer的源代码中可以发现, 它定义了三个私有属性:
1 | private int position = 0; |
实际上, 这三个属性值可以指定缓冲区在任意时刻的状态和它所包含的数据.
我们知道, 每一个基本类型的缓冲区底层实际上就是一个该类型的数组. 如在ByteBuffer中, 有:
1 | final byte[] hb; |
在从通道读取时, 所读取的数据将放被到底层的数组中;同理, 向通道中写入时, 将从底层数组中将数据写入通道. 下面我们来具体介绍这三个变量的作用:
position
position变量跟踪了向缓冲区中写入了多少数据或者从缓冲区中读取了多少数据.
更确切的说, 当您从通道中读取数据到缓冲区中时, 它指示了下一个数据将放到数组的哪一个元素中. 比如, 如果您从通道中读三个字节到缓冲区中, 那么缓冲区的position将会设置为3, 指向数组中第4个元素. 反之, 当您从缓冲区中获取数据进行写通道时, 它指示了下一个数据来自数组的哪一个元素. 比如, 当您从缓冲区写了5个字节到通道中, 那么缓冲区的 position 将被设置为5, 指向数组的第六个元素.
limit
limit变量表明还有多少数据需要取出(在从缓冲区写入通道时), 或者还有多少空间可以放入数据(在从通道读入缓冲区时).
position总是小于或者等于limit.
capacity
capacity变量表明可以储存在缓冲区中的最大数据容量. 实际上, 它指定了底层数组的大小—或者至少是指定了准许我们使用的底层数组的容量.
limit总是小于或者等于capacity.
举例说明
下面我们就以数据从一个输入通道拷贝到一个输出通道为例, 来详细分析每一个变量, 并说明它们是如何协同工作的:
初始变量:
我们首先观察一个新创建的缓冲区, 以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()方法. 其源代码如下:
1 | public final Buffer flip() { |
这个方法做两件非常重要的事:
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()方法. 这个方法重设缓冲区以便接收更多的字节. 其源代码如下:
1 |
|
clear做两种非常重要的事情:
i 将limit设置为与capacity相同.
ii 设置position为0.
下图显示了在调用clear()后缓冲区的状态, 此时缓冲区现在可以接收新的数据了.

访问方法
程序需要直接处理数据. 例如, 您可能需要将用户数据保存到磁盘. 在这种情况下, 您必须将这些数据直接放入缓冲区, 然后用通道将缓冲区写入磁盘. 或者, 您可能想要从磁盘读取用户数据. 在这种情况下, 您要将数据从通道读到缓冲区中, 然后检查缓冲区中的数据.
实际上, 每一个基本类型的缓冲区都为我们提供了直接访问缓冲区中数据的方法, 我们以ByteBuffer为例, 分析如何使用其提供的get()和put()方法直接访问缓冲区中的数据.
get()
ByteBuffer类中有四个get()方法:
1 | byte get(); |
第一个方法获取单个字节. 第二和第三个方法将一组字节读到一个数组中. 第四个方法从缓冲区中的特定位置获取字节. 那些返回ByteBuffer的方法只是返回调用它们的缓冲区的this值.
前三个get()方法是相对的, 而最后一个方法是绝对的. “相对”意味着get()操作服从limit和position值, 更明确地说, 字节是从当前position读取的, 而position在get之后会增加. 另一方面, 一个“绝对”方法会忽略limit和position值, 也不会影响它们. 事实上, 它完全绕过了缓冲区的统计方法.
上面列出的方法对应于ByteBuffer类. 其他类有等价的get()方法, 这些方法除了不是处理字节外, 其它方面是是完全一样的, 它们处理的是与该缓冲区类相适应的类型.
put()
ByteBuffer类中有五个put()方法:
1 | ByteBuffer put( 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()
事实上, 这其中的每个方法都有两种类型:一种是相对的, 另一种是绝对的. 它们对于读取格式化的二进制数据(如图像文件的头部)很有用.
如何使用?
下面的内部循环概括了使用缓冲区将数据从输入通道拷贝到输出通道的过程.
1 | while (true) { |
read()和write()调用得到了极大的简化, 因为许多工作细节都由缓冲区完成了. clear()和flip()方法用于让缓冲区在读和写之间切换.
连网和异步IO
连网是学习异步I/O的很好基础, 而异步I/O对于在Java语言中执行任何输入/输出过程的人来说, 无疑都是必须具备的知识. NIO中的连网与NIO中的其他任何操作没有什么不同, 它依赖通道和缓冲区, 而您通常使用InputStream和OutputStream来获得通道.
异步 I/O
异步I/O是一种“没有阻塞地读写数据”的方法. 通常, 在代码进行read()调用时, 代码会阻塞直至有可供读取的数据. 同样, write()调用将会阻塞直至数据能够写入. 但异步I/O调用不会阻塞. 相反, 您可以注册对特定I/O事件的兴趣:如可读的数据的到达、新的套接字连接等等, 而在发生这样的事件时, 系统将会告诉您.
异步I/O的一个优势在于, 它允许您同时根据大量的输入和输出执行I/O. 同步程序常常要求助于轮询, 或者创建许许多多的线程以处理大量的连接. 使用异步I/O, 您可以监听任何数量的通道上的事件, 不用轮询, 也不用额外的线程.
我们来看一个基于非阻塞I/O的服务器端的处理流程, 它接受网络连接并向它们echo它们可能发送的数据. 在这里假设它能同时监听多个端口, 并处理来自所有这些端口的连接. 下面是其主方法:
1 | private void execute () throws IOException { |
3) Selectors
Selector是异步I/O中的核心对象. Selector就是注册对各种I/O事件的兴趣的地方, 而且当那些事件发生时, 就是这个对象告诉您所发生的事件. 所以, 我们需要做的第一件事就是创建一个Selector:
1 | Selector selector = Selector.open(); |
然后, 我们将对不同的通道对象调用register()方法, 以便注册我们对这些对象中发生的I/O事件的兴趣. register()的第一个参数就是这个Selector对象.
4) 打开一个ServerSocketChannel
在服务端为了接收连接, 我们需要一个ServerSocketChannel. 事实上, 我们要监听的每一个端口都需要有一个ServerSocketChannel. 对于每一个端口, 我们打开一个ServerSocketChannel, 如下所示:
1 | ServerSocketChannel ssc = ServerSocketChannel.open(); |
第一行创建一个新的ServerSocketChannel, 最后三行将它绑定到给定的端口. 第二行将ServerSocketChannel设置为非阻塞的. 我们必须对每一个要使用的套接字通道调用这个方法, 否则异步I/O就不能工作.
5) 选择键
下一步是将新打开的ServerSocketChannels注册到Selector上. 为此我们使用ServerSocketChannel.register()方法, 如下所示:
1 | SelectionKey key = ssc.register( selector, SelectionKey.OP_ACCEPT ); |
register()方法的第一个参数总是这个Selector. 第二个参数是OP_ACCEPT, 这里它指定我们想要监听accept事件, 也就是在新的连接建立时所发生的事件. 这是适用于ServerSocketChannel的唯一事件类型.
请注意对register()的调用的返回值. SelectionKey代表这个通道在此Selector上的这个注册. 当某个Selector通知您某个传入事件时, 它是通过提供对应于该事件的SelectionKey来进行的. SelectionKey还可以用于取消通道的注册.
6) 内部循环
现在已经注册了我们对一些 I/O 事件的兴趣, 下面将进入主循环. 使用 Selectors 的几乎每个程序都像下面这样使用内部循环:
1 |
|
- 调用
Selector的select()方法. 这个方法会阻塞, 直到至少有一个已注册的事件发生. 当一个或者更多的事件发生时,select()方法将返回所发生的事件的数量. - 调用
Selector的selectedKeys()方法, 它返回发生了事件的SelectionKey对象的一个集合. - 通过迭代
SelectionKeys并依次处理每个SelectionKey来处理事件. 对于每一个SelectionKey, 您必须确定发生的是什么I/O事件, 以及这个事件影响哪些I/O对象.
7) 监听新连接
程序执行到这里, 我们仅注册了ServerSocketChannel, 并且仅注册它们“接收”事件. 为确认这一点, 我们对SelectionKey调用readyOps()方法, 并检查发生了什么类型的事件:
1 | if ((key.readyOps() & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT) { |
可以肯定地说, readOps()方法告诉我们该事件是新的连接.
8) 接受新的连接
因为我们知道这个服务器套接字上有一个传入连接在等待, 所以可以安全地接受它;也就是说, 不用担心accept()操作会阻塞:
1 | ServerSocketChannel ssc = (ServerSocketChannel)key.channel(); |
下一步是将新连接的SocketChannel配置为非阻塞的. 而且由于接受这个连接的目的是为了读取来自套接字的数据, 所以我们还必须将SocketChannel注册到Selector上, 如下所示:
1 | sc.configureBlocking( false ); |
注意我们使用register()的OP_READ参数, 将SocketChannel注册用于“读取”而不是“接受”新连接.
9) 删除处理过的SelectionKey
在处理SelectionKey之后, 我们几乎可以返回主循环了. 但是我们必须首先将处理过的SelectionKey从选定的键集合中删除. 如果我们没有删除处理过的键, 那么它仍然会在主集合中以一个激活的键出现, 这会导致我们尝试再次处理它. 我们调用迭代器的remove()方法来删除处理过的SelectionKey:
1 | it.remove(); |
现在我们可以返回主循环并接受从一个套接字中传入的数据(或者一个传入的I/O事件)了.
10) 传入的I/O
当来自一个套接字的数据到达时, 它会触发一个I/O事件. 这会导致在主循环中调用Selector.select(), 并返回一个或者多个I/O事件. 这一次, SelectionKey将被标记为OP_READ事件, 如下所示:
1 | } else if ((key.readyOps() & SelectionKey.OP_READ) |
与以前一样, 我们取得发生I/O事件的通道并处理它. 在本例中, 由于这是一个echo server, 我们只希望从套接字中读取数据并马上将它发送回去. 关于这个过程的细节, 请参见附件中的源代码 (MultiPortEcho.java).
11) 回到主循环
每次返回主循环, 我们都要调用select的Selector()方法, 并取得一组SelectionKey. 每个键代表一个I/O事件. 我们处理事件, 从选定的键集中删除SelectionKey, 然后返回主循环的顶部.
说明: 这个程序有点过于简单, 因为它的目的只是展示异步I/O所涉及的技术. 在现实的应用程序中, 您需要通过将通道从Selector中删除来处理关闭的通道. 而且您可能要使用多个线程. 这个程序可以仅使用一个线程, 因为它只是一个演示, 但是在现实场景中, 创建一个线程池来负责I/O事件处理中的耗时部分会更有意义.
缓冲区更多内容
比如缓冲区分配、包装和分片. 我们还会讨论NIO带给Java平台的一些新功能. 我们将学如何创建不同类型的缓冲区以达到不同的目的, 如可保护数据不被修改的“只读缓冲区”, 和直接映射到底层操作系统缓冲区的“直接缓冲区”, 以及如何在 NIO 中创建内存映射文件.
1) 缓冲区分配和包装
在能够读和写之前, 必须有一个缓冲区. 要创建缓冲区, 您必须“分配”它. 我们使用静态方法allocate()来分配缓冲区:
1 | ByteBuffer buffer = ByteBuffer.allocate( 1024 ); |
allocate()方法分配一个具有指定大小的底层数组, 并将它包装到一个缓冲区对象中, 在本例中是一个ByteBuffer.
您还可以将一个现有的数组转换为缓冲区, 如下所示:
1 | byte array[] = new byte[1024]; |
本例使用了wrap()方法将一个数组包装为缓冲区. 必须非常小心地进行这类操作. 一旦完成包装, 底层数据就可以通过缓冲区或者直接访问.
2) 缓冲区分片
slice()方法根据现有的缓冲区创建一个子缓冲区. 也就是说, 它创建一个新的缓冲区, 新缓冲区与原来的缓冲区的一部分共享数据.
使用例子可以最好地说明这点. 让我们首先创建一个长度为10的ByteBuffer:
1 | ByteBuffer buffer = ByteBuffer.allocate( 10 ); |
然后使用数据来填充这个缓冲区, 在第n个槽中放入数字n:
1 | for (int i=0; i<buffer.capacity(); ++i) { |
现在我们对这个缓冲区“分片”, 以创建一个包含槽3到槽6的子缓冲区. 在某种意义上, 子缓冲区就像原来的缓冲区中的一个窗口 .
窗口的起始和结束位置通过设置position和limit值来指定, 然后调用Buffer的slice()方法进行分片:
1 | buffer.position( 3 ); |
该“片段”是缓冲区的子缓冲区. 不过, “片段”和“缓冲区”共享同一个底层数据数组, 我们在下一节将会看到这一点.
- 缓冲区片份和数据共享
我们已经创建了原缓冲区的子缓冲区, 并且已经知道缓冲区和子缓冲区共享同一个底层数据数组. 让我们看看这意味着什么.
我们遍历子缓冲区, 将每一个元素乘以11来改变它. 例如, 5会变成55.
1 | for (int i=0; i<slice.capacity(); ++i) { |
最后, 再看一下原缓冲区中的内容:
1 | buffer.position( 0 ); |
结果表明只有在子缓冲区窗口中的元素被改变了:
1 | 0 |
缓冲区片对于促进抽象非常有帮助. 可以编写自己的函数处理整个缓冲区, 而且如果想要将这个过程应用于子缓冲区上, 您只需取主缓冲区的一个片, 并将它传递给您的函数. 这比编写自己的函数来取额外的参数以指定要对缓冲区的哪一部分进行操作更容易.
- 只读缓冲区
只读缓冲区的含义已经很直白了:您可以读取它们, 但是不能向它们写入. 可以通过调用缓冲区的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个字节映射到内存中:
1 | MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE, 0, 1024 ); |
map()方法返回一个MappedByteBuffer, 它是ByteBuffer的子类. 因此, 您可以像使用其他任何ByteBuffer 一样使用新映射的缓冲区, 操作系统会在需要时负责执行行映射.
- 分散和聚集
- 概述:
分散/聚集I/O是使用多个而不是单个缓冲区来保存数据的读写方法.
一个分散的读取就像一个常规通道读取, 只不过它是将数据读到一个缓冲区数组中而不是读到单个缓冲区中. 同样地, 一个聚集写入是向缓冲区数组而不是向单个缓冲区写入数据. 分散/聚集I/O对于将数据流划分为单独的部分很有用, 这有助于实现复杂的数据格式.
- 分散/聚集 I/O:
通道可以有选择地实现两个新的接口:ScatteringByteChannel和GatheringByteChannel. 一个 ScatteringByteChannel是一个具有两个附加读方法的通道:
1 | long read( ByteBuffer[] dsts ); |
这些long read()方法很像标准的read方法, 只不过它们不是取单个缓冲区而是取一个缓冲区数组.
在“分散读取”中, 通道依次填充每个缓冲区. 填满一个缓冲区后, 它就开始填充下一个. 在某种意义上, 缓冲区数组就像一个大缓冲区.
- 分散/聚集的应用:
分散/聚集I/O对于将数据划分为几个部分很有用. 例如, 您可能在编写一个使用消息对象的网络应用程序, 每一个消息被划分为固定长度的头部和固定长度的正文. 您可以创建一个刚好可以容纳头部的缓冲区和另一个刚好可以容难正文的缓冲区. 当您将它们放入一个数组中并使用分散读取来向它们读入消息时, 头部和正文将整齐地划分到这 两个缓冲区中.
我们从缓冲区所得到的方便性对于缓冲区数组同样有效. 因为每一个缓冲区都跟踪自己还可以接受多少数据, 所以分散读取会自动找到有空间接受数据的第一个缓冲区. 在这个缓冲区填满后, 它就会移动到下一个缓冲区.
- 聚集写入:
聚集写入类似于分散读取, 只不过是用来写入. 它也有接受缓冲区数组的方法:
1 | long write( ByteBuffer[] srcs ); |
聚集写对于把一组单独的缓冲区中组成单个数据流很有用. 为了与上面的消息例子保持一致, 您可以使用聚集写入来自动将网络消息的各个部分组装为单个数据流, 以便跨越网络传输消息.
从附件的例子程序 UseScatterGather.java 中可以看到分散读取和聚集写入的实际应用.
文件锁定
- 概述:
文件锁定初看起来可能让人迷惑. 它似乎指的是防止程序或者用户访问特定文件. 事实上, 文件锁就像常规的Java对象锁, 它们是“劝告式”的(advisory)锁. 它们不阻止任何形式的数据访问, 相反, 它们通过锁的共享和获取来允许系统的不同部分相互协调. 您可以锁定整个文件或者文件的一部分. 如果您获取一个排它锁, 那么其他人就不能获得同一个文件或者文件的一部分上的锁. 如果您获得一个共享锁, 那么其他人可以获得同一个文件或者文件一部分上的共享锁, 但是不能获得排它锁. 文件锁定并不总是出于保护数据的目的. 例如, 您可能临时锁定一个文件以保证特定 的写操作成为原子的, 而不会有其他程序的干扰. 大多数操作系统提供了文件系统锁, 但是它们并不都是采用同样的方式. 有些实现提供了共享锁, 而另一些仅提供了排它锁. 事实上, 有些实现使得文件的锁定部分不可访问, 尽管大多数实现不是这样的. 在这里, 我们将学习如何在 NIO 中执行简单的文件锁过程, 还将探讨一些保证被锁定的文件尽可能可移植的方法.
- 锁定文件:
要获取文件的一部分上的锁, 您要调用一个打开的FileChannel上的lock()方法. 注意, 如果要获取一个排它锁, 您必须以写方式打开文件.
1 | RandomAccessFile raf = new RandomAccessFile( "usefilelocks.txt", "rw" ); |
在拥有锁之后, 您可以执行需要的任何敏感操作, 然后再释放锁:
1 | lock.release(); |
在释放锁后, 尝试获得锁的其他任何程序都有机会获得它.
本附件的例子程序UseFileLocks.java必须与它自己并行运行. 这个程序获取一个文件上的锁, 持有三秒钟, 然后释放它. 如果同时运行这个程序的多个实例, 您会看到每个实例依次获得锁.
- 文件锁定和可移植性:
文件锁定可能是一个复杂的操作, 特别是考虑到 不同的操作系统是以不同的方式实现锁这一事实. 下面的指导原则将帮助您尽可能保持代码的可移植性:
i 只使用排它锁.
ii 将所有的锁视为劝告式的(advisory).
字符集
- 概述:
根据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
- 示例程序:
在打开相应的文件、将输入数据读入名为inputData的ByteBuffer之后, 我们的程序必须创建ISO-8859-1字符集的一个实例:
1 | Charset latin1 = Charset.forName( "ISO-8859-1" ); |
然后, 创建一个解码器(用于读取)和一个编码器 (用于写入):
1 | CharsetDecoder decoder = latin1.newDecoder(); |
为了将字节数据解码为一组字符, 我们把ByteBuffer传递给CharsetDecoder, 结果得到一个CharBuffer:
1 | CharBuffer cb = decoder.decode( inputData ); |
如果想要处理字符, 我们可以在程序的此处进行. 但是我们只想无改变地将它写回, 所以没有什么要做的.
要写回数据, 我们必须使用CharsetEncoder将它转换回字节:
1 | ByteBuffer outputData = encoder.encode( cb ); |
在转换完成之后, 我们就可以将数据写到文件中了.
Java NIO bug
JDK 原生 NIO 程序的问题
JDK 原生也有一套网络应用程序 API,但是存在一系列问题,主要如下:
NIO 的类库和 API 繁杂,使用麻烦。你需要熟练掌握 Selector、ServerSocketChannel、SocketChannel、ByteBuffer 等。
需要具备其他的额外技能做铺垫。例如熟悉 Java 多线程编程,因为 NIO 编程涉及到 Reactor 模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的 NIO 程序。
可靠性能力补齐,开发工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等。
NIO 编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大。
JDK NIO 的 Bug。例如臭名昭著的 Epoll Bug,它会导致 Selector 空轮询,最终导致 CPU 100%。
官方声称在 JDK 1.6 版本的 update 18 修复了该问题,但是直到 JDK 1.7 版本该问题仍旧存在,只不过该 Bug 发生概率降低了一些而已,它并没有被根本解决。
[参考文献]