BIO NIO AIO (NIO详解)


BIO

       Blocking IO: 同步阻塞的编程方式。
       在JDK1.4出来之前,我们建立网络连接的时候采用BIO模式,需要先在服务端启动一个ServerSocket,然后在客户端启动Socket来对服务端进行通信,默认情况下服务端需要对每个请求建立一堆线程等待请求,而客户端发送请求后,先咨询服务端是否有线程相应,如果没有则会一直等待或者遭到拒绝请求,如果有的话,客户端会线程会等待请求结束后才继续执行。
       且建立好的连接,在通讯过程中,是同步的。在并发处理效率上比较低。大致结构如下:
       同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。
BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。

NIO

一、基本知识

       Unblocking IO(New IO): 同步非阻塞的编程方式。
       NIO本身是基于事件驱动思想来完成的,其主要想解决的是BIO的大并发问题: 在使用同步I/O的网络应用中,如果要同时处理多个客户端请求,或是在客户端要同时和多个服务器进行通讯,就必须使用多线程来处理。也就是说,将每一个客户端请求分配给一个线程来单独处理。这样做虽然可以达到我们的要求,但同时又会带来另外一个问题。由于每创建一个线程,就要为这个线程分配一定的内存空间(也叫工作存储器),而且操作系统本身也对线程的总数有一定的限制。如果客户端的请求过多,服务端程序可能会因为不堪重负而拒绝客户端的请求,甚至服务器可能会因此而瘫痪。
       NIO基于Reactor,当socket有流可读或可写入socket时,操作系统会相应的通知引用程序进行处理,应用再将流读取到缓冲区或写入操作系统。 也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。
       BIO与NIO一个比较重要的不同,是我们使用BIO的时候往往会引入多线程,每个连接一个单独的线程;而NIO则是使用单线程或者只使用少量的多线程,每个连接共用一个线程。
       NIO的最重要的地方是当一个连接创建后,不需要对应一个线程,这个连接会被注册到多路复用器上面,所以所有的连接只需要一个线程就可以搞定,当这个线程中的多路复用器进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。
       在NIO的处理方式中,当一个请求来的话,开启线程进行处理,可能会等待后端应用的资源(JDBC连接等),其实这个线程就被阻塞了,当并发上来的话,还是会有BIO一样的问题。HTTP/1.1出现后,有了Http长连接,这样除了超时和指明特定关闭的http header外,这个链接是一直打开的状态的,这样在NIO处理中可以进一步的进化,在后端资源中可以实现资源池或者队列,当请求来的话,开启的线程把请求和请求数据传送给后端资源池或者队列里面就返回,并且在全局的地方保持住这个现场(哪个连接的哪个请求等),这样前面的线程还是可以去接受其他的请求,而后端的应用的处理只需要执行队列里面的就可以了,这样请求处理和后端应用是异步的.当后端处理完,到全局地方得到现场,产生响应,这个就实现了异步处理。

       NIO相关知识:buffer、channel、selector

二、NIO编程流程

1. NIO的服务端编程流程

1、实例化通道:ServerSocketChannel
2、绑定端口:通过 ServerSocketChannel调用bind()方法帮定端口
3、将ServerSocketChannel设置为非阻塞
4、实例化选择器(IO复用器)Selector
5、将ServerSocketChannel注册给选择器,并且关注accept事件
6、监听事件是否完成:selector.select(),如事件未完成则一直阻塞直至事件完成
7、获取已完成事件的集合并遍历,判断是否是accept事件事件,是:则进行accept()调用,获取SocketChannel
8、设置SocketChannel为非阻塞,并将SocketChannel注册到selector选择器,并关注read事件
9、监听事件是否完成,若有时间完成,则判断是否是read读事件
10、通过SocketChannel通道来读取数据(Buffer),读完数据循环值事件监听,即步骤6
11、关闭资源:SocketChannel、Selector、 ServerSocketChannel

2. NIO的客户端编程流程

1、实例化通道:SocketChannel
2、设置 SocketChannel为非阻塞
3、实例化复用器:Selector
4、连接服务端connect(),(该方法会直接返回不会阻塞,返回是Boolean,是否是连接成功)
5、若连接返回为false,则将SocketChannel注册到复用器中,并监听connect可连接事件
6、监听复用器中事件是否完成(Selector.select),若完成则判断完成集合中是否是可连接事件,将可连接事件完成(channel.finishConnect())
7、给服务端发送消息,channel.write()操作
8、关闭资源:selector、SocketChannel

三、相关知识

1. Channel

channel:通道

定义:

       channel用户IO操作的连接,由java.nio.channels包定义,原有IO的一种补充,但是不能直接访问数据
需要和缓冲区Buffer进行交互

       channel把数据读到buffer中,buffer把数据写入到channel中

举例说明:
   山西有煤,我们想要,于是建立一条铁路到山西,
   这条铁路就是这里的"Channel",那么煤通过什么运过来呢?铁路建好了,就差火车了,
   因此这里的火车就像是缓冲区"Buffer",火车把山西的煤运到这边来,把我们这里的钱运过去

       channel与流stream的区别:

        - channel不仅能读,也能写,stream通常是要么读要么写
        - channel可以同步也可以异步写
        - channel总是读取或写入一个Buffer中

主要的实现类:

  • FileChannel:用于读取、写入、映射和操作文件的通道
  • DatagramChannel:通过UDP读写网络中的数据通道
  • SocketChannel:通过TCP读写网络中的数据,一般是客户端的实现
  • ServerSocketChannel:监听新进来的TCP连接,对每一个连接创建一个SocketChannel,一般是服务端的实现

2. Buffer

Buffer(缓冲区)缓存是在java.nio包路径下提供丰富的处理类型:可以处理字符、double、float…类型的数据

buffer所需的空间开辟,有两种方式:

  • 在堆中开辟内存空间
  • 在堆外内存开辟内存空间:DirectXXX,在堆外空间开辟内存

构造函数:

ByteBuffer wrap(byte[] array)
ByteBuffer wrap(byte[] array,int offset, int length)
ByteBuffer allocateDirect(int capacity)
ByteBuffer allocate(int capacity)

常用的方法:

  • put() 写数据
  • get() 读数据
  • flip() 读写模式切换
  • remaining() Buffer中有效的数据长度
  • clear() 数据清除回复初始态
  • hasRemaining() 判断是否还有有效数据 返回Boolean true:有数据 false:无数据

Buffer中最主要的是指针移动:
mark <= position <= limit <= capacity

private int mark = -1;
private int position = 0;
private int limit;
private int capacity;

buffer的详细介绍

3. Selector:选择器

3.1 选择器的使用:

1、实例化选择器
Selector.open()

2、将通道channel注册到选择器上,并关注事件
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);

Channel必须是非阻塞的。
XXXChannel.register(selector, SelectionKey.OP_ACCEPT);
SelectionKey register(Selector sel, int ops, Object att)

3、监听事件是否完成
selector.select()

4、获取感兴趣事件的集合(获取已选择键的集合)
selector.selectedKeys()

3.2 Seletor选择器中重要类之SelectionKey:

SelectorKey介绍
这四种事件用SelectionKey的四个常量来表示:

  • SelectionKey.OP_CONNECT
  • SelectionKey.OP_ACCEPT
  • SelectionKey.OP_READ
  • SelectionKey.OP_WRITE

一个SelectorKey键表示一个特定的通道对象和一个特定的选择器对象直接的注册关系

key.attachment(); //返回SelectionKey的attachment,attachment可以在注册channel的时候指定。
key.channel(); // 返回该SelectionKey对应的channel。
key.selector(); // 返回该SelectionKey对应的Selector。
key.interestOps(); //返回代表需要Selector监控的IO操作的bit mask
key.readyOps(); // 返回一个bit mask,代表在相应channel上可以进行的IO操作。
key.interestOps():

以下方法来判断Selector是否对Channel的某种事件感兴趣

int interestSet = selectionKey.interestOps(); 
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;

从SelectionKey访问Channel和Selector很简单

Channel channel = key.channel();
  Selector selector = key.selector();
  key.attachment();

从Selector中选择channel(Selecting Channels via a Selector)

选择器维护注册过的通道的集合,并且这种注册关系都被封装在SelectionKey当中.

3.3 Selector维护的三种类型SelectionKey集合:

已注册的键的集合(Registered key set)
  所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。
  这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;
  试图这么做的话将引发java.lang.UnsupportedOperationException。
已选择的键的集合(Selected key set)
  所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。
  这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;
  试图这么做的话将引发java.lang.UnsupportedOperationException。
已取消的键的集合(Cancelled key set)
  已注册的键的集合的子集,这个集合包含了 cancel() 方法被调用过的键(这个键已经被无效化),
  但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。

注意:

  • 当键被取消( 可以通过isValid( )方法来判断)时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效。当再次调用 select( )方法时(或者一个正在进行的select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。通道会被注销,而新的SelectionKey将被返回
  • 当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。
  • 当选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取消)。
  • 一旦键被无效化,调用它的与选择相关的方法就将抛出CancelledKeyException。

3.4 select()方法介绍:

       在刚初始化的Selector对象中,这三个集合都是空的。 通过Selector的select()方法可以选择已经准备就绪的通道 (这些通道包含你感兴趣的的事件)。比如你对读就绪的通道感兴趣,那么select()方法就会返回读事件已经就绪的那些通道。下面是Selector几个重载的select()方法:

  • int select():阻塞到至少有一个通道在你注册的事件上就绪了。
  • int select(long timeout):和select()一样,但最长阻塞时间为timeout毫秒。
  • int selectNow():非阻塞,只要有通道就绪就立刻返回。

       select()方法返回的int值表示有多少通道已经就绪,是自上次调用select()方法后有多少通道变成就绪状态。之前在select()调用时进入就绪的通道不会在本次调用中被记入,而在前一次select()调用进入就绪但现在已经不在处于就绪的通道也不会被记入。
       例如:首次调用select()方法,如果有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。一旦调用select()方法,并且返回值不为0时,则 可以通过调用Selector的selectedKeys()方法来访问已选择键集合

3.5 selector过程

  • 已取消的键的集合将被检查。如果非空,每个被取消的键的集合将从其他两个集合中移除,并且相关通道将被注销,该步骤后,已取消集合将是空的

  • 已注册的键的集合汇总的键的interest集合将被检查,该步骤的检查执行后,对interest集合的改动不会影响剩余的检查过程, 一旦就绪事件被确定下来了,底层操作系统将会被查询,来确定底层操作系统真是就绪状态,依赖特定的select()方法调用,如果没有通道准备好,线程将被阻塞在这里,通常会有超时值,直到操作系统调用完成为止,这个过程可能会使调用线程睡眠一段时间,然后当前每个通道的就绪时间将被确定下来,对于那些没有准备好的通道将不执行任何操作,对于那些系统指示至少已经准备好interest集合中的一种操作的通道,将执行以下两种操作中的一种:
    a.通道的键还没有处于已选择的键的集合中,那么键的ready集合将被清空,然后表示操作系统发现的当前通道已经准备好的操作系统的比特掩码将被设置
    b.键在已选择的键的集合中,键的ready集合将被表示操作系统发现的当前已经准备好的操作系统的比特掩码更新。所以之前的已经不再是就绪状态的操作不会被清除,事实上,所有的比特为位都不会被清除,有操作系统决定的ready集合是与之前的ready集合按位分离的,一旦键被防放置与选择器的已选择的键的集合中,他的ready集合将是积累的,比特位只会被设置,不会被清理

  • 步骤2可能耗费较长,特别激发的线程处于休眠状态是,与该选择器相关的键可能被同时取消,当步骤2结束时,步骤1将重新执行,以完成任意一个在选择进行的过程中,借鉴可能已经被取消的通道注销

  • select操作的返回值是ready集合在步骤2中被修改的键的数量,而不是已选择的键的集合中的通道的总数,返回值不是已准备好的通道的总数,而是从上一个select()调用之后进入就绪状态的通道的数量,之前的调用中就绪的,并且在本次调用中任然就绪的通道将不会被计入,而那些在前一次的调用中已经就绪但已经不再处于就绪状态的通道也不会被计入,这些通道可能已经在已选择的键的集合中,但不会被计入返回值中,返回值可能是0

       使用内部的已取消的键的集合来延迟注销,是一种防止线程在取消键时阻塞,并防止与正在进行的选择操作冲突的优化,注销通道是一个签字啊的代价很高的操作,这可能需要重新分配资源(记住:键是和通道相关的,并且可能与他们相关的通道之间有复杂的交互),清理已取消的键,并与选择操作之前和之后注销通道,可能消除他们之间正好选择的过程中执行的潜在棘手问题,这是另一种兼顾健壮性的折中方案

3.6 停止选择的方法

       选择器执行选择的过程,系统底层会依次询问每个通道是否已经就绪,这个过程可能会造成调用线程进入阻塞状态,那么我们有以下几种方式可以唤醒在select()方法中阻塞的线程。

  • wakeup()方法 :通过调用Selector对象的wakeup()方法让处在阻塞状态的select()方法立刻返回该方法使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有进行中的选择操作,那么下一次对select()方法的一次调用将立即返回。
  • close()方法 :通过close()方法关闭Selector,该方法使得任何一个在选择操作中阻塞的线程都被唤醒(类似wakeup()),同时使得注册到该Selector的所有Channel被注销,所有的键将被取消,但是Channel本身并不会关闭。

selectorJDK源码分析:
链接1
链接2

四、NIO与BIO各自优势

       NIO比BIO最大的好处是,一个线程可以处理多个socket(channel),这样NIO+多线程会提高网络服务器的性能,
       最主要是大大降低线程的数量,服务器线程数量过多对系统有什么影响?

  1. java里面创建进程和线程,最终映射到本地操作系统上创建进程和线程,拿Linux来说,fork(进程创建函数)和pthread_create(线程创建函数)都是重量级的函数,调用它们开销很大
  2. 多线程随着CPU的调度,会有上下文切换,如果线程过多,线程上下文切换的时间花费慢慢趋近或者大于线程本身执行指令的时间,那么CPU就完全被浪费掉了,大大降低了系统的性能
  3. 线程的开辟伴随着线程私有内存的分配,如果线程数量过多,为线程运行准备的内存占去很多,真正能用来分配做业务处理的内存大大减少,系统运行不可靠
  4. 数量过多的线程,阻塞等待网络事件发生,如果一瞬间客户请求量比较大,系统会瞬间唤醒很多数量的线程,造成系统瞬间的内存使用率和CPU使用率居高不下,服务器系统不应该总是出现锯齿状的系统负载,内存使用率和CPU使用率应该持续的保证平顺运行

五、BIO、NIO适用场景分析:

       BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,JDK1.4以前的唯一选择,但程序直观简单易理解。
       NIO方式适用于连接数目多且连接比较短(轻操作)的架构,编程比较复杂,JDK1.4开始支持。比如聊天服务器

六、NIO+多线程编程

一个线程可以处理多个用户的连接(NIO中selector),多线程+NIO
子线程:处理SocketChannel的读写事件,子线程中需要独立的selector实例

AIO

       Asynchronous IO: 异步非阻塞的编程方式。
       与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。在JDK1.7中,这部分内容被称作NIO.2,主要在java.nio.channels包下增加了下面四个异步通道:AsynchronousSocketChannel、AsynchronousServerSocketChannel、AsynchronousFileChannel、AsynchronousDatagramChannel

发布了25 篇原创文章 · 获赞 12 · 访问量 2907

猜你喜欢

转载自blog.csdn.net/ALone_cat/article/details/104269186
今日推荐