Java NIO(New IO)是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API,以便提高传输速度。但实际上,在最新的JDK中旧的I/0包已经使用NIO重新实现过了。因此即使我们不显示的编写NIO代码,也能从中获益。速度的提高的文件I/0和网络I/O都有明显的提升。 —-《Java编程思想》
UNIX网络编程对I/O模型的分类
根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型,分别如下:
(1)阻塞I/O模型:最好理解的I/O模型就是阻塞I/O模型,所有文件操作都是阻塞的。其系统调用直到数据包到达且被复制到应用进程的缓冲区中或者发生错误时才返回,在此期间一直会等待。因为进程在从调用到它返回的整段时间内都是被阻塞的,因此被称为阻塞I/O模型。
(2)非阻塞I/O模型:应用进程调用到内核的时候,如果该缓冲区没有数据的话,就直接返回一个错误,一般都对非阻塞I/O模型进行轮询检查这个状态,看内核是不是有数据到来。
(3)I/O复用模型:Linux提供select/poll,进程通过将一个或多个文件描述符(fd)传递给select或poll系统调用,阻塞在select操作上,这样select/poll可以帮我们侦测多个fd是否处于就绪状态。select/poll是顺序扫描fd是否就绪,而且支持的fd数量有限,因此它的使用受到了一些制约。Linux还提供了一个epoll系统调用,epoll使用基于事件驱动方式代替顺序扫描,因此性能更高。当有fd就绪时,立即回调函数rollback。
(4)信号驱动I/O模型:开启套接口信号驱动I/O功能。当数据准备就绪时,就为该进程生成一个信号,通过信号回调通知应用程序来读取数据。
(5)异步I/O:告知内核启动某个操作,并让内核在整个操作完成后(包括将数据从内核复制到用户自己的缓冲区)通知我们。这种模型与信号驱动模型的主要区别是:信号驱动I/O由内核通知我们何时可以开始一个I/O操作;异步I/O模型由内核通知我们I/O操作何时已经完成。
下面我们重点讲下I/O多路复用技术,因为Java NIO的核心类库多路复用器Selector就是基于epoll的多路复用技术实现。
在I/O编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者I/O多路复用技术进行处理。I/O多路复用技术通过把多个I/O的阻塞复用到同一个select的阻塞上,从而使得系统在单线程的情况下可以同时处理多个客户端请求。与传统的多线程/多进程模型比,I/O多路复用的最大优势是系统开销小,系统不需要创建新的额外进程或者线程,也不需要维护这些进程和线程的运行,降低了系统的维护工作量,节省了系统资源,I/O多路复用的主要应用场景如下。
- 服务器需要同时处理多个处于监听状态或者多个连接状态的套接字。
- 服务器需要同时处理多种网络协议的套接字。
目前支持I/O多路复用的系统调用有select、pselect、poll、epoll,在Linux网络编程过程中,很长一段时间都使用select做轮询和网络事件通知,然而select的一些固有缺陷导致了它的应用受到了很大的限制,最终Linux不得不在新的内核版本中寻找select的替代方案,最终选择了epoll。epoll与select的原理比较类似,为了克服select的缺点,epoll作了很多重大改进,现总结如下。
- 支持一个进程打开的socket描述符(FD)不受限制(仅受限于操作系统的最大文件句柄数)。
- I/O效率不会随着FD数目的增加而线性下降。
Java NIO演进史:
发布JDK1.4时,新增了个java.nio包,提供了很多进行异步I/O开发的API和类库,主要的类和接口如下:
- 进行异步I/O操作的缓冲区ByteBuffer等;
- 进行异步I/O操作的管道Pipe;
- 进行各种I/O操作(异步或者同步)的Channel,包括ServerSocketChannel和SocketChannel;
- 多种字符集的编码能力和解码能力;
- 实现非阻塞I/O操作的多路复用器selector;
- 基于流行的Perl实现的正则表达式类库;
- 文件通道FileChannel。
新的NIO类库的提供,极大地促进了基于Java的异步非阻塞编程的发展和应用,但是,它依然有不完善的地方,特别是对文件系统的处理能力仍显不足,主要问题如下。
- 没有统一的文件属性(例如读写权限);
- API能力比较弱,例如目录的级联创建和递归遍历,往往需要自己实现;
- 底层存储系统的一些高级API无法使用;
- 所有的文件操作都是同步阻塞调用,不支持异步文件读写操作。
JDK1.7将原来的NIO类库进行了升级,被称为NIO2.0。主要提供了如下三个方面的改进。
- 提供能够批量获取文件属性的API,这些API具有平台无关性,不与特性的文件系统相耦合,另外它还提供了标准文件系统的SPI,供各个服务提供商扩展实现;
- 提供AIO功能,支持基于文件的异步I/O操作和针对网络套接字的异步操作;
- 完成JSR-51定义的通道功能,包括对配置和多播数据报的支持等。
BIO主要的问题在于每当有一个新的客户端请求接入时,服务端必须创建一个新的线程处理新接入的客户端链路,一个线程只能处理一个客户端连接。可以参见笔者的这篇文章。
即使你通过线程池或者消息队列实现1个或者多个线程处理N个客户端的模型,由于它的底层通信机制依然使用同步阻塞I/O。
同步阻塞IO的弊端
当调用OutputStream的write方法写输出流的时候,它将会被阻塞,直到所有要发送的字节全部写入完毕,或者发生异常。当消息的接收方处理缓慢的时候,将不能及时地从TCP缓冲区读取数据,这将会导致发送方的TCP window size不断减小,直到为0,双方处于Keep-Alive状态,消息发送方将不能再向TCP缓冲区写入消息,这时如果采用的是同步阻塞I/O,write操作将会被无限期阻塞,直到TCP window size大于0或者发生I/O异常。因此如果我们的应用程序依赖对方的处理速度,它的可靠性就非常差。也许在实验室进行的性能测试结果令人满意,但是一旦上线运行,面对恶劣的网络环境和良莠不齐的第三方系统,问题就会如火山一样喷发。
与Socket类和ServerSocket类相对应,NIO也提供了SocketChannel和ServerSocketChannel两种不同的套接字通道实现。这两种新增的通道都支持阻塞和非阻塞两种模式。阻塞模式使用非常简单,但是性能和可靠性都不好,非阻塞模式则正好相反。
介绍NIO编程中的一些基本概念
Channels and Buffers(通道和缓冲区):标准的IO基于字节流和字符流进行操作的,而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
唯一直接与通道交互的缓存器是ByteBuffer。这个类是相当基础的类:通过告知分配多少存储空间来创建一个ByteBuffer对象。
旧I/0类库中有三个类被修改了,用以产生FileChannel。这三类是FileInputStream,FileOutputStream以及可读可写的RandomAccessFile。这三类都是字节操作流,与底层的NIO性质一致。Reader和Writer这种字符模式类不能用于产生通道。但是java.nio.Channels.Channels类提供了实用方法,用以在通道中产生Reader和Writer。
现在我们基于如上三个旧I/0类来产生可写可读的”通道”,并用”缓冲区”来操作:
通过FileOutputStream来写数据:
public static final int SIZE = 1024;
public static final String PATH = "/home/wang/hadoopclass.txt";
try {
FileChannel fc = new FileOutputStream(PATH,true).getChannel();
fc.write(ByteBuffer.wrap("Hello World java NIO ".getBytes()));
fc.close();
} catch (IOException e) {}
通过FileInputStream来读数据:
public static final int SIZE = 1024;
public static final String PATH = "/home/wang/hadoopclass.txt";
try {
FileChannel fc = new FileInputStream(PATH).getChannel();
ByteBuffer buffer = ByteBuffer.allocate(SIZE);
fc.read(buffer);
//重值ByteBuffer中的数组
buffer.flip();
while (buffer.hasRemaining()){
System.out.println((char)buffer.get());
}
} catch (IOException e) {
e.printStackTrace();
}
通过RandomAccessFile来读写数据:
try {
FileChannel fc = new FileInputStream(PATH).getChannel();
ByteBuffer buffer = ByteBuffer.allocate(SIZE);
fc.read(buffer);
//重值ByteBuffer中的数组,调用方法后输出通道会从数据的开头而不是末尾开始
buffer.flip();
while (buffer.hasRemaining()){
System.out.println((char)buffer.get());
}
} catch (IOException e) {
e.printStackTrace();
}
这三个类通过getChannel()将会产生一个FileChannel。通道是一种相当基础的东西,可以向它传送用于读写的ByteBuffer,并且可以锁定文件的某些区域用于独占式访问。
如上的代码我们先了解了NIO为何物,后续博客详细分析Channel以及ByteBuffer,以及再网络编程中的具体的使用