Java NIO学习系列四:NIO和IO对比
前面的一些文章中我总结了一些Java IO和NIO相关的主要知识点,也是管中窥豹,IO类库已经功能很强大了,但是Java 为什么又要引入NIO,这是我一直不是很清楚的?前面也只是简单提及了一下:因为性能,但是仅仅是因为性能吗,除此之外是否还有别的原因,或者说既然NIO性能好,那为什么现在我们还在使用IO。本节我们就来详细对比一下两者的特性以及两者之间的不一致对我们编码所带来的影响。
同样,本文会主要围绕下面几个方面来总结:
1. Java NIO和IO的主要区别
两者之间的不同主要体现在如下三个方面:
- Java IO是面向流(Stream)的,而Java NIO是面向缓冲区(Buffer)的;
- IO模型的不同,Java IO是属于阻塞式IO(Blocking IO),而Java NIO是属于非阻塞式IO(Non Blocking IO);
- Java NIO中还引入了Selector的概念,可以实现多路复用;
在接下来的部分,我们逐个讨论这三个不同。
1.1 面向流与面向缓冲区
Java NIO和IO之间第一个不同点是IO是面向流(Stream)的而NIO是面向缓冲区(Buffer)的。
Java IO是面向流的,这意味着是一次性从流中读取一批数据,这些数据并不会缓存在任何地方,并且对于在流中的数据是不支持在数据中前后移动。如果需要在这些数据中移动(为什么要移动,可以多次读取),则还是需要将这部分数据先缓存在缓冲区中。
而Java NIO采用的是面向缓冲区的方式,有些不同,数据会先读取到缓冲区中以供稍后处理。在buffer中是可以方便地前移和后移,这使得在处理数据时可以有更大的灵活性。但是呢需要检查buffer是否包含需要的所有数据以便能够将其完整地处理,并且需要确保在通过channel往buffer读数据的时候不能够覆盖还未处理的数据。
1.2 IO模型的区别
Java IO中使用的流是属于阻塞式的,意味着当线程调用其read()或write()方法时线程会阻塞,直到完成了数据的读写,在读写的过程中线程是什么都做不了的。
Java NIO提供了一种非阻塞模式,使得线程向channel请求读数据时,只会获取已经就绪的数据,并不会阻塞以等待所有数据都准备好(IO就是这样做),这样在数据准备的阶段线程就能够去处理别的事情。对于非阻塞式写数据是一样的。线程往channel中写数据时,并不会阻塞以等待数据写完,而是可以处理别的事情,等到数据已经写好了,线程再处理这部分事情。
当线程在进行IO调用并且不会进入阻塞的情况下,这部分的空余时间就可以花在和其他channel进行IO交互上。也就是说,这样单个线程就能够管理多个channel的输入和输出了。
1.3 Selector
Java NIO中的Selector允许单个线程监控多个channel,可以将多个channel注册到一个Selector中,然后可以"select"出已经准备好数据的channel,或者准备好写入的channel。这个selector机制使得单个线程同时管理多个channel变得更容易。
2. NIO和IO的不同对代码设计带来的变化
选择使用NIO还是IO作为开发工具包会在如下几个方面影响应用设计:
- API是调用NIO类库还是IO类库;
- 数据的处理方式;
- 用来处理数据的线程的数量;
2.1 API的调用
采用NIO的API调用方式和IO是不一样的,与直接从InputStream中读取字节数据不同,在NIO中,数据必须要先被读到buffer中,然后再从那里进行后续的处理。
2.2 数据的处理方式
采用NIO的设计还是IO的设计,数据的处理方式也是不一样的。
在IO设计中是从InputStream或Reader中逐字节读取数据。在下面例子中,我们通过一个处理基于文本的简单例子来说明两种设计的区别:
Name: Anna
Age: 25
Email: anna@mailserver.com
Phone: 1234567890
采用IO的方式,这些数据流会像下面这样处理:
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()会阻塞直到整行数据读完。而且我们能够确切地知道所读取的这第一行是包含名字的。类似,第二次调用readLine()返回之后我们确切地知道所读取的内容包含年龄。
可以知道,上面的程序只有当有新的数据是可读时才会进行处理,在每一步都知道数据是什么。一旦执行读写的线程已经读取了一些数据之后,是不能够再返回到前面的数据(因为流的方式只能读取一次,很好理解,像水一样,流完了就流完了,除非你把它装到容器里面)。上面程序中所遵循的原则如下图所示:
而NIO的实现则看起来有些不同,如下:
ByteBuffer buffer = ByteBuffer.allocate(48); int bytesRead = inChannel.read(buffer);
注意第二行是从channel读取数据到buffer中,当read()方法返回时我们是不知道是否所有需要的数据有没有全部读到buffer中,我们知道的只是buffer中可能包含一部分数据,这会使得整个过程的处理有点麻烦。
假设,在第一次调用read()之后,所有读到buffer中的数据只有半行,比如,"Name:An"。这时可以处理数据吗,显然是不可以的(因为还没有读完),需要等到至少一行数据被读到buffer中。
那么我们又如何来知道buffer中包含足够可以处理的数据呢?唯一的办法只有检查buffer中的数据了。所以结果就是我们需要通过多次检查buffer中的数据来判断数据是否已经全部读进buffer了。这样就很低效,而且容易导致程序设计混乱。比如:
ByteBuffer buffer = ByteBuffer.allocate(48); int bytesRead = inChannel.read(buffer); while(! bufferFull(bytesRead) ) { bytesRead = inChannel.read(buffer); }
bufferFull()方法会跟踪有多少数据被读到buffer中了,并且返回true或者false,取决于buffer是否已满。换言之,如果buffer中的数据已经可供处理,那就代表它已经满了。
bufferFull()方法会扫描整个buffer,要保证扫描并不会影响整个buffer的状态,不然可能导致后面要读入buffer中的数据不能读到正确地位置。这并非不可能,所以对于设计者来说这是一个需要关注的地方。
如果buffer已满,那其中的数据就可供处理。如果没满,那可能需要部分地处理那些数据(如果需要的话),只是在大部分场景下是不需要的。
下图描述了这种 is-data-in-buffer-ready的循环:
3. 两种IO的各自适用场景
NIO使得通过单个或少量线程来管理多个channel(网络连接或者文件)成为可能,但是代价是传递数据会比从阻塞的流中读数据更复杂。我们学习一项新的技术时,既要看到其优点也要看到其缺点。
如果需要同时管理数以千计的连接,而且每个连接只会发送少量的数据,比如聊天服务器,用NIO的方式来实现这个服务器则比较合适。类似的,如果需要长时间保持一些和别的电脑的连接,比如在一个P2P网络中,用单个线程来管理所有的对外连接也有优势。如下图描述了这种单个线程,多个连接的设计模型:
如果只有少量的连接,但是每个连接又都占用大量的带宽,短时间之内发送大量数据,这时后也许传统的IO模型会更适用,因为专一,所以在特定场景下可以更高效。如下图描述了一个基于传统IO模型设计的服务器模型:
4. 总结
在前面总结了很多IO和NIO的相关知识之后,本文总结了Java中两种IO类库的区别即各自的优缺点:
- 传统Java IO是面向流,从流中读取数据或者写入到流中,而Java NIO是面向缓冲区的,通过channel和buffer的搭配使用来读取或者写入数据;
- 面向流只能一次读取数据;面向缓冲区可以多次读取数据;
- 面向流的方式处理数据过程相对简单,易于实现;而Java NIO中面向buffer的方式一般是非阻塞的方式,所以在数据的操作上会更复杂,从而会增加代码的复杂程度;
- Java NIO提供了Selector的概念,可以通过少量线程处理多个连接,可以有效处理并发;而Java IO则专注于单个线程阻塞式读写,对于少量连接但是每个连接都占用大量宽带的场景更适用;
技术没有好坏,只有合适与否!