Java I/O是Java基础之一,在面试中也比较常见,在这里我们尝试通过这篇文章阐述Java I/O的基础概念,帮助大家更好的理解Java I/O。
在刚开始学习Java I/O时,我很迷惑,因为网上绝大多数的文章都是讲解Linux网络I/O模型的,那时我总是搞不明白和Java I/O的关系。后来查了看了好多,才明白Java I/O的原理是以Linux网络I/O模型为基础的,理解了Linux网络I/O模型再学习Java I/O就很方便了,所以这篇文章,我们先来了解I/O的基本概念,再学习Linux网络I/O模型,最后再看Java中的几种I/O。另外结合面试中的反馈,补充了IO多路复用中select、poll、epoll的原理和比较。
什么是I/O?
I/O是Input、Output的缩写,即对应计算机中的输入输出。对于一次read操作中的I/O访问,数据会仙贝拷贝到操作系统内核的缓冲区中,然后才会从内核缓冲区拷贝到应用进程的地址空间。
以一次文件读取为例,我们需要将磁盘上的数据读取到用户空间,那么这次数据转移操作其实就是一次I/O操作,更具体的说是一次文件I/O。我们浏览网页,其中在请求一个网页时,服务器通过网络把数据发送给我们,此时程序将数据从TCP缓冲区复制到用户空间,那么这次数据转移操作其实也是一次I/O操作,更具体的说是一次网络I/O。I/O到处都在,十分重要,Java对I/O对底层操作系统的各种I/O模型进行了封装,使我们可以轻松开发。
Linux网络I/O模型
根据UNIX网络编程对I/O模型的分类,UNIX提供了5种I/O模型,分别是:阻塞I/O(Blocking I/O)、非阻塞I/O(Non-Blacking I/O)、I/O多路复用模型(I/O Multiplexing)、信号驱动式I/O(Signal Driven I/O)、异步I/O(Asynchronous I/O)。我们逐步了解一下其基本原理。
阻塞I/O(Blocking I/O)
阻塞I/O是最早最基础的I/O模型,其在读写数据过程中会阻塞。通过下图我们可以看到,当用户进程调用了recvfrom这个系统调用后,内核开始第一阶段的数据准备工作,直到内核等待数据准备完成,然后开始第二阶段的将数据从内核复制到用户空间的工作,最后内核返回结果。整个过程中用户进程都是阻塞的,直到最后返回结果后才解除阻塞block状态。阻塞I/O模型适用于并发量小且对时延不敏感的系统。
非阻塞I/O(Non-Blacking I/O)
当用户进程调用recvfrom这个系统调用后,如果内核尚未准备好数据,此时不再阻塞用户进程,而是立即返回一个EWOULDBLOCK错误。用户进程会不断发起系统调用直到内核中数据被准备好(轮询),此时将执行第二阶段的将数据从内核复制到用户空间的工作,然后内核返回结果。非阻塞I/O模型不断地轮询往往需要耗费大量cpu时间。
I/O多路复用模型(I/O Multiplexing)
I/O多路复用的优点在于单个进程可以同时处理多个网络连接的I/O,其基本原理就是select/poll/epoll函数可以不断的轮询其负责的所有多个socket,当某个socket有数据到达时,就通知用户进程。
如下图所示,当用户进程调用select函数时,整个进程会被阻塞block住,但是这里的阻塞不是被socket I/O阻塞,而是被select这个函数阻塞。同时内核会监听该select负责的所有socket(这里的socket一般设置为non-blocking),当任何一个socket中的数据准备好时,select就会返回给用户进程,这时候用户进程再此发起一个系统调用,将数据从内核复制到用户空间,并返回结果。
对比I/O多路复用模型和阻塞I/O模型的流程,多路复用多了一个系统调用来完成select环节,除此之外没有太大的不同。Select的优势在于它可以同时处理多个connection,但是会多一个系统调用。多路复用本质上也不是非阻塞的。
信号驱动式I/O(Signal Driven I/O)#
首先我们开启socket的信号驱动I/O功能,然后用户进程发起sigaction系统调用给内核后立即返回并可继续处理其他工作。收到sigaction系统调用的内核在将数据准备好后会按照要求产生一个signo信号通知给用户进程。然后用户进程再发起recvfrom系统调用,完成数据从内核到用户空间的复制,并返回最终结果。其基础原理图示如下:
异步I/O(Asynchronous I/O)#
用户进程向内核发起系统调用后,就可以开始去做其他事情了。内核收到异步I/O的系统调用后,会直接retrun,所以这里不会对用户进程有阻塞。之后内核等待数据准备完成后会继续将数据从内核拷贝到用户空间(具体动作可以由异步I/O调用定义),然后内核回给用户进程发送一个signal,告诉用户进程I/O操作完成了,整个过程不会导致用户请求进程阻塞。
信号驱动I/O模型是内核通知我们可以发起I/O操作了,而异步I/O模式是内核告诉我们I/O操作已经完成了。
以上就是Linux的5种网络I/O模型,其中前4中都是同步I/O模型,他们真正的I/O操作环节都会将进程阻塞,只有最后一种异步I/O模型是异步I/O操作。
Java中的I/O模型#
在JDK1.4之前,基于Java的所有socket通信都是使用阻塞I/O(BIO),JDK1.4提供了了非阻塞I/O(NIO)功能,不过虽然名字叫做NIO,实际底层模型是I/O多路复用,JDK1.7提供了针对异步I/O(AIO)功能。
BIO#
BIO简化了上层开发,但是性能瓶颈问题严重,对高并发第时延支持差。
基于消息队列和线程池技术优化的BIO模式虽然可以对高并发支持有一定帮助,但是还是受限于线程池大小和线程池阻塞队列大小的制约,当并发数超过线程池的处理能力时,部分请求法务继续处理,会导致客户端连接超时,影响用户体验。
NIO#
NIO弥补了BIO的不足,简单说就是通过selector不断轮询注册在自己上面的channel,如果channel上面有新的连接读写时间时就会被轮询出来,一个selector上面可以注册多个channel,一个线程就可以负责selector的轮询,这样就可以支持成千上万的连接。Selector就是一个轮询器,channel是一个通道,通过它来读取或者写入数据,通道是双向的,可以用于读、写、读和写。Buffer用来和channel交互,数据通过channel进出buffer。
NIO的优点是可以可靠性好以及高并发低时延,但是使用NIO的代码开发较为复杂。
AIO#
AIO,或者说叫做NIO2.0,引入了异步channel的概念,提供了异步文件channel和异步socket channel的实现,开发者可以通过Future类来表示异步操作的结果,也可以在执行异步操作时传入一个channels,实现CompletionHandler接口作为回调。AIO不用开发者单独开发独立线程的selector,异步回调操作有JDK地城思安城池负责驱动,开发起来比NIO简单一些,同时保持了高可靠高并发低时延的优点。
参考:
https://blog.csdn.net/historyasamirror/article/details/5778378
https://juejin.im/post/5cce5019e51d453a506b0ebf
概念解释:文件描述符fd
file description,是一个用于表述指向文件的引用的概念。
实际上文件描述符是一个非负整数,表示一个索引值,指向内核为每一个进程为维护的该进程打开文件的记录表。
I/O多路复用值select、poll、epoll
select、poll、epoll都是IO多路复用的机制。IO多路复用简单来说就是通过一种机制,达到一个进程可以监视多个描述符,一旦某个描述符就绪(读就绪、写就绪),可以通知应用程序进行相应的读写操作。
select、poll、epoll本质上都是同步IO,因为他们都需要在读写事件就绪后,自己负责进行读写,这个读写的过程也是阻塞的。
select
函数:
int select (int n, fdset readfds, fd_set writefds, fd_set exceptfds, struct timeval **timeout);
原理:
select函数监视三类fd,分别是writefds、readfds、exceptfds。调用后select函数会阻塞,直到有描述符fd就绪(有数据可读、可写、或有exception),或者超时timeout,函数返回。函数返回后,可以通过遍历fdset,找到就绪的文件描述符。
优点:
select目前几乎在所有平台上都支持,跨平台支持也好。
缺点:
单个进程能够监视的文件描述符的数量预先,32位机默认是1024。可以通过一定方式修改但是会导致效率降低。
每次调用select,都需要吧fd集合从用户态拷贝到内核态,fd数量多时开销很大。
对socket的扫描是线程的遍历,当socket较多但是活跃的较少时,遍历会比较低效。
poll
函数:
int poll (struct pollfd *fds, unsigned int nfds, int timeout);
原理:
poll本质上和select没有区别,也是将用户传入的数组拷贝到内核空间,然后遍历查询每个文件描述符对应的状态。与select不同的是,poll使用pollfd结构来传递,poolfd包含了要监视的event和发生的event。同时pollfd没有最大数量限制。poll返回后,也是和select一样通过轮询pollfd来获取就绪的描述符。
优点:
没有监视的文件描述符数量的限制。
缺点:
和select一样,当监视的描述符数量多时,遍历的方式效率低。
每次调用select,都需要吧fd集合从用户态拷贝到内核态,fd数量多时开销很大。
epoll
函数:
int epoll_create(int** size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大_
int epoll_ctl(intepfd, int op, int fd, struct epoll_event *event);
int epoll_wait(intepfd, struct epoll_event events, int maxevents, int timeout);
原理:
epoll是select和poll的增强版本,epoll更加灵活没有文件描述符的限制。epoll使用一个文件描述符管理多个文件描述符,将用户关心的文件描述符的事件,存档到内核的一个事件表中,这样不用向select和poll那样每次执行就要copy一遍。
在select、poll中,进程只有在调用一定的方法后,内核才对所有监视的fd进行扫描,而epoll通过事先调用epoll_ctl()来注册一个fd及其事件,后续一旦fd就绪时,内核会采用类似callback的回调机制,循序机会这个fd,然后进程调用epoll_wait()是便得到通知。
过程解析:
intepoll_create(int size);用来创建一个epoll句柄,size用来告诉内核这个监听的数目一共有多大。epoll使用完后,必须调用close(),否则epoll句柄会一直占用一个fd,导致fd被耗尽。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event **event);epoll的事件注册函数,它不同之处是不像select那样告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。epfd是epoll_create的返回值,op代表动作(add注册新的fd到epfd中,mod修改已经注册的fd的监听事件,del删除一个已注册的fd),fd代表要操作的fd,event代表告诉内核要注册监听什么事。
intepoll_wait(int epfd, struct epoll_event * events, int maxevents, int** timeout);等待事件的产生,类比于select()调用,参数events是从内核得到的事件的集合,maxevents告诉内核这个events有多大。
工作模式:
epoll对fd的操作有两种模式:LT(level trigger)和ET(edge trigger),其中LT是默认模式。
LT模式:当epoll_wait检测到描述符事件发生,并将事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次相应应用程序并通知此事。
ET模式:当epoll_wait检测到描述符事件发生,并将事件通知应用程序,应用程序必须立即处理该事件。下次条用epoll_wait时,不会再次相应应用程序并通知此事件。
ET模式很大程度上减少了epoll事件被重复触发的次数,因此效率比LT高。但是ET模式要使用非阻塞套接字。
优点:
没有最大并发连接数的限制,能打开的FD远大于1024,实际上1G的内存可以监听10万个端口。
无需每次都copy文件描述符,灵活且节省开销。
注册回调机制,取代了遍历寻找的方式,高效。
缺点:
只能在Linux下
应用:
redis、nginx
| select | poll | epoll | |
|---|---|---|---|
| 数据结构 | bitmap | 数组 | 红黑树 |
| 最大连接数 | 1024 | 无上限(但过高导致遍历低效) | 无上限 |
| fd拷贝 | 每次都要copy | 每次都要copy | 首次调用ctl时copy,每次调用wait时不copy |
| 效率 | 轮询O(n) | 轮询O(n) | 回调O(1) |
