Java NIO

Java IO 相关的文章,在网络上可以搜索到一大堆,并且 《Java NIO》和《Netty 权威指南》两本书可以包读者从入门到进阶。写这篇文章的目的在于让自己总结一下 Java IO 知识,从总结中学习。

Java 的 IO 大致可以分为如下几类:

  • 磁盘操作:File
  • 字节操作:InputStream & OutputStream
  • 字符操作:Reader & Writer
  • 网络操作:Socket

最开始 Java 只提供了 BIO(Block IO),Block IO 的意思是线程调用 IO 操作的时候,线程会阻塞等待 IO 完成。在这个期间,线程除了等待无法完成其他的事情。因为这种 IO 模式,之前的 Java 框架在编写服务器的时候,会对每一个 Socket 的请求新建一个线程进行处理。因为线程的新建与销毁是需要消耗系统资源的,所以若系统频繁地创建与销毁线程,那么会对系统资源造成很大的浪费。在这种 IO 模式下的改进方式是利用池化技术,通过线程池的方式来管理线程,减少线程频繁创建和销毁的开销。但是并没有改变最根本的问题,即一个服务请求的连接都需要一个线程来处理,在高并发的情况下,阻塞 IO 的实现肯定无法满足性能的需求。

针对这一问题,在 JDK 1.4 中 NIO 就登场了。

很多人将 NIO 称为 New IO 或者 Not Block IO,后者应该更贴切一点。

首先是 NIO 里面有哪些东西。

基本组件

Buffer(缓冲区)

NIO 处理的数据存储在 Buffer 里,一个 Buffer 对象是一个固定数量的数据容器,也可以视作数据传输的来源或者目标。

基本属性

缓冲区的几个属性如下:

  • Capacity(容量):在创建时设定,无法在后续修改。表示能够容纳的数据元素的最大数量。
  • Limit(上界):缓冲区中现存元素的计数,也表示了第一个不能读写的元素的位置。
  • Position(位置):下一个要读或者要写的位置。
  • Mark(标记):一个备忘的位置。

过程

新建 Buffer

假设创建一个容量为 10 的缓冲区。

1
2
3
4
5
ByteBuffer buffer = ByteBuffer.allocateDirect(10);
// capacity: 10
// limit: 10
// position: 0
// mark: x

填充 Buffer

向缓冲区填充 5 次数据。

1
2
3
4
5
buffer.put((byte)'H').put((byte)'e').put((byte)'l').put((byte)'l').put((byte)'o');
// capacity: 10
// limit: 10
// position: 5 (即可视为当前 put 到数据中的数量,也可视为下个元素 put 的位置)
// mark: x

翻转 Buffer

当我们要去读取 Buffer 的时候,我们需要翻转(flip) Buffer。

1
2
3
buffer.flip();
// 代码等价于如下,即把 limit 位置设置成 position的位置,再将 position 设置为 0
buffer.limit(buffer.position()).position(0);

压缩 Buffer

当读取缓冲区中两个元素之后,缓冲区如下。

此刻从缓冲区中释放一部分数据,而不是全部,然后重新填充。为了实现这一点,未读的数据元素需要下移以使第一个元素索引为 0。从而将 Buffer 进行压缩(compact)。

1
buffer.compact();

根据图示,数据元素 2-5 被复制到 0-3 位置,4 以后的位置超出了 position,所以在后面 buffer 写入数据的时候被覆盖掉。

标记 Buffer

使用标记函数将 mark 设置为当前 position 的值。当我们需要重复读取 Buffer 中某段数据的时候会可以派上用场。

1
buffer.position(2).mark().position(4);

通道(Channel)

通道位于缓冲区与通信的另一方(文件或 socket)之间,提供全双工的数据传输。JDK 1.4 提供的通道种类如下:

  • FileChannel
  • SocketChannel
  • ServerSocketChannel
  • DatagramChannel

打开 FileChannel 必须通过在 RandomAccessFile、FileInputStream 和 FileOutputStream 对象上调用 getChannel。而另外三个关于 socket 的通道则可以通过相应的静态工厂方法打开。原因在于,创建文件通道的时候,一定要有明确的文件目标,即先有了目标文件才去与之建立数据的通道。一个打开的文件通道对应了一个文件描述符。而开启 Socket 通道的时候还不知道谁来建立连接,与谁建立连接。

Socket 通道可以选择两种模式,阻塞模式和非阻塞模式。

1
2
SocketChannel sc = SocketChannel.open( );
sc.configureBlocking (false); // nonblocking

NIO Socket 提供的非阻塞模式,是编写高性能 IO 应用的关键。对于 Java 编写的服务器来说,能够实现单线程对多个 Socket 连接的管理,并且方式简单。

选择器(Selector)

通过将通道注册到选择器 Selector上,并绑定相关的事件,在后面就可以通过选择器找到这些通道就绪的相关事件。SelectionKey 封装了一个通道和选择器的注册关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// step 1
Selector selector = Selector.open();
// step 2
ServerSocketChannel ssChannel = ServerSocketChannel.open();
ssChannel.configureBlocking(false); // 通道必须设置为非阻塞的模式
ssChannel.register(selector, SelectionKey.OP_ACCEPT); // 这里注册了接受事件
...
// step 3
// 当有就绪的通道的时候,我们可以调用 selectedKeys() 方法获取到这些 key,并进行处理
int num = selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = keys.iterator();
while (keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if (key.isAcceptable()) {
// ...
} else if (key.isReadable()) {
// ...
}
keyIterator.remove();
}

事件的种类如下:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE
1
2
3
4
5
6
7
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

// 可以看出每个事件可以被当成一个位域,从而组成事件集整数
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

Selector 的原理图如下:

Reactor 模式

当使用 NIO 编写 Java 服务器相关的程序的时候,通常会采用名为 Reactor 的设计模式。

Wikipedia: The reactor design pattern is an event handling pattern for handling service requests delivered concurrently by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to associated request handlers.

画个图如下所示:

从图中可以看出当有外部事件发生后,这些事件发送给 Service Handler,Service Handler 的作用是做一个分发,将这些请求分发给具体的 Handler 进行处理。

那么将 Reactor 模式应用到 NIO 的服务器编程上,可以有三种方案。

单线程 Reactor

Reactor 线程要做很多事情,包括:

  • 接受请求,注册事件
  • 将就绪事件分别处理

这种模式下,Reactor 线程又要维护连接,又要做相应的工作。

带线程池的 Reactor

单线程 Reactor 模式,最直观的改进方式就是在事件处理的部分引入工作队列 + 线程池。我们将就绪任务进行分类,对每一种类别的任务,提交到专属的线程池中去处理。这样 Reactor 线程只需要做接受请求,注册监听事件的任务。而任务的处理提交给其他线程,不仅可以减小 Reactor 的压力,也能够提高任务处理的速度。

主从多 Reactor

使用一个 Reactor 线程来接受请求,注册监听所有的读写事件,当读写事件变多时,但线程可能无法满足性能的需求。可以引入了多 Reactor,也即一个主 Reactor 负责监控所有的连接请求,多个子 Reactor 负责监控并处理读/写请求,减轻了主 Reactor 的压力,降低了主 Reactor 压力太大而造成的延迟。并且每个子 Reactor 分别属于一个独立的线程,每个成功连接后的 Channel 的所有操作由同一个线程处理。这样保证了同一请求的所有状态和上下文在同一个线程中,避免了不必要的上下文切换,同时也方便了监控请求响应状态。

总结

Java NIO 的实现基于底层操作系统提供的相关 API,例如 Linux 提供了 select、poll、epoll 等系统调用。Java NIO 无疑为编写的 IO 程序提高了性能,但是其缺点在于实现复杂,维护和管理困难。这也是 Netty 框架致力于解决的缺点。