NIO
概述
相信很多java开发工程师都有经历过NIO面试的吊打,这里需要澄清一下,我们IO其实跟java的关系其实不大的! 可能很多做java开发的人都都会一头雾水,怎么就没关系了呢?没错,真的没有关系,IO永远是针对操作系统而言的,并且通常情况下 我们其实更关注的是NI,当然并不是NO不重要,只是我们日常开发的过程中很少会有NO的瓶颈,毕竟数据掌握在我们自己手中,但是 对于输入流就不一样了,尤其是网络上的输入流。
IO介绍
内存空间
在真正的开始介绍IO的相关概念之前,我觉得有必要介绍一下操作系统的内存空间的划分:操作系统会将内存空间 划分为用户空间和内核空间,内核空间是操作系统运行的内存空间,而用户空间是用户进程运行的内存空间。之所以 对内存空间进行划分是为了安全,将用户的进程空间隔离在操作系统的进程空间之外,这样的话即便是用户进程的 非法操作也可以在操作系统层面上做统一的控制。从而保证操作系统的正常运行。
在读写文件的时候用户进程会通过系统调用由用户态切换为内核态,当文件准备就绪之后又会由内核态切换为用户态,而用户态和 内核态之间的切换需要保存现场,相关的系统开销是比较大的。
IO过程
IO是外部设备和计算机之间的一种交互,整个交互的过程分成两步:
- 发起IO操作:用户线程(或者进程)通过系统调用向内核发起读取数据的请求,由于数据可能不存在内存中,这时候发起系统调用的线程会进入等待(当然也可以不等待)
- 实际IO操作:数据从磁盘上读取到内核空间之后(或者网络上的分组数据到齐之后),由用户线程或者进程将内核空间中的数据拷贝到用户空间的过程(当然也可以是内核线程主动将数据拷贝到用户空间)
第一个过程涉及的阻塞操作(等待与否)就是我们常说的IO中的阻塞或者非阻塞,第二个过程涉及的阻塞操作(主动拷贝数据与否)我们称之为同步或者异步
IO模型划分
linux的IO大致可以划分成如下几种:
- 同步阻塞IO
- 同步非阻塞IO
- 多路复用IO
- 异步IO
我们分别来看一下:
同步阻塞IO

如上,用户进程或者线程发起recvfrom系统调用来读数据的时候,这个时候不管我们要读取的是网络数据还是磁盘数据, 都会出现如下的处理过程:
- 用户进程或者线程进入等待:网络数据需要等待足够的分组到来,本地磁盘会通过缺页异常中断将文件从磁盘加载到指定的内核内存空间, 数据未准备好的情况下将会用户进程或者线程将会一直阻塞直到数据准备好,阻塞的过程中用户的线程或者进程做不了其他的事情
- 数据拷贝:数据准备好之后由用户线程将内核空间的数据拷贝到用户空间
上面两个步骤中第一步我们称之为阻塞,第二步我们称之为同步,因此合起来就叫做同步阻塞IO。
上面这种问题可以通过服务端配置多线程的方式来进行优化,更进一步,我们可以指定线程池来进行优化,这样可以控制服务端能同时处理的连接的个数。
同步非阻塞IO(NIO)
上面的处理流程中很明显存在一个问题,那就是当数据不存在的时候用户的进程或者线程将会一直阻塞,也就没办法处理其他业务了,
假定线程资源被连接耗尽了,而连接又没有真正的数据发送到服务端,那么服务端就处于僵死的状态了,
因此通常情况下可以设置服务端为非阻塞的状态,对应的系统调用图如下:
当设置了非阻塞的时候,我们通过系统调用read操作来读取数据的时候,如果没有数据会立即返回一个错误,
由于此时进程或者线程已经又从内核态转变成用户态,因此用户的线程不会阻塞,而接下来的过程中
我们就可以继续处理我们自己的业务了(这里的业务不再是从上面对应的socket接收数据了),
例如打印个hello world,然后我们在接下来的一轮循环中再次从socket中读取数据(
这里的socket需要记录并保存,否则我们将无法再使用这些socket)。
当我们在循环的过程中发现有socket数据准备好之后,用户进程或者线程就可以同步去拷贝数据了。 这里我们看到了循环的过程,其实是用户态的多路复用,只不过这个多路复用和真正的多路复用还不太一样 ,真正的多路复用是阻塞的,也就是说当复用器(并没有所谓的复用器,这里其实是用户进程或者线程) 监听的多路socket都没有数据的时候其实是会阻塞的,因此是需要单独的一个线程完成多路数据的监听的,防止阻塞主线程, NIO(用户态的多路复用)更多的像是是一个忙等待并非像多路复用那样是一个阻塞的过程。如下,我们用java代码演示一下:
服务端代码:
1 | import java.io.IOException; |
客户端代码:
1 | import java.io.IOException; |
如上,关于客户端的代码没有什么好说的,服务端的代码就有讲究了,我们在创建了一个serverSocketChannel后,特意设置其为非阻塞。需要注意的是 这里的阻塞不仅包括IO的阻塞,也包括accept的阻塞,当设置为非阻塞的时候accept在调用的 时候如果没有客户端链接过来,那么会直接返回一个null,而如果我们设置为阻塞,则在accept的时候如果没有连接到来,那么用户线程或者进程会一直阻塞住直到 有连接过来。
另外我们还看到了在serversocket监听到用户的请求过来的并生成对应的socket的时候,我们将其保存在一个list中,这种情况下其实就是模拟的多路复用IO中的多路,如果我们不保存 多路的话,那么我们怎么样复用一个线程来轮训呢?毕竟,我们需要知道这么多新建的socket对应的fd。再进一步可以想像一下,我们新建了一个连接到服务端,此时如果不保存该链接对应的 socket,那么过了十分钟后,客户端向服务端发送了一条消息,我们却找不到对应的socket了,那么数据又怎么获取到呢?
多路复用IO
多路复用IO顾名思义,多个IO由同一个监听器完成监视(多个IO由复用同一个监视器),具体可以分成以下三种模型:
select
系统调用函数原型如下:
1 | int select(int maxfdp, fd_set *readfds, fd_set *writefds, fd_set *errorfds, struct timeval *timeout); |
查看该系统调用的说明如下:

上面主要的结构体是fd_set,该set是一个位图代表的集合,我们使用该位图来表征文件描述符(具体点就是文件描述符为x则将位图的x位进行置位),具体定义如下:
1 | typedef struct |
上面使用long类型的数据来作为位图的基本单元,每一个long类型的数据都是由64个bit组成, 因此我们使用 1024/64个long类型的数据就可以指代一个1024位的集合了。接下来我们如果我们想要监听文件描述符为x(x<1024)的读事件,只需要 将readfds对应的第x位进行置位即可,同理写事件、错误事件只需要将writefds、errorfds对应的位进行置位就可以了。
select有一个明显的缺点,那就是fd_set太小,虽然可以更改,不过大小都是通过宏引入的,因此会存在限制。 另外这么多的文件描述符需要从用户空间和内核空间来回进行拷贝,因此也会有一定的开销,再者,这么多的文件描述符都需要内核进行全遍历(不论该fd对应的socket是否有数据到来), 因此也会有一定的开销,不过相较于NIO中使用list来保存并遍历socket,select是通过内核来进行遍历所有的fd(NIO是用户主动遍历所有的socket, 有点类似于批量插入的时候在用户的循环中插入数据,肯定是内核这种提交一个集合的方式插入效率要高一点),毕竟用户线程或者进程主动遍历的会带来多次的系统调用开销。
poll
poll系统调用也可以用于执行多路复用IO,对应的函数原型如下:
1 | #include <poll.h> |
上面的函数使用到一个结构体pollfd:
1 | struct pollfd { |
对应的参数的含义分别如下:
- fds为文件描述符相关的结构体对应的数组的头指针
- nfds用来描述数组的长度,毕竟数组是有序的,只要确定了头指针,就可以通过下表来确定范围
- timeout用来指定接口调用的超时时间
这里其实和select并没有太多的不同,只是这里使用了一个指针指向文件描述符的数组,因此调用的时候文件描述符不再拘于长度了,其他的过程也还是一样的。 不过其在遍历的时候仍然需要把所有的文件描述符都给遍历,而不论是否真的有数据。这样一旦文件描述符过多,同样会使用户进程陷于内核态无法自拔。
epoll
上面两种类型的多路复用都面临两个问题:
- 文件描述符在用户空间和内核空间大量copy
- 大量没有数据的socket不停的被轮循是否有数据到来
这无形中加大了系统的开销,epoll是上面两种模型的一种增强,其本质是创建一个文件,将其他的socket对应的fd所关心的事件注册进来, 因此该文件相当于一个事件表,接收其他的socket的事件的到来,这个时候如果我们想要查看有哪些socket出现了我们期待的事件,只需要在用户空间和 内核空间中传递一个文件描述符就可以了,另外我们在拿到新生成的文件描述符后就可以查询文件描述符对应的文件内容, 从而将真正有数据的socket给取出来,而且不会因为有大量的socket而导致系统调用文件描述符拷贝带来的开销,也不会 全遍历文件描述符对应的socket,因此效率可以说是最高的。
epoll具体的调用流程如下:
1 | int epoll_create(int size); |
上面的第一个函数epoll_create就是用来创建一个文件,该文件就是一个事件表,接下来epoll_ctl会将
第一步创建得到的文件描述符epfd作为参数传进来,并将我们关心的文件fd的事件epoll_event注册进来,
当对应的fd有事件epoll_event到来的时候会通过系统的回调函数将其写到epfd对应的文件中。最后异步epoll_wait
则有点类似于select的作用,不过区别于select,这里选出来的socket是有数据的socket并非全量的socket。
在上面多路复用的几个系统调用函数中我们都看到了timeout这个参数,代表的是该系统调用的超时时间,0表示立即返回,这 种情况下有点类似于accept的非阻塞,-1表示永久阻塞,这有点类似于accpet的阻塞,为正则代表设定阻塞的超时时间。
异步IO
比较少见,暂不研究
JAVA NIO
上面介绍了一些理论性的东西,接下来看一下java中NIO相关的组成部分,java中的NIO涉及了磁盘文件和网络文件相关的类库,这里做特殊声明
buffer
buffer看名字也可以直到是缓冲区,buffer出现的原因是优化了stream(流)的吞吐量限制,通常情况下我们在读写文件的时候用到的比较多的 其实是stream,这里就不细说了,buffer的本质是一个数组的包装类,也就是说我们进行数据读写的时候操作的是buffer中的数组,只不过buffer提供了 更强大的API。buffer涉及了几个比较核心并且晦涩的成员变量:
- position:第一个可以读写的位置
- limit:第一个不可以读写的位置
- capacity:buffer的容量
- mark:缓存position的位置用于重复读、写的操作
上面介绍了一下buffer中的几个变量,这几个变量在使用的过程中对应了buffer的读、写两种模式,如下图所示:

在写模式中,position代表了下一个可写的位置,limit为capacity-1(数组的最末位,也就是第一个不可写的位置), 在读模式中,position代表了第一个可以读的位置,limit则代表了第一个不可读的位置。buffer由读模式切换为写模式的时候 需要调用flip方法翻转一下,由写模式切换为读模式的时候需要调用clear(清空缓冲区,其实是将上述成员变量复位)或者compact (区别于clear方法,该方法在切换为写模式的时候,会将还没有来的及读的数据拷贝到数组的首端,position放到第一个可写的位置)方法。 mark在调用了mark方法的时候会记录当前的position的位置,后续可以使用reset方法来将position复位到mark所指定的标记位。
channel
channel类似于文件句柄,不管这个文件是网络文件还是磁盘文件,我们拿到句柄之后就可以对文件进行读写操作,读写的时候需要使用上面提到的buffer来存放数据
- filechannel:对于filechannel并没有太多好讲的,只是有一点需要明确一下其是阻塞的,没有所谓的非阻塞一说,因此也就用不到下面的selector了
- socketchannel&serversocketchannel:socketchannel用来表示服务端与客户端建立的链接,serversocketchannel用于监听特定的端口来接收客户端的请求,区别于 filechannel,这两种类型的channel可以设置阻塞、非阻塞的模式。
selector
selector是多路复用器,针对前面的socketchannel、serversocketchannel的非阻塞模式会特别有用,其会监听注册到该复用器上的channel的事件(该事件 是通过socket对应的buff的回调函数写入的)。我们可以直接通过select来查看已经就绪的channel,最后通过一个示例来演示一下几个模块的组合:
1 | import java.io.IOException; |
小结
多路复用IO解决的问题是连接数比较多,但是大量连接都没有数据的情况,非阻塞IO解决的是服务端由于阻塞导致线程资源耗尽,进程僵死的问题, 因此并不能说哪种方案是最优的,具体场景具体分析吧。而且并不是NIO或者多路复用就是最优的方案, 当真的有很多连接都处于活跃状态,阻塞IO可能会更好一点,毕竟只需要一次系统调用。