Java BIO/NIO(Non-blocking I/O)详解

 IO模型

  IO模型简单点说就是使用什么样的通道进行数据的发送和接收,这种通道的特性决定了程序通信的性能, 比如这个通道是否是异步还是同步,是阻塞还是非阻塞,是否有缓存,是单向通道还是双向通道。

 

 Java中IO模型

  Java中共支持3中网络IO模型:BIO,NIO,AIO。

  1. BIO:

    同步并阻塞(传统的阻塞型),服务器实现模式为一个连接一个线程,就是客户端发送连接请求时候,服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情就会造成不必要的线程开销,比如socket阻塞在 accept 等待连接。

    BIO可以通过多线程的方式来改善并发性能,不过底层还是一个线程对应一个连接。

  2. NIO(Non-blocking I/O,在Java领域,也称为New I/O,因为是原始IO之后出现的),是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,服务器实现模式为一个线程处理多个请求(连接),就是客户端发送的连接请求都会注册到多路复用器上,多路复用器轮训到连接有I/O请求就进行处理。NIO现在已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。

   Netty目前是基于NIO模型来实现的。

          NIO在JDK1.4就引入了,不过还存在一些问题,在后续的版本中一直在修复,知道JDK1.8才稳定下来。

     NIO并不是在原始的IO基础上扩展的,而是从新设计了一套IO标准,为什么要重新设计呢?其实就是作为原始IO的补充,主要就是为了应对高并发,高性能IO的场景。

  3. AIO(NIO.2):

    异步非阻塞,AIO引入异步通道的概念,采用了Proactor模式,简化了程序的编写,有效的请求才启动线程,它的特点是先由操作系统完成后才通知服务端程序启动线程去处理,一般适用于连接数较多而且连接世界比较长的应用。

   

    BIO,NIO,AIO适用场景:

     1. BIO方式适用于连接数比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限的应用中,JDK1.4以前是唯一的选择,但程序简单容易理解。

     2. NIO方式适用于连接数目较多且连接比较短的架构,比如聊天服务器(长连接),弹幕系统,服务器间通讯,编程比较复杂,从JDK1.4开始支持。

     3. AIO方式适用于连接数目较多且连接比较长的重操作的架构,比如相册服务器,充分调用OS参与并发的操作,编程比较复杂,从JDK7开始支持。

 

 BIO模型

   BIO是基于阻塞IO实现的。

   BIO工作机制

    

 

  1. 首先服务端启动一个ServerSocket用于监听客户端的请求。

  2. 客户端发起建立连接请求,启动Socket对服务器通信,默认情况下服务器需要对每个客户端建立一个线程与之通信。

  3. 服务端在接收到客户端的请求后会创建一个新的线程,客户端发出请求后,先询问服务器是否有线程响应,如果没有则会等待,或者被拒绝。

  4. 上面服务端新创建的线程会和客户端建立Socket连接,然后响应客户端成功信息,此时客户端已经知道已经成功和服务端成功连接,随时可以向服务端发送数据。

  5. 服务端会一直等待客户端发来数据,此时这个服务端线程相当于阻塞等待客户端发来的数据。

 

  

  BIO缺点:

    1. 由于基于阻塞时IO模型,就会导致在大量并发的情况下,需要创建大量的线程,同时如果客户端一直不发送请求,会导致服务端线程一直存在。

    2. 客户端线程和服务端线程的数量比是1:1,一个客户端就对应一个服务端线程,所以会造成弹性伸缩能力差,由于线程是Java虚拟机非常宝贵的资源,当线程数膨胀后,容易造成堆栈溢出,造成创建线程失败,机器宕机,再强大的服务器也承受不住同时成千上万的线程。

    3. 服务端存在大量线程的创建,销毁,调度会消耗很多的资源。

 

  Java BIO就是传统的java.io编程,其相关接口和类在java.io中。

  

 

伪异步I/O

  当有新的客户端接入的时候,将客户端的socket封装成一个Task丢入到后端的一个线程池中处理。线程池维护一个消息队列和N个活跃的线程,对消息队列中的任务进行处理,当有M个客户端接入的时候,服务端将会创建具有N个线程的线程池来对客户端请求进行处理。由于线程池可以设置消息队列的大小和线程池最大线程数,因此它的占用资源是可控的,无论多少个客户端的访问都不会导致服务端的资源耗尽。

  这种通过线程池或者消息队列实现1个或多个线程处理N个客户端连接的模型,由于它的底层通信机制仍然会用同步阻塞I/O,所以被称为“伪异步”。

  但是也有缺点就是,当有大量客户端并发访问时候,随着并发访问量的增加会导致线程池阻塞。

  下图是伪异步IO通信模型:

  

   和BIO最大区别是伪异步IO的服务端不会为每个客户端Socket创建一个独立线程,由一个独立的线程池统一的维护线程的接入。

 

 

NIO模型

  相较于BIO,NIO要复杂写,NIO是基于非阻塞式IO构建的。

  NIO工作基本思路

  

   NIO多了一个Selector组件,是NIO核心,主要作用就是管理与所有客户端建立的连接,负责监听注册上面的事件,比如有新的连接接入,或者某个连接已经就绪,有可读消息。一旦监听到事件触发,就会调用事件所对应的处理器来完成对事件的响应。

 

  

 

 

  1. 首先在Selector中注册建立连接的事件。

  2. 客户端向服务器发起建立连接请求就会监测到建立连接的事件。

  3. 触发了建立连接事件,就会启动建立连接事件对应的处理器(Acceptor Handler),也就是对应的方法,注意一点和BIO不同这里的Acceptor Handler不是一个线程,而是一个方法,可以依次处理多个请求。

  4. 处理器(Handler)会创建与客户端的连接(Socket),并且响应与客户端连接成功的信息。

  5. 处理器(Handler)会将上面新创建的Socket连接注册到Selector上,并且注册连接为可读(Read)事件。

  6. 客户端再次发起请求到Selector,Selector会监听到可读事件,然后Selector启动连接读写处理器(Read&Write Handler)。

  7. 读写处理器(Read&Write Handler)对发送过来的请求来处理相应的读写业务逻辑,并直接响应客户端,最后在将可读事件注册到Selector上。

   

       NIO模型对比BIO模型:

        1. NIO模型是一种非阻塞IO,避免了BIO那种一个客户端就需要服务器建立与之对应的一个线程。

   2. 弹性伸缩能力加强了,因为服务器端不再是多个线程,而是一个线程可以处理多个请求。

   3. 因为单线程所以节省资源,也不用频繁创建,销毁,切换线程,性能大大提升。

 

  NIO三个实现类

   NIO有三个主要组成部分

   1. Channel:通道

     1)双向性

      通道是信息传输的通道,是JDK对输入输出的抽象,可以类比BIO中流的概念,但是和流不同的是,流只是单向的,有InputStream,OutputStream,而通道支持双向,即可读也可写。

    2)非阻塞

      传统的流是阻塞模式,而通道是非阻塞式,所以这个特点,通道构成了NIO网络的基础。

    3)操作唯一性

      基于数据块的操作,只能通过buffer来操作,具体可以看下面的buffer内容。

    Channel实现类

      文件类:FileChannel,用于文件的读写。

      UDP类:DatagramChannel,用于UDP数据读写。

      TCP类:ServerSocketChannel / SocketChannel,基于TCP数据的读写。

              

      2. Buffer:缓冲区

          Buffer是NIO Api中新加入的类,用于和NIO Channel进行交互,可以读写Channel中的数据。Buffer本质上是一块可以读写的内存区域,这块内存区域被NIO包装成NIO Buffer对象,并提供了一组方法来方便的操作这块内存。在NIO中所有数据都是Buffer处理的,读取/写入数据时候都是直接读取或者写入到缓冲区中,任何时候访问NIO中的数据都是通过Buffer操作。

       Buffer属性:

        Buffer有四个属性,一切对Buffer的操作都是对这四个属性的操作。

        1)Capacity:容量

          标明Buffer最大容量(单位:字节),只能往里写capacity个byte、long,char等类型,一旦超过这个数量必须将其清空后才能继续写数据往里写数据。

        2)Position

          当你写数据到Buffer中时,Position表示当前的位置。初始的position值为0,当一个byte、long等数据写到Buffer后, Position会向前移动到下一个可插入数据的Buffer单元。Position最大可为capacity – 1,相当于数组的下标最大值。当读取数据时,也是从某个特定位置读,当将Buffer从写模式切换到读模式,position会被重置为0,当从Buffer的Position处读取数据时,Position向后移动到下一个可读的位置。

         3)Limit:上限

          在写模式下,Buffer Limit表示最多可以往Buffer中写入多少数据,此模式下Limit = Capacity。当切换到读模式时,Limit表示最多可以从Buffer中读取多少数据,此时Limit会被设置成写模式下的Position值。

        4)Mark:标记

          Mark存储一个特定的Position位置,之后可以调用Buffer的reset方法可以恢复到这个Position位置。

 

      Buffer核心方法的使用

      网路编程中使用字节类型的ByteBuffer比较多,下面就介绍ByteBuffer相关方法。 

        1. 调用allocate来初始化

     /**
         * 初始化长度为10的byte类型的buffer
         */
        ByteBuffer.allocate(10);

       

 

        此时Position等于0,Limit和Capacity都等于10。

      2. 写入数据

       使用put方法向byteBuffer中写入三个字节:

     byteBuffer.put("abc".getBytes(Charset.forName("UTF-8")));

         

       abc写到下标为0 1 2的地方,此时Position指向3,说明第三个位置可以插入数据,Limit和Capacity还是等于10。

     3. 切换读模式

      将byteBuffer从写模式切换成读模式

     byteBuffer.flip();

      

 

       Position变成0,Limit变成了3,Limit代表从这个buffer中最多可以读取的数据数量。

    4. 读取数据,从byteBuffer中读取一个字符。

     byteBuffer.get();

     

     5. 使用mark方法,记录当前Position的位置

     byteBuffer.mark();

 

         

     6. 先调用get方法读取下一个字节,接着在调用reset方法将Position位置重置到mark位置上。

      

      byteBuffer.get();
      byteBuffer.reset();

      先调用get方法读取2这个位置,此时Position会移动到下标为2的位置,接着调用reset方法会将原来的Position位置重置为上一次Mark的位置,也就是Position=1。

    7. 调用clear方法将所有属性重置。

    byteBuffer.clear();

 

      3. Selector:选择器或多路复用

     Selector是NIO网络编程的基础,整个NIO都是建立在非阻塞IO和多路复用器之上的。用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写状态。如此可以实现单线程管理多个channels,从而可以管理多个网络链接。

    通俗的说就是Selector会不断的轮询它上面的Channel,如果某个Channel上发生了读或者写事件,这个Channel就处于就绪状态,就会被Selector轮询出来,然后被selectionKey可以获取就绪Channel集合然后进行后续IO操作。由于JDK采用epoll而不是select模型,所以不会受到最大连接数的限制,可以接入大量的客户端。

    下面是Selector主要方法:

//使用静态方法 创建Selector对象
Selector selector = Selector.open();
        
// 将channel注册到Selector上,并传入希望监听的事件,监听读就绪事件
SelectionKey selectionKey = channel.register(selector,SelectionKey.OP_READ);
        
// 阻塞等待channel有就绪事件发生,调用select方法,不同的系统底层支持不同,如果发现已经就绪就返回就绪的个数,如果没有就会一直阻塞
int selectNum = selector.select();
        
// 获取发生就绪事件的channel集合
Set<SelectionKey> selectedKeys = selector.selectedKeys();

 

      1) 四种就绪常量(SelectionKey

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

 

 

NIO实现步骤

  1. 创建Selector。

  2. 创建SelectorSocketChannel,并绑定监听端口。

  3. 将Channel设置为非阻塞模式。

  4. 将Channel注册到Selector上,监听连接事件。

  5. 循环调用Selector的select方法,检测就绪情况。

  6. 调用selectedKeys方法获取就绪channel集合。

  7. 判断就绪事件种类,调用相应的业务处理方法。

  8. 根据业务需要是否再次注册监听事件,重复执行第三步操作。

 NioServer.java

package com;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * NIO服务端
 */
public class NioServer {

    public void start() throws IOException {
        // 创建Selector
        Selector selector = Selector.open();
        // 通过SelectorSocketChannel创建channel,
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        // 为channel绑定监听端口,所有到客户端到对该端口到接入都是通过这里serverSocketChannel处理
        serverSocketChannel.bind(new InetSocketAddress(8000));
        // 这一步很重要,设置channel为非阻塞模式,才能被Selector多路复用器来统一管理
        serverSocketChannel.configureBlocking(false);
        // 将channel注册到Selector上,并监听连接事件
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务器启动成功,开始监听");
        // 循环等待监听新接入到连接请求
        for (;;) {
            // 获取可用到channel数量。下面select本身是一个阻塞方法,只有当注册到上面serverSocketChannel中所监听到到事件已经就绪了才会返回
            int readyChannels = selector.select();
            if (readyChannels == 0) {
                continue;
            }
            // 获取注册到selector上到可用集合。该方法会返回一个SelectionKey到set集合
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            // 遍历上面集合
            Iterator iterator = selectionKeys.iterator();
            while (iterator.hasNext()) {
                // selectionKey实例
                SelectionKey selectionKey = (SelectionKey) iterator.next();
                // 还需要移除当前selectionKey,也就是上面到Set<SelectionKey>中
                // 因为当selector监听到一个channel已经就绪后,会放入到Set<SelectionKey>,下次又检测到它就绪后还会将其通过selector.selectedKeys()方式放入到Set<SelectionKey>中,否则集合会越来越多
                iterator.remove();

            }
        }
        // 根据不同到就绪状态,调用对应
        // 如果是接入事件
        // 如果是可读事件

    }

    /**
     * 接入事件处理器
     */
    private void acceptHandler() {

    }

    /**
     * 接入事件处理器
     */
    private void readyHandler() {

    }

    public static void main(String[] args) {
        NioServer nioServer = new NioServer();

    }
}

 

 

 

原生NIO缺点

  1. 类库和API繁杂,需要熟练掌握。

  2. 入门门槛高,比如需要掌握Java多线程,因为NIO使用了Reactor模式。所以必须对多线程和网络编程非常熟悉才能开发出高质量的NIO程序。

  3. 工作量和难度比较大,比如客户端会出现断连,重连,网络抖动,网络拥塞,半包,异常码流等问题需要处理。

  4. JDK NIO Bug,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU飙升到100%,在JDK1.7中官方声称已经修复,其实只是降低了该问题出现的概率,并没有根本去解决,也就是说原生NIO是存在缺陷的。

 

AIO模型

  AIO:异步非阻塞IO。AIO是连接注册读写事件和回调函数。读写方法异步,同时它是主动通知程序的。

 

模型总结:

  1. 同步阻塞:BIO。

  2. 同步非阻塞:NIO。

  3. 异步非阻塞:AIO。

 

 

 

  

posted @ 2021-02-12 00:32  songguojun  阅读(496)  评论(0编辑  收藏  举报