一、NIO的概念

Java NIO(New IO)是一个可以替代标准Java IO API的IO API(从Java1.4开始),Java NIO提供了与标准IO不同的IO工作方式。

所以Java NIO是一种新式的IO标准,与之间的普通IO的工作方式不同。标准的IO基于字节流和字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入通道也类似。

由上面的定义就说明NIO是一种新型的IO,但NIO不仅仅就是等于Non-blocking IO(非阻塞IO),NIO中有实现非阻塞IO的具体类,但不代表NIO就是Non-blocking IO(非阻塞IO)。

Java NIO 由以下几个核心部分组成:

  • Buffer
  • Channel
  • Selector

传统的IO操作面向数据流,意味着每次从流中读一个或多个字节,直至完成,数据没有被缓存在任何地方。NIO操作面向缓冲区,数据从Channel读取到Buffer缓冲区,随后在Buffer中处理数据。

二、Buffer的使用

利用Buffer读写数据,通常遵循四个步骤:
  1. 把数据写入buffer;
  2. 调用flip;
  3. 从Buffer中读取数据;
  4. 调用buffer.clear()

当写入数据到buffer中时,buffer会记录已经写入的数据大小。当需要读数据时,通过flip()方法把buffer从写模式调整为读模式;在读模式下,可以读取所有已经写入的数据。

当读取完数据后,需要清空buffer,以满足后续写入操作。清空buffer有两种方式:调用clear(),一旦读完Buffer中的数据,需要让Buffer准备好再次被写入,clear会恢复状态值,但不会擦除数据。

Buffer的容量,位置,上限(Buffer Capacity, Position and Limit)

buffer缓冲区实质上就是一块内存,用于写入数据,也供后续再次读取数据。这块内存被NIO Buffer管理,并提供一系列的方法用于更简单的操作这块内存。

一个Buffer有三个属性是必须掌握的,分别是:

  • capacity容量
  • position位置
  • limit限制

position和limit的具体含义取决于当前buffer的模式。capacity在两种模式下都表示容量。
下面有张示例图,描诉了不同模式下position和limit的含义:
buffers-modes.png

容量(Capacity)

作为一块内存,buffer有一个固定的大小,叫做capacity容量。也就是最多只能写入容量值得字节,整形等数据。一旦buffer写满了就需要清空已读数据以便下次继续写入新的数据。

位置(Position)

当写入数据到Buffer的时候需要中一个确定的位置开始,默认初始化时这个位置position为0,一旦写入了数据比如一个字节,整形数据,那么position的值就会指向数据之后的一个单元,position最大可以到capacity-1。
当从Buffer读取数据时,也需要从一个确定的位置开始。buffer从写入模式变为读取模式时,position会归零,每次读取后,position向后移动。

上限(Limit)
在写模式,limit的含义是我们所能写入的最大数据量。它等同于buffer的容量。
一旦切换到读模式,limit则代表我们所能读取的最大数据量,他的值等同于写模式下position的位置。
数据读取的上限时buffer中已有的数据,也就是limit的位置(原position所指的位置)。

分配一个Buffer(Allocating a Buffer)

为了获取一个Buffer对象,你必须先分配。每个Buffer实现类都有一个allocate()方法用于分配内存。下面看一个实例,开辟一个48字节大小的buffer:

  1. ByteBuffer buf = ByteBuffer.allocate(48);

开辟一个1024个字符的CharBuffer:

  1. CharBuffer buf = CharBuffer.allocate(1024);
Buffer的实现类

Java NIO - 图2
其中MappedByteBuffer比较特殊。Java类库中的NIO包相对于IO 包来说有一个新功能是内存映射文件,日常编程中并不是经常用到,但是在处理大文件时是比较理想的提高效率的手段。其中MappedByteBuffer实现的就是内存映射文件,可以实现大文件的高效读写。 可以参考这两篇文章理解: [Java][IO]JAVA NIO之浅谈内存映射文件原理与DirectMemory深入浅出MappedByteBuffer

三、Channel的使用

Java NIO Channel通道和流非常相似,主要有以下几点区别:

  • 通道可以读也可以写,流一般来说是单向的(只能读或者写)。
  • 通道可以异步读写。
  • 通道总是基于缓冲区Buffer来读写。
  • 正如上面提到的,我们可以从通道中读取数据,写入到buffer;也可以中buffer内读数据,写入到通道中。下面有个示意图:
    Java NIO - 图3
Channel的实现类有:
  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

还有一些异步IO类,后面有介绍。

FileChannel用于文件的数据读写。 DatagramChannel用于UDP的数据读写。 SocketChannel用于TCP的数据读写。 ServerSocketChannel允许我们监听TCP链接请求,每个请求会创建会一个SocketChannel。

Channel使用实例
  1. RandomAccessFile aFile = new RandomAccessFile("data/nio-data.txt", "rw");
  2. FileChannel inChannel = aFile.getChannel();
  3. ByteBuffer buf = ByteBuffer.allocate(48);
  4. int bytesRead = inChannel.read(buf);
  5. while (bytesRead != -1) {
  6. System.out.println("Read " + bytesRead);
  7. buf.flip();
  8. while(buf.hasRemaining()){
  9. System.out.print((char) buf.get());
  10. }
  11. buf.clear();
  12. bytesRead = inChannel.read(buf);
  13. }
  14. aFile.close();

上面介绍了NIO中的两个关键部分Buffer/Channel,对于Selector的介绍,先放一放,先介绍阻塞/非阻塞/同步/非同步的关系。

四、阻塞/非阻塞/同步/非同步的关系

为什么要介绍这四者的关系,就是因为Selector是对于多个非阻塞IO流的调度器,通过Selector来实现读写操作。所以有必要理解一下什么是阻塞/非阻塞?

本文讨论的背景是UNIX环境下的network IO。本文最重要的参考文献是Richard Stevens的“UNIX® Network Programming Volume 1, Third Edition: The Sockets Networking ”,6.2节“I/O Models ”,Stevens在这节中详细说明了各种IO的特点和区别。

Stevens在文章中一共比较了五种IO Model:

  • blocking IO
  • nonblocking IO
  • IO multiplexing
  • signal driven IO
  • asynchronous IO。

由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。再说一下IO发生时涉及的对象和步骤。对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。

当一个read操作发生时,它会经历两个阶段:
1 等待数据准备 (Waiting for the data to be ready)
2 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

blocking IO

在UNIX中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
Java NIO - 图4

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

non-blocking IO

UNIX下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
Java NIO - 图5
从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。所以,用户进程其实是需要不断的主动询问kernel数据好了没有。

IO multiplexing

IO multiplexing这个词可能有点陌生,但是如果我说select,epoll,大概就都能明白了。有些地方也称这种IO方式为event driven IO。我们都知道,select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select/epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。它的流程如图:
Java NIO - 图6

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

Asynchronous I/O

UNIX下的asynchronous IO其实用得很少。先看一下它的流程:
Java NIO - 图7
用户进程发起read操作之后,立刻就可以开始去做其它的事。 而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

到目前为止,已经将四个IO Model都介绍完了。现在回过头来回答最初的那几个问题:

blocking和non-blocking的区别在哪,synchronous IO和asynchronous IO的区别在哪?

先回答最简单的这个:blocking vs non-blocking。前面的介绍中其实已经很明确的说明了这两者的区别。调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。Stevens给出的定义(其实是POSIX的定义)是这样子的:
A synchronous I/O operation causes the requesting process to be blocked until that I/O operationcompletes; An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。

按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人可能会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:
Java NIO - 图8

经过上面的介绍,会发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

五、NIO中的blocking IO/nonblocking IO/IO multiplexing/asynchronous IO

上面讲完了IO中的几种模式,虽然是基于UNIX环境下,具体操作系统的知识个人认识很浅,下面就说下自己的个人理解,不对的地方欢迎指正。

首先,标准的IO显然属于blocking IO。

其次,NIO中的实现了SelectableChannel类的对象,可以通过如下方法设置是否支持非阻塞模式:

SelectableChannel configureBlocking(boolean block):调整此通道的阻塞模式。

如果为 true,则此通道将被置于阻塞模式;如果为 false,则此通道将被置于非阻塞模式
设置为false的NIO类将是nonblocking IO。

再其次,通过Selector监听实现多个NIO对象的读写操作,显然属于IO multiplexing。关于Selector,其负责调度多个非阻塞式IO,当有其感兴趣的读写操作到来时,再执行相应的操作。Selector执行select()方法来进行轮询查找是否到来了读写操作,这个过程是阻塞的,具体详细使用下面介绍。

最后,在Java 7中增加了asynchronous IO,具体结构和实现类框架如下:

Java NIO - 图9
篇幅有限,具体使用可以看这篇文章:Java 学习之路 之 基于TCP协议的网络编程(八十二)

六、Selector使用

Selector是Java NIO中的一个组件,用于检查一个或多个NIO Channel的状态是否处于可读、可写。如此可以实现单线程管理多个channels,也就是可以管理多个网络链接。

通过上面的了解我们知道Selector是一种IO multiplexing的情况。

下面这幅图描述了单线程处理三个channel的情况:
Java NIO - 图10

创建Selector(Creating a Selector)。创建一个Selector可以通过Selector.open()方法:
  1. Selector selector = Selector.open();
注册Channel到Selector上:
  1. channel.configureBlocking(false);
  2. SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

Channel必须是非阻塞的。上面对IO multiplexing的图解中可以看出。所以FileChannel不适用Selector,因为FileChannel不能切换为非阻塞模式。Socket channel可以正常使用。

注意register的第二个参数,这个参数是一个“关注集合”,代表我们关注的channel状态,有四种基础类型可供监听:

  • Connect
  • Accept
  • Read
  • Write

一个channel触发了一个事件也可视作该事件处于就绪状态。

因此当channel与server连接成功后,那么就是“Connetct”状态。server channel接收请求连接时处于“Accept”状态。channel有数据可读时处于“Read”状态。channel可以进行数据写入时处于“Writer”状态。当注册到Selector的所有Channel注册完后,调用Selector的select()方法,将会不断轮询检查是否有以上设置的状态产生,如果产生便会加入到SelectionKey集合中,进行后续操作。

上述的四种就绪状态用SelectionKey中的常量表示如下:

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

如果对多个事件感兴趣可利用位的或运算结合多个常量,比如:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

从Selector中选择channel(Selecting Channels via a Selector)

一旦我们向Selector注册了一个或多个channel后,就可以调用select来获取channel。select方法会返回所有处于就绪状态的channel。

select方法具体如下:

int select()
int select(long timeout)
int selectNow()

select()方法在返回channel之前处于阻塞状态。 select(long timeout)和select做的事一样,不过他的阻塞有一个超时限制。

selectNow()不会阻塞,根据当前状态立刻返回合适的channel。

select()方法的返回值是一个int整形,代表有多少channel处于就绪了。也就是自上一次select后有多少channel进入就绪。

举例来说,假设第一次调用select时正好有一个channel就绪,那么返回值是1,并且对这个channel做任何处理,接着再次调用select,此时恰好又有一个新的channel就绪,那么返回值还是1,现在我们一共有两个channel处于就绪,但是在每次调用select时只有一个channel是就绪的。

selectedKeys()

在调用select并返回了有channel就绪之后,可以通过选中的key集合来获取channel,这个操作通过调用selectedKeys()方法:

  1. Set<SelectionKey> selectedKeys = selector.selectedKeys();

遍历这些SelectionKey可以通过如下方法:

  1. Set<SelectionKey> selectedKeys = selector.selectedKeys();
  2. Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
  3. while(keyIterator.hasNext()) {
  4. SelectionKey key = keyIterator.next();
  5. if(key.isAcceptable()) {
  6. // a connection was accepted by a ServerSocketChannel.
  7. } else if (key.isConnectable()) {
  8. // a connection was established with a remote server.
  9. } else if (key.isReadable()) {
  10. // a channel is ready for reading
  11. } else if (key.isWritable()) {
  12. // a channel is ready for writing
  13. }
  14. keyIterator.remove();
  15. }

上述循环会迭代key集合,针对每个key我们单独判断他是处于何种就绪状态。

注意keyIterater.remove()方法的调用,Selector本身并不会移除SelectionKey对象,这个操作需要我们手动执行。当下次channel处于就绪是,Selector任然会把这些key再次加入进来。

SelectionKey.channel返回的channel实例需要强转为我们实际使用的具体的channel类型,例如ServerSocketChannel或SocketChannel.

wakeUp()

由于调用select而被阻塞的线程,可以通过调用Selector.wakeup()来唤醒即便此时已然没有channel处于就绪状态。具体操作是,在另外一个线程调用wakeup,被阻塞与select方法的线程就会立刻返回。

close()

当操作Selector完毕后,需要调用close方法。close的调用会关闭Selector并使相关的SelectionKey都无效。channel本身不管被关闭。

完整的Selector案例

这有一个完整的案例,首先打开一个Selector,然后注册channel,最后调用select()获取感兴趣的操作:

  1. Selector selector = Selector.open();
  2. channel.configureBlocking(false);
  3. SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
  4. while(true) {
  5. int readyChannels = selector.select();
  6. if(readyChannels == 0) continue;
  7. Set<SelectionKey> selectedKeys = selector.selectedKeys();
  8. Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
  9. while(keyIterator.hasNext()) {
  10. SelectionKey key = keyIterator.next();
  11. if(key.isAcceptable()) {
  12. // a connection was accepted by a ServerSocketChannel.
  13. } else if (key.isConnectable()) {
  14. // a connection was established with a remote server.
  15. } else if (key.isReadable()) {
  16. // a channel is ready for reading
  17. } else if (key.isWritable()) {
  18. // a channel is ready for writing
  19. }
  20. keyIterator.remove();
  21. }
  22. }