Java对数据的操作是通过流的方式,学好IO流可以方便地实现数据的输入/输出操作,其重要性不言而喻。
以下是本篇文章的思维导图
为了保护操作系统的安全,会将内存分为内核空间和用户空间。用户进程想要操作数据,必须通过系统调用(System Call)向内核(Kernel)发出指令,将数据从内核空间复制到用户空间,才能操作数据。
以Linux操作系统为例,Linux是一个将所有的外部设备视为文件来操作的的系统。那么我们对外部设备的操作可看作是对文件的操作,由于LINUX中进程无法直接操作I/O设备,其必须通过系统调用,请求内核(kernel)来协助完成I/O动作, 内核会为每个I/O设备维护一个缓冲区。
对于一个输入操作来说,用户进程在系统调用后,内核会先看缓冲区中有没有相应的缓存数据,没有的话再到设备中读取,因为设备IO一般速度较慢,需要等待;内核缓冲区有数据则直接复制到进程空间。
一个完整的IO分为两个阶段:
- 读取 设备空间(网络、磁盘等)的数据
到
内核空间的缓冲区- 复制 内核空间的缓冲区的数据
到
用户进程空间
java的io是实现输入和输出的基础,可以方便的实现数据的输入和输出操作。
在java中,把不同的输入/输出源(键盘,文件,网络连接等)抽象表述为“流”(stream)。通过流的形式,允许java程序使用相同的方式来访问不同的输入/输出源
。流是一种有顺序的,有起点和终点的字节集合,是对数据传输的总称或抽象。即数据在两个设备间的传输成为流,流的本质是数据传输
。
IO按存储形式分为内存IO、网络IO和磁盘IO三种,我们通常所说的IO指的是后两者,网络IO就是通过网络进行数据的拉去和输出。磁盘IO主要是对磁盘进行读写工作。
● 按流方向:输入流、输出流;
● 按数据类型:字节流(8位字节)、字符流(16位字节)。
字符流和字节流的区别:
- 字节流本身没有缓冲区,字符流本身带有缓冲区,所以缓冲字节流相比缓冲字符流,效率提升非常明显
- 字节流可以处理一切文件,而字符流只能处理纯文本文件。
以下是常见流的分类表:
同步和异步关注的是调用方和被调用方的交互
,本质是通讯机制。
同步:调用方发起调用后,等待或者轮询的去查看被调用方是否就绪
异步:调用方发起调用后,便开始做自己的事情,当被调用方完成时,会得到IO 完成的通知
阻塞和非阻塞关注的是调用者在等待调用结果时的状态
,本质是描述当前线程状态。
阻塞:调用方发起调用后,一直等待,
线程会被挂起(不能去做其他事情)
。
非阻塞:调用方发起调用后,会立即收到一个状态值,该线程去做其他事情。
同步异步是相对于客户端来说,而阻塞非阻塞是相对于服务端来说。因此,异步可以提升客户端的体验,非阻塞可以避免服务端创建新的线程(或进程),减少了CPU资源的消耗。
因为IO分为两个阶段,期间都需要等待和数据的复制,这大大的限制了IO的执行效率,如何才能解决这个性能瓶颈呢?
1、降低IO中等待的比重
2、避免数据的复制
为了解决IO效率,提出了五种IO模型
用户进程调用内核进程后,需要等待内核IO彻底操作完,才能返回用户空间。如下图:
用户进程发起recvfrom后,进入阻塞状态
内核进程收到recvfrom后,就开始准备数据,等待数据到达内核缓冲区
内核进程收到完整数据后,就会将数据从内核缓冲区复制到用户空间的内存,然后返回结果(如:复制数据字节数)
用户进程收到内核返回结果后,才解除阻塞状态,继续运行。
recvfrom: linu系统调用函数。
优点:
开发简单,用户态进程阻塞时,不会占用CPU资源
缺点:
在高并发场景下,需要大量的线程来维护大量的网络连接,内存和线程切换开销大,性能低。
用户进程调用内核进程后,不用等内核IO操作彻底完成,即可执行后续的指令。如下图:
用户进程发起recvfrom后,内核进程没有准备好数据,直接返回EWOULDBLOCK错误
用户进程会执行其他任务,期间用户进程会不断发起recvfrom,如果收到错误说明还没准备好
内核进程收到完整数据后,用户进程发起recvfrom后会被阻塞,直到数据从内核缓冲区复制到用户空间
用户进程收到内核返回结果后,才开始处理接收到的数据。
优点:
用户态进程调用内核态进程后,不需要阻塞,实时性好
缺点:
用户态进程需要不断轮询内核态进程,大量占用CPU资源,浪费CPU资源。假如1000个线程,单位时间内会有1000次系统调用去轮询执行结果。
轮询时间不好控制
轮询需要程序员实现循环读取文件描述符,代码编写复杂
Select是内核提供的系统调用,支持一次查询多个系统调用的可用状态,当任意一个结果状态为可用时就返回
,用户进程再次发起一次系统调用进行数据读取。
在多路复用IO模型中,多个进程的IO可以注册到一个复用器(select)上,然后会有一个线程不断去轮询多个socket的状态,只有当socket真正有读写事件时,才真正调用实际的IO读写操作。因为在多路复用IO模型中,只需要使用一个线程就可以管理多个socket,系统不需要建立新的进程或者线程
,也不必维护这些线程和进程,并且只有在真正有socket读写事件进行时,才会使用IO资源,所以它大大减少了资源占用。
用户进程调用内核select,将发起recvfrom的socket连接注册到Linux的select/poll选择器中,内核开启轮询流程。
内核空间内,遍历所有的文件描述符, 将准备好数据的socket连接加入到就绪列表。
用户进程轮询选择器上注册的就绪socket列表有值时,用户进程发起recvfrom并阻塞,复制数据到用户缓冲区。
用户进程复制完后,内核返回结果,用户进程解除阻塞状态,开始处理用户空间的数据,继续执行后续指令。
优点:
一个选择器线程,可以同时处理大量网络请求,不必创建大量线程,减轻了系统开销
缺点:
select底层采用数组实现,存在连接数限制,因此poll应运而生,其底层采用链表实现,解决了连接数限制
select与poll解决了NIO重复无效系统调用问题,却引来新的问题:
- 用户空间和内核空间之间,文件描述符集合拷贝
内核循环遍历文件描述符集合,浪费CPU时间
select/poll 虽然减少了用户进程发起的系统调度,但内核工作量只增不减,内核存在无效的循环遍历
通过事件驱动,减少select/poll引入的内循环遍历,如下图:
epoll相较于select/poll,多了两次系统调用,其中epoll_create 建立与内核的连接,epoll_ctl注册事件,epoll_wait阻塞用户进程,等待IO事件。
用户进程调用内核epoll_create,创建一个eventpoll对象,再通过内核epoll_ctl,将需要监听的socket放入eventPoll对象的rbr(红黑树)中,同时会给内核中断处理程序注册一个回调函数。(如果这个句柄处理程序中断到了,会把它放入rdlist(就绪链表)里)
socket接收到数据,会对CPU发起硬中断,内核执行回调函数,把socket的数据写入rdlist(就绪链表)里,当执行内核epoll_await时,立即返回准备就绪链表里的数据。
用户进程取链表中fd做实际的read/write操作
优点:
一个选择器线程,可以同时处理大量网络请求,不必创建大量线程,减轻了系统开销
通过共享空间,避免了数据的来回拷贝
缺点:
epoll,提升了IO执行效率,但是IO执行的第一阶段,数据准备阶段还是处于阻塞状态。
信号驱动IO,在数据的准备阶段不会阻塞用户进程,内核将数据准备好后,发送SIGIO信号通知用户进程进行IO操作。
用户进程发起sigaction系统调用,内核进程立即返回,用户进程继续执行
内核进程准备好数据后,会发给用户进程一个SIGIO信号
用户进程收到信号后,会发起recvfrom系统调用
用户进程进入阻塞状态,并复制数据到用户空间缓冲区,复制完后,处理接收到的数据
优点:
数据准备阶段,不会阻塞用户进程
Netty支持NIO,开发简单实用
缺点:
IO执行第二阶段【复制数据到用户空间缓冲区】时,用户进程被阻塞
异步IO真正的实现了IO全流程非阻塞,即用户进程(线程)在等待数据报和数据报从内核拷贝到用户空间这两阶段都是非阻塞的。其流程如下图:
用户进程发起sigaction系统调用后,内核进程立即返回一个状态值,用户进程去做其他事情。
内核进程把接收的数据复制到
用户空间
后,再去通知用户进程用户进程接收到通知后,直接去处理用户空间接收的数据。
类似java的回调模式,用户进程向内核空间注册了各种IO事件的回调函数,由内核主动调用
优点:
完全异步
缺点:
Linux对AIO性能不比NIO有优势,实现并不成熟
以上就是今天要讲的内容,本文从操作系统进行文件读写入手,对同步、异步、阻塞、非阻塞以及它们组合而成的IO模式进行了介绍,还介绍了Linux操作系统中的五种IO模型,相信大家应该有了一定的了解。如有不恰当的地方,还请雅正。
参考资料:
1.要问技术多NB,请问IO模型知多少?
2.Java IO流详解
3.Java中五中IO模型详解