Java NIO预备知识:I/O底层原理与网络I/O模型
1.操作系统内存划分与缓存机制
首先,操作系统将虚拟内存(对物理内存的映射)划分为用户空间和内核空间,用户空间分配给应用程序,而系统的核心功能在内核空间中运行。这样的分隔可以保证用户程序出现问题(比如软件崩溃)不会影响到操作系统。
无论是使用Java传统的流I/O还是NIO,在传输数据时,都不会直接将程序的用户空间中的数据与另一端(如硬盘、网卡)进行直接交互而是要经过系统内核空间,且只能由操作系统内核代码完成。而用户进程与线程(对于多线程程序与操作系统,处理一个程序中任务的单位可以是一个线程,下文皆使用线程作为单位书写)工作在用户空间,每次进行I/O操作时需要将当前线程的状态从用户态切换为内核态以进行系统调用,去调用内核代码完成读写。此时该线程处于阻塞状态,内核需要将该线程的上下文信息进行保存,以便读写完成后恢复现场继续执行线程的任务。
那么,在用户进程进行高频率的I/O时将会进行大量的线程状态转换,这样的带来的时间空间代价相对来说是比较大的。所以操作系统在内核空间中设置缓存(cache)区域,每次进行读写时,可以先将数据读到缓存中,待到缓存写满时在进行一次I/O操作将数据传输给用户空间(读操作)或网卡、硬盘(写操作),这样就会减少系统调用带来的线程状态转换开销。
同样的,用户空间也可以设置相应的缓冲区(Buffer)加速I/O操作。
缓冲区或缓存的数据会在写满时或根据相应的刷新机制进行写出,可能会写出到相应应用程序的地址空间(内存)或硬盘、网卡中。
2.四种网络I/O模型
Socket:源IP地址和目的IP地址以及源端口号和目的端口号的组合称为套接字Socket,Socket也可以直译为“插座、插口”的意思。其实,可以把Socket想象成在服务器和客户端的程序开的插口,在创建Socket时指定网络层(IPV4/6)、传输层协议(TCP/UDP)。那么,如果两端的插口匹配,就可以通过网络进行通信了。对于想要进行网络通讯任何一端来说,只需向这个“插口”中写入数据或从“插口”中读取数据就可以。
网络I/O主要关心的是对Socket中数据的读取,Socket在Linux和Java中默认都是被作为流来处理,并且默认创建的Scoket都是阻塞的。对于Socket数据的读取主要分为两个阶段,分别是:
1.等待内核数据:此时内核会等待网络中数据的到达,通常网络中的数据将会分组到达,这些数据将会被存储在内核的缓存区域中。
2.将数据交给用户进程地址空间:内核数据准备完毕,开始向用户空间写入,此时将会由内核缓存向应用程序占有的地址空间写入数据。
Linux对于I/O划分了五种模型,其中四种常用的I/O模型为同步阻塞IO、同步非阻塞IO、多路复用IO以及异步IO,还有一种则是信号驱动IO。
这四种模型的区别主要是线程在进行I/O时所处阻塞状态有所不同或者内核与用户线程的调用方向不同。
2.1 同步阻塞 IO(blocking IO)
同步阻塞IO模型是最符合人们思路的一种模型,当线程请求网络数据时,会产生一个I/O请求,然后线程一直阻塞。此时内核在等待数据,数据到达后再把数据复制给用户线程。当用户线程接收到数据后阻塞结束。
因此,在两个阶段中,线程都会被阻塞。
处于阻塞状态的线程不再占用CPU且只消耗少量的资源。
2.2 同步非阻塞 IO(nonblocking IO)
同步非阻塞与同步阻塞不同的地方在于,发出I/O请求后无需等待内核数据准备完毕,会立即返回。若此时数据没有准备好(返回EAGAIN 或 EWOULDBLOCK),那么接下来需要再不断询问内核是否准备好数据,这个询问过程被称为轮询。当某次询问时,内核准备好了数据那么就会产生系统调用,由内核向程序地址空间写入数据。
每次进行询问内核到内核返回结果这个过程,线程是阻塞的。
当内核向用户空间写入数据时,线程是阻塞的。
在每次查询之后,线程是非阻塞的,所以在这个空隙,线程可以做一些自己的事情
同步非阻塞IO相对于同步阻塞IO的优缺点如下:
优点:不必全程阻塞,询问内核间隙可以完成其他任务
缺点:由于数据可能在两次查询之间就准备好了,所以会造成系统吞吐量降低。此外,轮询操作需要不断占用CPU时间,也会导致线程状态转换,对于高并发环境,这是不合适的。
2.3 IO多路复用(IO multiplexing)
显然,如果在多线程环境下使用同步非阻塞IO访问各自线程对应的Socket数据将会产生大量的轮询操作,并造成CPU时间与状态切换开销。对于这个问题,可以使用多路复用IO来解决。多路复用IO的本质就是使用单个线程就可以记录跟踪每个Socket的状态来同时管理多个Socket。
1.首先,将需要使用的所有Socket注册到多路复用可查询的Socket列表。
2.之后内核会分配一个线程跟踪这些Socket的状态,使用的方式即为轮询。
3.当本线程希望读取一个Socket的数据时,线程将会被请求函数阻塞,之后Socket中数据可用时多路复用系统返回可读条件。
4.得到可读条件后,线程进行读取数据,读取完毕后,线程阻塞解除。
其实,在上图中可以看到,如果想要读取一个Socket时,线程将会被阻塞,直至数据读取完毕。这和单线程同步阻塞IO是一样的。如果使用多线程加阻塞IO可以完成相同的任务,不适用轮询,延迟还会更低一些。只不过多路复用机制可以不必开出很多线程,对Socket的维护由单线程进行,减少了多连接时的开销,IO多路复用最大的优势就是系统开销小。
如果你使用的Socket是阻塞的,那么第一个请求的Socket没有数据到达时,整个线程都将被阻塞。所以,一般对于多路复用的Socket都是设置为非阻塞,然后在线程中使用一个轮询检查多个Socket数据的返回情况以读取数据。
UNIX/Linux的select、poll、epoll函数(epoll相当于poll函数的增强版),Java的NIO使用的都是多路复用技术。
2.4 异步非阻塞IO(asynchronous IO)
前几种方式都是用户线程不断去询问内核数据的准备情况,这些方式都是同步的。与同步相对应的就是异步IO,其含义是:当用户线程发送完IO请求之后就可以转而做其它任务,当内核准备好数据后,会由内核直接将数据复制到用户空间,复制完毕后通知线程。
在Linux中,通知是由信号(signal)完成的
缺点:异步IO需要大量的操作系统底层支持。
2.5 各种IO模型的比较:
本文参考文章链接:
聊聊Linux 五种IO模型
10分钟看懂, Java NIO 底层原理
Java IO 底层原理