想理解Java的IO,不要从操作系统开始说起的都是耍流氓...
本文来自作者投稿,原作者:N.Y
前言
在上一篇文章中,我们了解流的概念以及JavaIO流的基本用法,但JavaIO流的演化不仅是如此简单,有心的读者会发现,在JDK1.4之前的IO类都是基于阻塞的IO(可以从InputStream.read()方法实现中看到由synchronized修饰的代码块),发展到JDK1.4之后NIO提供了selector多路复用的机制以及channel和buffer,再到JDK1.7的NIO升级提供了真正的异步api......
Java网络IO涵盖的知识体系很广泛,本文将简单介绍Java网络IO的相关知识:
(若文章有不正之处,或难以理解的地方,请多多谅解,欢迎指正)
从操作系统开始
为了保护操作系统的安全,会将内存分为用户空间和内核空间两个部分。如果用户想要操作内核空间的数据,则需要把数据从内核空间拷贝到用户空间。
举个栗子,如果服务器收到了从客户端过来的请求,并且想要进行处理,那么需要经过这几个步骤:
- 服务器的网络驱动接受到消息之后,向内核申请空间,并在收到完整的数据包(这个过程会产生延时,因为有可能是通过分组传送过来的)后,将其复制到内核空间;
- 数据从内核空间拷贝到用户空间;
- 用户程序进行处理。

因此我们可以将服务器接收消息理解为两个阶段:
- 等待数据到达
- 将数据从内核空间拷贝到用户空间
在操作系统中的IO
在此以Linux操作系统为例。Linux是一个将所有的外部设备都看作是文件来操作的操作系统,在它看来:everything is a file,那么我们就把对与外部设备的操作都看作是对文件进行操作。而且我们对一个文件进行读写,都需要通过调用内核提供的系统调用。
而在Linux中,一个基本的IO会涉及到两个系统对象:一个是调用这个IO的进程对象(用户进程),另一个是系统内核。也就是说,当一个read操作发生时,将会经历这些阶段:
- 通过read系统调用,向内核发送读请求;
- 内核向硬件发送读指令,并等待读就绪;
- DMA把将要读取的数据复制到指定的内核缓存区中;
- 内核将数据从内核缓存区拷贝到用户进程空间中。

在此期间会发生几种IO操作:
- 同步IO:当用户发出IO请求操作后,内核会去查看要读取的数据是否就绪,如果没有,就一直等待。期间用户线程或内存会不断地轮询数据是否就绪。当数据就绪时,再把数据从内核拷贝到用户空间。
- 异步IO:用户线程只需发出IO请求和接收IO操作完成通知,期间的IO操作由内核自动完成,并发送通知告知用户线程IO操作已经完成。也就是说,在异步IO中,并不会对用户线程产生任何阻塞。
- 阻塞IO:当用户线程发起一个IO请求操作,而内核要操作的数据还没就绪,则当前线程被挂起,阻塞等待结果返回。
- 非阻塞IO:如果数据没有就绪,就会返回一个标志信息告知用户线程,当前的数据还没有就绪。当前线程在获得此次请求结果的过程中,还可以做点其他事情。
可能会有读者觉得,怎么同步IO、异步IO和阻塞IO、非阻塞IO的操作好相似,为什么要它们都分出来呢?笔者认为,这同步、异步和阻塞、非阻塞是从不同角度来看待问题的。
同步与异步
同步与异步主要是从消息通知的角度来说的。
同步就是当一个任务A的完成需要依赖另一个任务B时,只有等到B任务完成后,A才能成功地进行,这是一种可靠的任务队列。要么都成功,要么都失败,两个任务的状态可以保持一致。
异步是不需要等待任务B完成,只是通知任务B要完成什么工作,任务A也立即执行,只要任务A自己执行完了那么整个任务就算完成了。至于任务B最终是否真正完成,A任务无法确定,所以这是不可靠的一种任务队列。
举个栗子,假如小J要去银行柜台办事,拿号排队。如果他只盯着号码提示牌,还时不时问是否到他了,这就是同步;如果他拿了号之后就去打电话了,等到排到他的时候柜员通知他去办理业务,这就是异步。他们之间的区别就在于,等待消息通知的方式不同。
阻塞与非阻塞
阻塞与非阻塞主要是从等待消息通知时的状态角度来说的。
阻塞就是指在调用结果返回之前,当前线程会被挂起,一直处于等待消息通知的状态,不能执行其他业务。只有当调用结果返回之后才能进行其他操作。
非阻塞与阻塞的概念相对应,就是指不能立即得到结果之前,该函数不会阻塞当前线程,而是会立即返回。虽然非阻塞的方式看上去可以明显提高CPU的利用率,但是也会使系统的线程切换增加,需要好好评估增加的CPU执行时间能不能步长系统的切换成本。
我们继续用上面的栗子,小J无论是在排队还是拿号等通知,如果在这个等待的过程中,小J除了等待消息通知之外就做不了其他的事情,那么该机制就是阻塞的。如果他可以一边打电话一边等待,这个状态就是非阻塞的。
同步、异步与阻塞、非阻塞
其实可能会有其他读者把同步与阻塞等同起来,实际上这两个是不同的。对于同步来说,很多时候当前线程还是在激活状态,只是逻辑上当前函数没有返回而已,此时,线程也会去处理其他的消息。也就是说,同步、阻塞其实是在消息通知机制下从不同角度对当前线程状态的描述。
5.1 同步阻塞形式
这是效率最低的一种方式,拿上面的栗子来说,就是小J心无旁骛地排队,什么别的事都不做。
在这里,同步与阻塞体现在:
- 同步:小J等待队伍排到他办理业务;
- 阻塞:小J在等待队伍排到他的过程中,不做其他任务处理。
5.2 异步阻塞形式
如果小J在银行等待办理业务的时候,领了号,这时候就采用了异步的方式去等待消息被触发(通知),等着柜员喊他的号而不是时刻盯着是不是排到他了。但是在这段时间里,他还是不能离开银行去做其他的事情,那么很显然,他被阻塞在这个等待喊号的操作上了。
在这里,异步与阻塞体现在:
- 异步:排到小J的话柜员会喊他的号码;
- 阻塞:等待喊号的过程中,不能做其他事情。
5.3 同步非阻塞形式
实际上效率也是低下。小J在排队的过程中可以打电话,但是要边打电话边看看还有多久才排到他。如果将打电话和观察排队情况看成是程序中的两个操作的话,这个程序需要在这两个不同的行为之间来回切换。
在这里,同步与非阻塞体现在:
- 同步:排队等待轮到他办理业务;
- 非阻塞:可以在排队的过程中打电话,只不过要时不时看看还要多久才排到他办理业务。
5.4 异步非阻塞形式
这是一个效率更高的模式。小J在拿号之后可以去打电话,只要等待柜员喊号就可以了,在这里打电话是等待者的事情,而通知小J办理业务是柜员的事情。
在这里,异步和非阻塞体现在:
- 异步:柜员喊小J去办理业务;
- 非阻塞:在等待喊号的过程中,小J去打电话,只要接收到柜员喊号的通知即可,无需关注是否队伍的进度。
也就是说,同步和异步仅需关注消息如何通知的机制,而阻塞和非阻塞关注的是在等待消息通知的过程中能不能去做别的事。在同步情况下,是由处理者自己去等待消息是否被触发,而异步情况下是由触发机制来通知处理者处理业务。
Linux的五种IO模型
在我们了解Linux操作系统的IO操作,以及同步与异步、阻塞与非阻塞的概念之后,我们来看看Linux系统中根据同步、异步、阻塞、非阻塞实现的五种IO模型。以Linux下的系统调用recv为例,是一个用于从套接字上接收一个消息,因为是系统调用,所以在调用的时候,会从用户空间切换到内核空间运行一段时间后,再切换回来。在默认情况下recv会等到网络数据到达并复制到用户空间或发生错误时返回。
6.1 同步阻塞IO模型
从系统调用recv到将数据从内核复制到用户空间并返回,在这段时间内进程始终阻塞。就相当于,小J想去柜台办理业务,如果柜台业务繁忙,他也要排队,直到排到他办理完业务,才能去做别的事。显然,这个IO模型是同步且阻塞的。

6.2 同步非阻塞IO模型
在这里recv不管有没有获得到数据都返回,如果没有数据的话就过段时间再调用recv看看,如此循环。就像是小J来柜台办理业务,发现柜员休息,他离开了,过一会又过来看看营业了没,直到终于碰到柜员营业了,这才办理了业务。而小J在中间离开的时间,可以做他自己的事情。但是这个模型只有在检查无数据的时候是非阻塞的,在数据到达的时候依然要等待复制数据到用户空间(办理业务),因此它还是同步IO。

6.3 IO复用模型
在IO复用模型中,调用recv之前会先调用select或poll,这两个系统调用都可以在内核准备好数据(网络数据已经到达内核了)时告知用户进程,它准备好了,这时候再调用recv时是一定有数据的。因此在这一模型中,进程阻塞于select或poll,而没有阻塞在recv上。就相当于,小J来银行办理业务,大堂经理告诉他现在所有柜台都有人在办理业务,等有空位再告诉他。于是小J就等啊等(select或poll调用中),过了一会儿大堂经理告诉他有柜台空出来可以办理业务了,但是具体是几号柜台,你自己找下吧,于是小J就只能挨个柜台地找。

6.4 信号驱动IO模型
此处会通过调用sigaction注册信号函数,在内核数据准备好的时候系统就中断当前程序,执行信号函数(在这里调用recv)。相当于,小J让大堂经理在柜台有空位的时候通知他(注册信号函数),等没多久大堂经理通知他,因为他是银行的VIPPP会员,所以专门给他开了一个柜台来办理业务,小J就去特席柜台办理业务了。但即使在等待的过程中是非阻塞的,但在办理业务的过程中依然是同步的。

6.5 异步IO模型
调用aio_read令内核把数据准备好,并且复制到用户进程空间后执行事先指定好的函数。就像是,小J交代大堂经理把业务给办理好了就通知他来验收,在这个过程中小J可以去做自己的事情。这就是真正的异步IO。

我们可以看到,前四种模型都是属于同步IO,因为在内核数据复制到用户空间的这一过程都是阻塞的。而最后一种异步IO,通过将IO操作交给操作系统处理,当前进程不关心具体IO的实现,后来再通过回调函数,或信号量通知当前进程直接对IO返回结果进行处理。
BIO、NIO、AIO的区别
上文谈到IO的四种模式:同步阻塞IO、同步非阻塞IO、异步阻塞IO、异步非阻塞IO,在JavaIO中提供了三种模式的实现:BIO(同步阻塞IO)、NIO(同步非阻塞IO)、AIO(异步非阻塞IO)。至于这四种模式之间的区别,上文已经有较为详细的介绍了,接下来笔者将对这三种JavaIO类型之间的区别进行介绍。
- BIO:同步并阻塞,在服务器中实现的模式为一个连接一个线程。也就是说,客户端有连接请求的时候,服务器就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然这也可以通过线程池机制改善。BIO一般适用于连接数目小且固定的架构,这种方式对于服务器资源要求比较高,而且并发局限于应用中,是JDK1.4之前的唯一选择,但好在程序直观简单,易理解。
- NIO:同步并非阻塞,在服务器中实现的模式为一个请求一个线程,也就是说,客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到有连接IO请求时才会启动一个线程进行处理。NIO一般适用于连接数目多且连接比较短(轻操作)的架构,并发局限于应用中,编程比较复杂,从JDK1.4开始支持。
- AIO:异步并非阻塞,在服务器中实现的模式为一个有效请求一个线程,也就是说,客户端的IO请求都是通过操作系统先完成之后,再通知服务器应用去启动线程进行处理。AIO一般适用于连接数目多且连接比较长(重操作)的架构,充分调用操作系统参与并发操作,编程比较复杂,从JDK1.7开始支持。
结语
本文从操作系统进行文件读写入手,对同步、异步、阻塞、非阻塞以及它们组合而成的IO模式进行了介绍,还了解Linux操作系统中的五种IO模型,以及重新回到JavaIO,看待BIO、NIO、AIO之间的区别。
如果本文对你有帮助,请给一个赞吧,这会是我最大的动力~
参考资料:
https://www.cnblogs.com/felixzh/p/10345929.html