io和nio的原理,以及io到nio转化的原因

2019-12-17 09:50栏目:编程
TAG: java

我们都知道io为是阻塞的,nio为非阻塞的,但是这么理解太过于片面,因为这个东西太过于泛化,没有意义。
其实io的阻塞也分为类型,分为连接阻塞和通信阻塞,这么说也太过于抽象,我们先画图说明,然后以实际的代码来进行深入理解。

java io与nio

通过上图,我们得知阻塞io的连接和通信过程,接下来我们通过代码来验证上图的过程:
首先,我们创建一个服务端IOServerTest类:
nio与io的区别

接着,我们创建一个io客户端IOClientTest类:

1.启动服务端的进程,结果如下:
io与nio的原理及差异
这里我们验证了第一个过程,accept()监听客户端的连接,无连接到来时陷入阻塞(因为未打印客户端连接成功);

2. 启动客户端的进程,建立于服务端的连接,客户端进程console结果如下:
传统io和nio的区别

此时,我没有输入任何东西,我们来看一下服务端的console输出结果:

此时我们发现,客户端和服务端建立了连接,但是服务端居然阻塞了,阻塞的原因是read方法,这里我们验证了read方法会一只等待数据的到来,如果没有,则陷入阻塞(这里的底层原理主要是因为java调用read方法会发起一个系统调用,内部通过JNI去调用read0方法(read0方法使用c或c++写的,用户去调用系统的read函数),会去询问内核是否有数据到来,此时内核在监听客户端的数据(处于阻塞),当有数据到来时,此时操作系统内核的read函数解阻塞,将数据读取到内核空间,然后通知用户空间,数据已经准备就绪,然后会将内核空间的数据拷贝到用户空间(这里又涉及到了线程的上下文切换,后续会以博客的方式来讲解我对上下文切换的理解),然后java程序获取到了客户端的数据,代码接着向下运行)。
客户端输入内容:


服务端console输出内容:
 
这便是阻塞io的整体流程,那么阻塞io的缺点是啥呢,我们现在已经知道,io阻塞分为连接阻塞和通信阻塞(读写), 此时我们模拟一个场景,客户端A与服务端S建立了连接,但是客户端A不发送任何数据到服务端S,此时服务端S在read方法中陷入阻塞(此时是线程从用户态陷入到了内核态),在这种情况下,客户端B与服务端S建立连接,这里的连接是建立不上的,因为服务端的主线程在read方法中阻塞了,无法调用accept方法来监听客户端的连接。只有当客户端A发送了数据,服务端才会解阻塞,来监听客户端的连接。

这便是阻塞io最大的弊病,既然有问题那么我们肯定是有方式去解决的,第一种方案便是采用线程池来调度,这是我们所有人都能理解的,主线程只负责监听客户端的连接,连接成功后,read和write操作完全交由线程池去调度,本身的主线程只对监听进行阻塞和调度。
我们来看代码:


我们只修改了服务端的代码,将服务端的读写交由线程池进行管理,实现伪异步,我们启动服务端,同时启动三个客户端,
服务端console输出内容如下:
我们发现3个客户端都连接成功了,只是3个客户端未发送数据,意味着线程池中的3个线程陷入read阻塞,此时暂时解决了我们阻塞io的瓶颈问题。

那么,我们此时来分析一下我们用这种伪异步的方式有什么缺点:

1. 虽然使用了线程池,但是线程池创建最大线程数是有限制的,而且实际场景中,100个客户端建立了连接,意味着线程池要创建100个线程来同时处理客户端请求数据,那么此时可能只有20个线程实际发送了数据,剩余80个只是连接,不发送任何数据,此时会造成系统内存的巨大消耗,而且随着线程数的增多,线程上下文切换会导致系统性能急剧下降。

2. 如果线程数达到了线程池的最大线程数时,以及线程池的任务队列已满,那么接下来的客户端连接便会被抛弃或者抛出异常(这里主要是看线程池的拒绝策略采用哪一种,我们代码中采用的是抛出错误异常的策略),那么会造成客户端发送数据

3. 线程池对线程池的创建和销毁是很耗性能的,而且对于长连接而言,线程池的作用和每个客户端到来创建一个线程的差异并不大。

我们发现了一个问题,只要是服务端对客户端的监听是阻塞的, 服务端对等待客户端的read()是阻塞的,那么采用任何方式来优化都无法达到高并发。我们就会想,如果accept 和read都不是非阻塞的那么问题不久解决了吗,此时nio应运而生。nio又称为non-blocking-io,非阻塞io,他的非阻塞io的意义在于他对服务端监听客户端的连接是可以设置成非阻塞的,等待客户端的数据,也就是read,write()也可以设置成非阻塞的。(我们暂时先不考虑Selector),我们来看一下代码:

其次我们创建客户端NIOClientTest类(这里需要说明一下,这个客户端也可以采用前面所写的IOClientTest类,这里主要是因为用到了nio,所以希望保持一致):

ok,代码编写完毕,我们首先启动服务端,服务端console输出内容如下:

因为此时我们并没有启动客户端,但是这里的accept方法是非阻塞的,所以会一直打印暂无连接;
ok ,我们此时打开客户端连接,客户端连接成功后,服务器端的console输出内容如下:

 
查看结果我们发现连接建立成功了,但是客户端未发送任何数据,所以在read之后,读到的字节个数为0,所以数据了未获取到客户端的请求数据,我们发现这都是非阻塞的,这就解决了我们io阻塞的缺点。
此时我们在客户端输入内容:
我们来看服务端的console输出内容:

 
一直输出暂无连接,在这里不知道大家有没有发现问题,因为客户端与服务端建立了连接后,因为都是非阻塞的,服务端对客户端的连接建立成功后,服务端走完了此客户端连接后的业务代码,然后接着轮询查看是否有连接,意思就是服务端并未保存与客户端建立连接后的socketChannel。这样会造成数据丢失,无法实时的查看客户端是否有数据过来,那么我们如何解决呢?

我们是不是立马想到了用list集合来存储,然后每次遍历这个集合中的socketChannel中是否有数据到来,如果有,则在控制台数据,没有则跳过。OK,我们开始来改造我们的服务端代码:


此时我们在客户端中输入hello,我们来查看服务端console的输出内容:

我们发现,成功的打印了出来,这里我们采用自定义的List集合来实现了对socketChannel的轮询,这里其实就是一个自旋。我们这里采用了一个线程来处理多客户端连接的问题。

但是我们再来看看这里面有什么问题,我们知道List集合是在我们的JVM中分配内存的,如果此时有1000个客户端与服务器进行长连接,我们list集合中便会有1000个数据集,每次轮询都会遍历所有的数据集,然后其中可能只有100个向服务器发送了数据,意味着我们浪费了900的数据集的遍历,我们是否对其进行优化,做一个标识,如果存在读的情况,那我就遍历它将它输出,没有就不遍历,这里就不演示了,有小伙伴自己去尝试吧。

但是如果有10000个客户端呢,100000个客户端呢,我们JVM是不是会出现内存溢出,造成系统崩溃,那我们来看看nio是如何实现的,nio中有一个Selector,我们将自身的socketChannel注册到Selector中,Selector会自动帮我们遍历,如果存在读的情况,他会返回一个SelectionKey,然后我们可以通过此对象来获取socketChannel进行一系列的读写操作,那么Selector又是如何实现的呢,这里有涉及到了linux底层中的select, poll, 和epoll的多路复用模式。

本文来自网络,不代表山斋月平台立场,转载请注明出处: https://www.shanzhaiyue.top