在我们学习 NIO 和 IO 的 API 时,脑海中会冒出这个问题:
“什么时候用 NIO?什么时候用 IO?”
接下来我们将对二者之间的异同点进行详述。
Java NIO 与 IO 之间的主要差异
下面的表格列出了二者间的主要差异,下文将对这些差异进行详述。
IO | NIO |
---|---|
面向 Stream | 面向 Buffer |
阻塞 IO | 选择器 Selectors |
面向 Stream vs. 面向 Buffer
面向 stream 和 面向 buffer 的不同,意味着什么呢?
Java IO 是面向流的,意味着你从流中一次读取一个或多个字节,你用读到的字节做什么完全取决于你,字节没有任何缓存。此外,你无法在流中前后移动。如果你需要在你从流中读到的数据里前后移动,那你必须先将它们缓存在缓冲区中。
Java NIO 面向缓冲区的方法略有不同。数据被读到之后处理的缓冲区中。你可以在缓冲区中按你的需求前后移动。这会让你在处理期间有更大的灵活性。然而,为了完整的处理数据,你需要检查缓冲区是否包含了你需要的全部数据。还有,你需要确保往缓冲区写入更多数据时,是否会覆盖缓冲区中待处理的数据。
阻塞(Blocking)vs. 非阻塞(Non-blocking) IO
Java IO 的各种流都是阻塞式的。也就是说,当一个线程调用 read() 或 write() 方法,那个线程将被阻塞,直到有数据读到或数据完全写入为止。该线程在此期间将什么都不做。
Java NIO 的非阻塞模式使一个线程能够请求从通道中读数据,仅得到当前可读的数据,或者如果当前没有数据可读时,就什么都得不到。而不是一直阻塞到数据成为可供读取的状态,在此期间线程可以继续做其他事情。
该方式同样适用于非阻塞式写入。一个线程可以请求向通道中写入一些数据,但是不必等到它完全写入。在此期间线程可以继续做其他事情。
该线程在非阻塞 IO 调用期间,会利用空闲时间处理其他通道的 IO 请求。也就是说,单个线程现在可以管理多个通道的输入和输出。
选择器(Selectors)
Java NIO 的 选择器(selectors )可以用单个线程来监听多个通道(channels )的输入状态。你可以在一个选择器上注册多个通道,然后用单个线程去选中(select)那些有输入信息的通道或准备进行输出操作的通道来处理。这个选择器机制使得单线程管理多通道的问题变得很简单。
NIO 和 IO 对应用程序设计的影响
无论你选择 NIO 或 IO 作为你的 IO 工具,都可能从以下几个方面影响应用程序的设计:
- NIO 或 IO 类的API 调用方式;
- 数据处理过程;
- 用于处理数据的线程数。
API调用
采用 IO 来调用API,仅需要从输入流(例如 InputStream)中读取字节数据,而 NIO 方式则需要先将数据读取到一个缓冲区(Buffer)中,然后在缓冲区中进行处理。
由此来看,NIO 与 IO 在 API调用方面差距较大。
数据处理
当使用一个纯 NIO 或 IO 方式时,对数据处理过程也有一定的影响。
用 IO 方式,你可以从 InputStream 或 Reader 中读取数据字节。假设你正在处理一个基于行的文本数据流,文本如下:
Name: Anna
Age: 25
Email: anna@mailserver.com
Phone: 1234567890
这个文本行的流可以这样处理:
InputStream input = ... ; // get the InputStream from the client socket
BufferedReader reader = new BufferedReader(new InputStreamReader(input));
String nameLine = reader.readLine();
String ageLine = reader.readLine();
String emailLine = reader.readLine();
String phoneLine = reader.readLine();
程序的执行状态是由程序执行了多少来决定的。换句话说,一旦第一行 reader.readLine() 方法返回,你肯定知道已经读到了一整行的文本。因为readLine() 会一直阻塞到整行文本读取完成。你也知道这行文本中包含了name 信息。同理,当第二行 readLine() 调用返回时,你知道这行文本中包含了 age 信息。
正如你所看到的,只有当有新数据读取时,程序才会进行,并且每一步你都都知道数据是什么。一旦执行中的线程已经执行了读取某个数据片段的代码,这个线程将无法(几乎不能)回退数据。这个原理在下图中有所说明:
NIO 的实现看起来会有所不同,这是一个简单的例子:
ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buffer);
注意第二行,从通道将字节读入到 ByteBuffer 中。当方法调用返回时,你不知道你所需的所有数据是否已在缓冲区中。这使得处理过程稍微更难一些。
想象一下,在第一次 read(buffer) 调用之后,读入缓冲区的所有内容都是半行。例如,"Name: An"。你能处理这些数据吗?显然是不能的。你需要等到至少有一整行数据进入到了缓冲区,在此之前处理任何数据都无意义。
那么你怎么知道缓冲区是否包含足够的数据来使它有被处理的意义呢?的确,你不会知道。查看缓冲区中的数据是知道的唯一方法。结果是,你可能必须多次检查缓冲区中的数据,然后才知道是否所有数据都在内部。这种方式效率很低,而且在程序设计方面可能变得很乱。例如:
ByteBuffer buffer = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buffer);
while(! bufferFull(bytesRead) ) {
bytesRead = inChannel.read(buffer);
}
bufferFull() 方法必须跟踪读取到缓冲区中的数据量,并返回 true 或 false,具体取决于缓冲区是否已满。换句话说,如果缓冲区准备进行处理,则认为缓冲区已满。
bufferFull() 方法扫描缓冲区,但必须使缓冲区处于与调用 bufferFull() 方法之前相同的状态。否则,读入缓冲区的下一个数据可能不会在正确的位置读取。这并非不可能,但它也是另一个值得注意的问题。
如果缓冲区已满,则可以进行处理。如果它不是满的,你也许能够部分处理这些数据(假设这在你的特定情况下是有意义的,事实上在大部分情况下是没有意义的)。
在下图中说明了缓冲区内数据准备循环的过程:
总结
使用 NIO ,你可以用一个(或少量)线程来管理多个通道(网络连接或文件)。成本是,解析数据可能比在从阻塞流读取数据时要复杂得多。
如果你需要管理成千上万个同时打开的连接,而且每个连接上只发送少量数据(比如聊天服务器),那么采用 NIO 方式来实现服务器会更好。
同样,如果你需要与其他电脑保持大量打开的连接(例如 P2P 网络),使用单个线程管理所有出站(outbound)连接可能是一个优势。下面这个图描述了单个线程管理多连接的设计:
如果你需要少量连接数且非常高的带宽,每次发送大量数据,那么采用典型的 IO 服务器实现更符合需求。这个图展示了典型的 IO 服务器设计: