Java NIO 简明教程

Java NIO 是在 Java1.4 之后推出的一套全新的 IO 接口,是用来解决传统 Java IO 和 Java 网络包中 API 的扩展性差、效率低等问题。 NIO 有着一套完全不同的设计理念。

NIO 指的是 Non-blocking,而不是 New-IO

IO 通常可以分为两种类型:

  • 网络 IO
  • 文件 IO (对外部设备的操作都可以看作是对文件的操作)

对于一个 IO 操作来,可以分成两步:

  • 发起 IO 请求
  • 实际 IO 操作

如果在发起 IO 请求阶段被阻塞,那就是阻塞 IO,如果不阻塞,那就是非阻塞 IO

如果在实际 IO 操作阶段阻塞进程,那么就是同步 IO,如果不阻塞进程,而是由操作系统完成 IO 操作,然后把结果返回给进程,那么就是异步 IO

Java NIO 在发起 IO 请求时是非阻塞的,但是在实际的 IO 操作过程中是阻塞的,所以 Java NIO 是属于同步非阻塞IO。在 Java1.7 以后提供了 AIO,这是真正的异步非阻塞 IO。在这篇文章中我们只讨论 Java1.4 中的 NIO。

还要一个概念需要说明一下,并发并行, 简单来说:

  • 并发:可以处理多个任务,多个任务不一定要同时处理
  • 并行:同时处理多个任务的能力。

NIO 核心组件

在 NIO 中,有三个核心的组件:

  • Buffers
  • Channels
  • Selectors

核心三个组件的类图如下:

Buffer 是数据容器,除了 boolean 以外,每一个基本类型都有 Buffer 的实现。

Channel 和 java.io 中的 Stream 非常相似,但是有几点重要的区别:

  • Channel 可以读也可以写,但是 Stream 同时只能读或者只能写
  • Channel 的读写都是基于 Buffer 来进行的。

Selector 是在单线程里面利用事件机制去监听多个 Channel,然后让准备就绪的 Channel 进入到 IO 操作阶段。 但是在实际进行 IO 的阶段,依然是阻塞的,所以这也就是为什么 Java NIO 是 同步非阻塞的

Channel 详解

在上图中,已经将 Channel 的 4 个重要的实现都列出来了,分别是:

  • FileChannel (文件数据的读写)
  • DatagramChannel (UDP 数据的读写)
  • SocketChannel(TCP 数据的读写)
  • ServerCocketChannel (监听 TCP 链接请求,每个请求都会创建一个 SocketChannel)

Channel 用于连接外部的 IO 服务和 NIO 中的Buffer。Channel 与操作系统的关系非常紧密。在不同的操作系统中都有着不同的实现。

Channel 可以一般可以设置为阻塞和非阻塞的模式。

FileChannel 只有阻塞模式,所以不能注册到 Selector。

通常来说,一个 Channel 会向一个 Buffer 中读或者写数据,但是 Channel 也支持 Scatter 和 Gather,Scatter 是指把 Channel 中读取到的数据写入到多个 Buffer 中。Gather 是指把多个 Buffer 中的数据写入到一个 Channel 中。

在把一个 Channel 的数据读入到多个 Buffer 中时,会在写满一个 Buffer 后再接着写另一个 Buffer。

在 FileChannel 中,有两个很有用的方法,一个是 transferFrom(),一个是 transferTo(), 可以用来拷贝文件,两个方法的用法大致相同。

1
2
3
4
5
6
7
8
9
10
RandomAccessFile fromFile = new RandomAccessFile("from.txt", "rw");
FileChannel fromChannel = fromFile.getChannel();

RandomAccessFile toFile = new RandomAccessFile("to.txt", "rw");
FileChannel toChannel = toFile.getChannel();

long position = 0;
long count = fromChannel.size();

toChannel.transferFrom(fromChannel, position, count);

Buffer 详解

Buffer 用于和 Channel 进行交互,Channel 从 BUffer 中读取数据和写入数据,Buffer 的本质是一块内存区域,Buffer 提供了一系列的接口来操作这块内存的数据。

Buffer 有两种不同的模式,一种是模式,这种模式下只能从 Buffer 中读取数据,一种是模式,这个时候只能往 Buffer 中写数据。

Buffer 中有几个非常关键的属性:

  • capacity:这个属性表示 Buffer 的容量,这个值是不可变的。
  • position: 在模式下,需要有一个确定的位置可以来写入数据,默认 position 为 0,position 最大为 capacity - 1;在变成模式下,position 归零,position 变成读的初始位置,每次读取后,position 后移。
  • limit:在模式下,limit 的含义就是我们能写入的最大数据量,等于 capacity;在模式下,limit 表示我们所能读取的最大数据量,等同于模式下 position 的值。

下面的代码简单展示了如何操作 Buffer,以及如何向 Channel 中读取和写 Buff:

1
2
3
4
5
6
7
8
9
10
11
12
13
ByteBuffer buff = ByteBuffer.allocate(8);

SocketChannel socketChannel = SocketChannel.open();

socketChannel.read(buff);

IntBuffer intBuff = buff.asIntBuffer();

buff.clear();
intBuff.put(0, 1);
intBuff.put(1, 2);

socketChannel.write(buff);

当 Channel 开始写入数据到 Buffer 中时,Buffer 会记录已经写入的数据的大小。当需要读取数据时,Buffer 会通过 flip() 方法将 Buffer 从写的模式调整为读的模式,然后就可以读取 Buffer 中所有已经写入的数据。

在将数据读取完成后,需要清除已经读取过的数据,有两种方式,一个方法是 clear() 方法,一个是 compact() 方法。clear 方法会清空 Buffer 中所有的数据, 而 compact 只会清空已经读取的数据,然后会把未读取的数据移到 Buffer 开始的位置,新写入的数据的开始位置则在未读取的数据之后。

Selector 详解

Selector 管理着一个被注册的了 Channel 的集合和它们的就绪状态。

只有继承了 SelectableChannel 的 Channel 才能被注册到 Selector,一个 Channel 可以被注册到多个 Selector 上,而每个 Selector 只能被注册一次。Channel 在被注册到 Selector 之前,首先要先设置成为非阻塞模式

SelectionKey 封装了 Channel 与 Selector 的注册关系。

在 SelectionKey 中定义了四种状态:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

在将一个 Channel 注册到一个 Selector 时,我们可以设置我们对哪个状态感兴趣:

1
2
3
4
5
6
// Selector operation
SocketChannel channel = SocketChannel.open();
Selector selector = Selector.open();
channel.configureBlocking(false);

channel.register(selector, SelectionKey.OP_CONNECT);

register 方法中的第二个参数就是我们希望处理的状态,Selector 会把这些状态收集起来作为一个我们关注的兴趣集

如果底层操作系统通知 Selector 一个 Channel已经处于就绪状态,那么 Selector 就会把这个 Channel 的key 放入到它就绪集中。

Selector 可以在单线程中处理多个 Channel,就是是说可以用少量的线程就可以处理更多的连接,可以减少线程之前的切换,从而提升系统的并发能力。

在 Selector 中会维护 SelectionKey 的集合来完成对 Channel 的调度。

Selector 如何维护 selectionKeys

在 Selector 中维护着三个 selectionKeys 的集合:

  • key set: 这里面包含着所有的 selectionKeys,所有注册的 channel 都在里面,可以通过 selector.keys() 来获得这个集合。
  • selected-key set: 这个集合一定是 key set 的子集,包含着我们在注册 Channel 时传入的一种状态相匹配的 key。可以通过 selector.selectionKeys() 获取到集合。
  • cancelled-key set:这里面的 key 也一定是 key set 的子集,其中的每一个 selectionKey 都已经被取消了,但注册的 Channel 还没有被注销。

在一个新创建的 selector 中,着三个集合都是空着的。每注册一个 Channel,key set 中都会增加一条记录。在 Selector 轮序期间,一些准备就绪的 SelectionKey 会被添加到 selected-key set 中。这些key 可以通过 remove 方法来移除。在调用了 channel.close 或者 selectionKey.cancel() 方法后,这些 SelectionKey 会被添加到 cancelled-key set 中。

Selector 如何选择就绪的 Channel

在 Selector 执行了 selector() selecotr(long)selectNow() 方法之后,SelectionKeys 都可以在 selected-key set 中填金额或者被删除,同时也可以从 key set 和 cancelled-key set 中删除。在执行 select() 方法后,会涉及到以下的三个步骤:

  1. 首先 cancelled-key set 中的 key 会从 key set 中被删除,并且对于的 Channel 会被注销。然后 cancelled-key set 变成空。

  2. 然后查询底层操作系统来获得 Selector 中剩余的 Channel 的就绪状态从 调用 select() 方法到此刻的更新情况,只要有 Channel 的就绪状态与传入的状态值匹配上,就会做以下两件事: 1. 如果这个 Channel 的 key 还没有在 selected-key set 中,并将这个 Channel 的就绪状态集修改成只包含 Channel 当前的状态,任何之前的就绪状态都会被丢弃。 2. 如果这个 Channel 的 key 在 selected-key set 中存在,那么就保留就绪状态集中先前的就绪状态,并且将 Channel 当前的状态写进去,底层系统会通过操作来更新当前的就绪集。

    如果兴趣集为空,那么 selected-key set 和 就绪状态集都不会被更新。

  3. 如果在第 2 步中将任何 key 添加到 cancelled-key set 中,就按步骤 1 处理它们。

(完)

参考文献:

  1. http://tutorials.jenkov.com/java-nio/index.html

微信公众号

© 2018 ray