底层I/O
在说Java NIO之前先介绍一下与系统相关的一些底层I/O细节,通常为了系统的安全,用户进程是无法直接操作I/O设备的,必须通过调用系统内核来协助完成I/O动作,在系统内核中为每个I/O设备维护着一个buffer。整个流程如下图所示:用户进程发起请求recvFrom,内核接受请求,从I/O设备中读取数据到内核buffer中,然后将buffer中的数据copy到用户进程的地址空间(可以理解成内存),用户进程从用户空间中获取数据后再响应客户端。
在整个请求过程中,将数据(从磁盘读取或从网卡读取)读至buffer需要时间,从内核buffer复制到用户空间也需要时间。因此根据在这两段时间内用户进程等待方式的不同,I/O动作可以分为以下5类:
1)阻塞I/O (Blocking I/O):在IO执行的两个阶段用户进程一直处于block状态,直到内核将数据拷贝到用户缓冲区,返回结果后才会解除block状态。如图
当用户进程调用了recvfrom这个系统调用后,内核就开始了IO的第一个阶段:等待数据准备。对于网络IO来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候内核就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当内核一直等到数据准备好了,它就会将数据从内核中拷贝到用户内存,然后内核返回结果,用户进程才解除block的状态,重新运行起来。
在JDK1.4之前,所有的IO都是阻塞I/O,如下面的is.read(),该方法的阻塞流程是这样的:客户端通过输出流发送数据-->网络传输-->服务器网卡接到数据-->读取数据到系统内核-->复制到用户进程空间(内存)-->read方法从内存中读取数据,返回。在这样的整个过程中该线程都处理阻塞状态。
public static void bio() throws IOException {
ServerSocket ss = new ServerSocket(9191);
Socket socket = ss.accept();
InputStream is = socket.getInputStream();
byte[] b = new byte[1024];
is.read(b);
}
在Java中,BIO通常配合多线程或线程池一起使用,如早期的Tomcat
private static final int DEFAULT_POO_SIZE = 10;
private static final ExecutorService pool = Executors.newFixedThreadPool(DEFAULT_POO_SIZE);
public static void bio() throws Exception {
ServerSocket ss = new ServerSocket(9191);
while (true) {
Socket socket = ss.accept();
pool.execute(() -> {
//doing something (socket)
});
}
}
相当于每接收到一个客户端请求,就分配一个新的线程去处理,随着访问量的增加,线程数量有限的情况下,阻塞仍然不可避免,但如果增加线程数量,则线程间切换的成本也会相应的提高,所以在访问量大的情况下可以使用多路复用I/O进一步提高利用率。
2)非阻塞I/O (Non-Blocking I/O):进程在发送请求后会立即收到一个结果,如果此时内核中数据尚未准备好,那么这个结果代表一个错误信息,通常情况下,进程需要利用轮询的方式来检测该数据是否就绪,如果最终内核的数据准备好了,并且又再次收到了用户进程的调用请求,那么它马上就将数据拷贝到用户内存,然后返回。这里说的轮询是指在应用程序中循环调用,是一种比较浪费CPU时间的操作,但这种模式偶尔会遇到。
3)I/O复用(I/O Multiplexing):多路复用的基本原理依赖于select/epoll函数,select/epoll会不断的轮询其负责的所有socket,当某个socket有数据到达了,就通知用户进程(与上面的区别在于,轮询是由操作系统发起的,而不是我们的应用程序)。所以这就决定了select/epoll的优势并不是对于单个连接能处理得更快,而是在于系统级别的连接过滤(及时过滤出可以处理的连接),从而可以一定程序上应对更多的连接。如下图
当用户进程调用了select,进程处于block状态,内核会轮询“监视”select负责的socket,只要有任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从内核拷贝到用户进程。在整个过程中,用户进程也是一直被block的,只不过进程是被select这个函数block,而不是被Socket IO给block。
虽然多路复用IO和阻塞IO在整个过程中都会被阻塞,但是select的优势在于在一个进程(或线程)中可以同时处理多个connection,更适应连接量大的场景,可以减少需要的线程数量,从而降低线程间的切换消耗。但如果处理的连接数不是很高的话,使用select就并不一定比【多线程 + 阻塞IO】要快了,原因在于select需要使用两个system call (select 和 recvfrom),而bio只需要调用一个recvfrom。
4)信号驱动I/O
进程向内核注册一个IO信号事件,在数据可操作时内核将通过SIGIO信号通知应用进程,应用进程就可以在优先定义好的信号处理程序中调用recvfrom来读数据(相当于回调函数)。整个过程在第一阶段并会不block,只在第二阶段(将数据从内核复制到应该进程的空间)会产生block。
以上四种,虽然在第一阶段阻塞情况各不相同,但在第二阶段数据拷贝期间都会block,所以都可以看成是同步IO,这也是同步IO和异步IO的主要区别。
5)异步AIO
进程通过系统调用通知内核启动某个操作并在整个操作(包括第二阶段的操作)完成后通知进程。原理与信号驱动I/O相似,主要区别在于,前者告诉我们何时可以开始拷贝,而异步I/O表示何时拷贝完成。
总结
阻塞IO:两个阶段都阻塞
非阻塞IO:在第一阶段,进程不断的轮询直到数据准备好,第二阶段还是阻塞的
IO复用:在第一阶段,由内核轮询所有监视的socket,当有IO准备就绪时,通知进程。第二阶段进程通过recvform来拷贝数据。两个阶段都阻塞,优势在于可以连接过滤,从而可以应对更多的连接。
信号IO:进程注册IO事件信号,内核在数据准备完毕后通过响应信号,通知进程,只在第二阶段阻塞。
异步IO:两阶段都不会阻塞
select/poll/epoll说明:select/poll/epoll链接好文
Java对NIO的支持
nio即new I/O,在JDK1.4之后才有(JDK1.6版本后使用epoll替代了传统的select/poll,更加提升了NIO通信的性能),与原IO的主要区别在于,NIO是一种非阻塞式I/O,工作原理是通过由Selector来集中管理和分发所有的IO事件,事件到来时,执行相应监听事件,这其实就是I/O多路复用模式。事件主要有以下四类
OP_ACCEPT: 服务端接收客户端连接 |
OP_CONNECT: 客户端新开连接事件 |
OP_READ: 读事件 |
OP_WRITE: 写事件 |
从编码上来说NIO面向是缓冲区,而且是通道是双向的。而原IO面向流且是单向的,要么是输出流要么是输入流。
选择器(Selector)
选择器用于监听通道事件,通过向selector中注册多个通道,可以在单个线程中可以监听多个数据通道。与selector配合的通道必须是非阻塞通道,对于阻塞通道,在注册时将抛出异常:IllegalBlockingModeException
通道(Channel)
通道是一个用于 I/O 操作的连接,可以是一个网络连接、或一个文件描述符等等。通道总是从buffer中读取数据或将数据写入到buffer中。常用的通道类型有以下几种
- FileChannel:从文件中读写数据,这被设计成一个阻塞通道(因为认为file操作不需要非阻塞),不能与与selector配合使用。
- DatagramChannel:通过UDP向网络连接的两端读写数据
- SocketChannel:通过TCP向网络连接的两端读写数据,代表客户端到服务端的一个连接。
- ServerSocketChannel:一个基于通道的Socket监听器,用来在服务端监听新进来的TCP连接,类似于Web服务器。
缓冲区(Buffer)
Buffer主要有以下个属性
- capacity(容量):缓冲区大小
- position(偏移量):表示当前所处的读或写的位置,从0开始,所以最大值为capacity-1。
- limit(上限):表示最多可以往buffer中写入或最多可从buffer中读取多少数据,在写模式下,该值等于capacity;读模式下该值等于写模式下的position。
- mark(标记):用来标记一个位置,后续通过reset()可以恢复position到该位置,所以mark的位置<=position。
以上的4个属性:mark<=position<=limit<=capacity