前面我们介绍了一系列的 NIO Channel 的使用,发现它与标准的 IO 有很大的不同,本篇我们就来总结一下 NIO 与 IO 之间的差异。

主要区别

对比如下:

IO NIO
面向流 (Stream) 面向缓冲 (Buffer)
阻塞 IO (Blocking IO) 非阻塞 IO (Non Blocking IO)
\ 选择器 (Selectors)

流 vs 缓冲区

Java NIO 和 IO 之间的第一个重要区别是 IO 是面向流的,而 NIO 是面向缓冲区的。

IO 以面向流为主,意味着你可以从流中一次性读取一个或多个字节。如何操作读取到的字节取决于你自己。这些流中的字节没有做任何缓存。此外,你无法在流中的数据做前后移动操作。如果你想要对读取到数据做前后移动操作,则必须将其缓存到 Buffer 中。

Java NIO 的面向缓冲区的方法略有不同。数据读入缓冲区,然后在处理该缓冲区。 你可以根据你的需要在缓冲区中做前后移动操作。这样可以提高处理数据时的灵活性。不过,在处理数据之前,还需要检查缓冲区是否包含了完整的数据,并且,要确保缓冲区在读入更多的数据时,不能覆盖掉尚未处理的数据。

阻塞 vs 非阻塞

Java IO 中的各种流都是阻塞的。当一个线程调用 read () 或 write () 方法时,在数据被完整读取或写入之前,该线程一直处于阻塞状态,其间它不能处理任何其他的任务。

Selector-NIO

Java NIO 非阻塞模式能允许线程从通道中读取数据,通道中有多少数据就读多少数据,如果没有可读的数据,那就什么也不读取。在有数据可以读取之前,线程可以去干其他的事情,而不用一直阻塞等待。

Java NIO 写操作也是类似。个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。

线程通常将非阻塞 IO 的空闲时间用于在其它通道上执行 IO 操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

Socket-BIO

选择器

Java NIO 选择器允许一个线程去监控多个输入通道。你可以用一个选择器去注册多个通道,然后线程就可以” 选择” 出哪些通道已经准备好了读取或写入操作。这种选择器机制让单个线程同时管理多个通道变得更加容易。

NIO 与 IO 影响应用程序设计

选择用 NIO 还是 IO 作为你程序的 IO 工具包,将会影响应用程序的以下几个方面:

  • NIO 或 IO 类的 API 调用
  • 数据的处理方式
  • 处理数据的线程数量

API 调用

当然,使用 NIO 时的 API 调用看起来与使用 IO 时不同。 这并不奇怪。 而不是仅仅从例如字节读取数据字节。 在 InputStream 中,必须首先将数据读入缓冲区,然后从那里进行处理。

数据处理

使用纯 NIO 设计与 IO 设计时,数据处理也会受到影响。

在 IO 设计中,你从 InputStream 或 Reader 中一个字节一个字节地读取数据。 想象一下,你正在处理基于行的文本数据流。 例如:

1
2
3
4
Name: Anna
Age: 25
Email: [email protected]
Phone: 1234567890

逐行逐行地处理该数据,过程如下:

1
2
3
4
5
6
7
8
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();

从代码中,我们可以注意到,程序当前的状态取决于程序已经运行到哪一行了。换句话说,当第一个 readLine () 方法返回时,我们就知道文本中的第一行完整的内容已经被读取出来了,因为在 readLine () 读取完一整行的内容之前,它一直处于 Block 状态。而且,你也知道第一行读取出来的内容就是名字,第二行则是年龄,第三、四行等等。

正如你所看到的,只有当有新数据要读取时,程序才会进行,并且对于每个步骤,你都知道该数据是什么。 一旦执行的线程已经超过读取代码中的某个数据片段,该线程就不会在数据中向后移动(通常不会)。 如图:

NIO 的实现就完全不一样了,例如:

1
2
3
ByteBuffer buffer = ByteBuffer.allocate(48);

int bytesRead = inChannel.read(buffer);

注意第二行代码,从 channel 中读取字节保存到 Buffer 中。当你这个 read () 方法返回时,你无法知道你所需要的数据是否已经全部存储在了 Buffer 中,你仅仅只是知道 Buffer 中存储一些字节数据。这样就让数据处理变得稍微困难了点。

想象一下,在第一次调用 read(buffer) 之后 ,buffer 中只读取到了一半的数据,例如:”Name: An”。你能处理这样不完整的数据吗?当然不能!因此,在处理任何数据之前,你需要等待至少一整行数据进入缓冲区。

那么,你怎么知道 Buffer 中包含了你想要处理的数据呢?很显然,不能。唯一的办法就是去检查 Buffer 中的数据。这样一来,在你知道所有数据是否存在之前,你可能需要多次检查缓冲区中的数据。这样就会导致程序变得低效,而且代码变得有点混乱。例如:

1
2
3
4
5
6
7
ByteBuffer buffer = ByteBuffer.allocate(48);

int bytesRead = inChannel.read(buffer);

while(! bufferFull(bytesRead) ) {
bytesRead = inChannel.read(buffer);
}

这个 bufferFull() 方法必须去跟踪有多少字节已经读取到了 Buffer 中。当 Buffer 中的数据已经准备好了的时候,需要返回 ture,否则返回 false。

bufferFull() 方法扫描缓冲区,但必须使缓冲区保持与调用 bufferFull () 方法之前相同的状态。否则,下一个将要读取到缓冲区的数据可能无法在正确的 position (位置) 上被读取。 这不是不可能的,但这是另一个需要注意的问题。

如果缓冲区已满,则可以对其进行处理。 如果它不满,你可以对缓冲区中的部分数据进行处理,但这样处理的前提是对你所意义。在许多情况下,一般是没有意义的。

请看缓冲区处理数据的示意图:

总结

NIO 允许你仅使用一个(或几个)线程来管理多个通道(网络连接或文件),但成本是解析数据可能比从阻塞流中读取数据时要复杂一些。

如果你需要同时管理数以千计的活跃连接,每个只发送一些数据,例如聊天服务器,用 NIO 来实现服务器可能是明智的选择。 同样,如果你需要与其他计算机保持大量的开放连接,例如在 P2P 网络中,使用单个线程来管理所有出站连接可能是比较好的选择。 此图中说明了一个线程管理多个连接的设计:

如果您拥有较少的且带宽高的连接,一次需要发送大量数据,那么经典的 IO 服务器实现可能是最合适的。此图说明了典型的 IO 服务器设计:

参考资料