一、前言

    传统的BIO方式是基于流行进行读写的,而且是阻塞的,整体性能比较差。为了提高I/O性能,JDK与1.4版本引入NIo,他弥补了原来BIO方式的不足,在标准的Java代码中提供了高速、面向块的I/O。通过定义包含数据的类以及块的形式处理数据,NIO可以再不编写表弟代码的气哭下利用底层优化,这是BIO无法做到的。

二、NIO

    与BIO相比,NIO有如下几个新的概念:
    1.通道
    通道(Channel)是对BIO中流的模拟,到任何目的地(或者来自任何地方)的所有数据都必须通过一个通道对象。

    通道与流的不同之处在于通道是双向的。流只是在一个方向上移动(一个流要么用于读,要么用于写),而通道可以用于读、写或者同事用于读写。因为通道是双向的,所以他可以比流更好的反应底层操作系统的真实情况(特别是在UNIX模型中底层操作系统通道同样是双向的情况下)。

    2.缓冲区
    尽管通道用于读写数据,但是我们却并吧直接操作通道进行读写,而是通过缓冲区(Buffer)完成。缓冲区实质上是一个容器对象。发送给通道的所有对象都必须先放到缓冲区中,同样从通道中读取的任何数据都要先读到缓冲区中。

    缓冲区体现了NIO与BIO的一个重要区别。在BIO中,读写可以直接操作流对象。简单讲,缓冲区通常是一个字节数组,也可以使用其他类型的数组。但是缓冲区不仅仅是一个数组,他提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

    3.选择器
    Java NIO提供了选择器组件(Selector)用于同时检测多个通道的事件以实现异步I/O。我们将感兴趣的事件注册到Selector上,当事件发生时可以通过Selector获得事件发生的通道,并进行相关的操作。

    异步I/O的一个优势在于,他允许你同时根据大量的输入、输出执行I/O操作。同步I/O一般要借助于轮询,或者创建许许多多的线程以处理大量的链接。使用异步I/O,你可以监听任意数量的通道事件,不必轮询,也不必启动额外的线程。

    由于Selector.select()方法是阻塞的,因此Tomcat采用轮询的方式进行处理,轮询线程称为Poller。每个Poller维护了一个Selector实例以及一个PollerEvent事件队列。每当接收到新的链接时,会将获得的SocketChannel对象封装为org.apache.tomcat.util.net.NioChannel,并且将其注册到Poller(创建一个PollerEvent实例,添加到事件队列)。

    Poller运行时,首先将新添加到队列中的PollerEvent取出,并将SocketChannel的读事件(OP_READ)注册到Poller持有的Selector上,然后执行Selector.select。当捕获到读事件时,构造SocketProcessor,并提交到线程池进行请求处理。

    为了提升对象的利用率,NioEndpoint分别为NioChannel和PollerEvent对象创建了缓存队列。当需要NioChannel和PollerEvent对象时,会检测缓存队列中是否存在可用对象,如果存在则从队列中取出对象并且重置,如果不存在则新建。

    NioEdnpoint的处理过程如下图所示:
在这里插入图片描述

  • NioEndpoint中ServerSocketChannel是阻塞的。因此,仍采用多线程并发接收客户端链接。
  • NioEndpoint根据pollerThreadCount配置的数量创建Poller线程。与Acceptor相同,Poller线程也是单独启动,不会占用请求处理的线程池。默认Poller线程个数与JVM可以使用的处理器个数相关,上限为2。
  • Acceptor接收到新的链接后,将获得的SocketChannel置为非阻塞,构造NioChannel对象,按照轮转法(Round-Robin)获取Poller实例,并且将NioChannel注册到PollerEvent事件队列。
  • Poller负责为SocketChannel注册读事件。接收到读事件后,由SocketProcessor完成客户端请求处理。

    Poller在将SocketProcessor添加到请求处理线程池之前,会将接收到读事件的SocketChannel从Poller维护的Selector上取消注册,避免当前Socket多线程同时处理。而读写过程中的事件处理则是由NioSelectorPool完成的。事件变化如下图所示:
在这里插入图片描述
    NioSelectorPool提供了一个Selector池,用于获取有效的Selector供SocketChannel读写使用。他由NioEndpoint维护,可以通过系统属性org.apache.tomcat.util.net.NioSelcetorShard配置是否在SocketChannel之间共享Selector,如果为true则所有SocketChannel均共享一个Selector实例,否则每一个SocketChannel使用不同的Selector,NioSelectorPool池维护的Selector实例数上限由属性maxSelectors确定。

    NIOSelectorPool读信息分为阻塞和非阻塞两种方式:

  • 在阻塞模式下,如果第一次读取不到数据,则会在NioSelectorPool提供的Selector对象上注册OP_READ事件,并循环调用Selector.select,超时等待OP_READ事件。如果OP_READ事件就绪,则进行数据读取。
  • 在非阻塞模式下,如果读不到数据,则直接返回。

    同样,在NioEndpoint中写详细也分为阻塞和非阻塞两种方式:

  • 在阻塞模式下,如果第一次写数据没有成功,则会在NioSelectorPool提供的Selector对象上注册OP_WRITE事件,并循环调用Selector.select()方法,超时等待OP_WRITE事件。如果OP_WRITE事件就绪,则会进行写数据操作。
  • 在非阻塞模式下,写数据之前不会监听OP_WRITE事件。如果没有成功,则直接返回。

    综上可知,Tomcat在阻塞方式下读/写时并没有监听OP_READ/OP)WRITE事件,而是当第一次操作没有成功时再进行注册。这实际上是一种乐观设计,即假设网络大多数情况下是正常的。第一次操作不成功,则表明网络存在异常,此时再对事件进行监听。

三、NIO2

    NIO2是JDK7新增的文件及网络I/O特性,他继承自NIO,同时添加了众多特性及功能改进,其中最重要的即是对异步I/O(AIO)的支持。

    1.通道
    在AIO中,通道必须实现接口java.nio.channels.AsynchronousChannel。JDK7提供了3个通道实现类:java.nio.channels.AsynchronousFileChannel用于文件I/O,Java.nio.channels.AsyschronousServerSocketChannel和java.nio.channels.AsyschronousSocketChannel用于网络I/O。

    2.缓冲区
    AIO仍通过操作缓冲区完成数据的读写操作,此处不再描述。

    3.Future和CompletionHandler
    AIO操作存在两种操作方式:Future和CompletionHandler。

    首先,AIO使用了Java并发包的API,无论接收Socket请求还是读写操作,均可以返回一个java.util.concurrent.Future对象来表示I/O处于等待状态。通过Future的方法,我们可以检测操作是否完成(isDone)、等待完成并取得操作结果(get)等。当接收请求(accept)结束时,Future.get返回值为AsynchronousSocketChannel;读写操作时(read/write),Future.get返回值为读写操作结果。

    除了Future外,接收请求以及读写操作还支持指定一个java.nio.channels.CompletionHandler<V,A>接口(此时不再返回Future对象),当I/O操作完成时,会调用接口的completed()方法,当操作失败时,则会调用failed()方法。

    比较两种操作方式,Future方式需要我们自己检测I/O操作状态或者直接通过Future.get()方法等待I/O操作结束,而CompletionHandler方式则由JDK检测I/O状态,我们需要实现每种操作状态的处理即可。在实际应用中,我们可以只采用Future方式或者CompletionHandler方式,也可以两者混合使用。

    4.异步通道组
    AIO新引入了异步通道组(Asynchronous Channel Group)的概念,每个异步通道均属于一个指定的异步通道组,同一个通道组内的通道共享一个线程池。线程池内的线程接收指令来执行I/O事件并将结果分发到CompletionHandler。异步通道组包括线程池以及所有通道工作线程共享的资源。通道生命周期受所属通道组影响,当通道组关闭后,通道也随着关闭。

    在实际开发中,除了可以手动创建异步通道组外,JVM还维护了一个系统分为的通道组实例,作为默认通道组。如果创建通道时为指定通道组或者指定的通道组为空,那么将会使用默认通道组。

    默认通道组通过两个系统属性进行配置。首先是java.nio.channels.DefaultThreadPool.threadFactory,该属性值为具体的java.util.concurrent.ThreadFactory类,由系统类加载器加载并且实例化,用于创建默认通道组线程池的线程。其次为java.nio.channels.DefaultThreadPool.initialSize,用于指定线程池的初始化大小。

    如果默认 通道组不能满足需要,我们还可以通过AsynchronousChannelGroup的下列3个方法来创建自定义的通道组:

  • withFixedThreadPool用于创建固定大小的线程池,固定大小的线程池适合简单的场景:一个线程等待I/O事件、完成I/O事件、执行CompletionHandler(内核将事件直接分发到这些线程)。当CompletionHandler正常终止,线程将返回线程池并且等待下一个事件。但是如果CompletionHandler未能及时完成,他将会阻塞处理线程。如果所有线程均在CompletionHandler内部阻塞,整个应用将会被阻塞。此时所有新事件均会排队等待,知道有一个线程变为有效。最糟糕的场景是没有线程被释放,内核将不再执行任何操作。这个问题避免的方法是在CompletionHandler内部不采用阻塞或者长时间的操作,也可以使用一个缓存线程池或者超时时间来避免这个问题。
  • withCachedThreadPool用于创建缓存线程池。异步通道组提交时间到线程池,线程池知识简单地执行CompletionHandler的方法。此时大家会有疑问,如果线程只是简单地执行CompletionHandler的方法,那么是谁执行具体的I/O操作?答案是隐藏线程池。这是一组独立的线程用于等待I/O事件。更准确的讲,内核I/O操作由一个或者多个不可见的内部线程处理并且将事件分发到缓存线程池,缓存线程池依次执行CompletionHandler。隐藏线程池非常重要,因为他显著降低了应用程序阻塞的可能性(解决了固定大小线程池的问题),确保内核能够完成I/O操作。但是他仍存在一个问题,由于缓存线程池需要无边界的队列,这将使队列无限制的增长并最终导致outOfMemoryError。因此仍需要注意避免CompletionHanler中的阻塞以及长时间的操作。

    Tomcat中AIO的使用可以创建Nio2Endpoint。与BIO、NIO类似,Tomcat仍使用Acceptor线程池的方式接收客户端请求。在Acceptor中,采用Fy=uture方式进行请求接收。此外,Tomcat分别采用Future方式实现阻塞读写,采用CompletionHandler方式实现非阻塞读写。

  • Nio2Endpoint创建异步通道时,指定了自定义异步通道组,并且使用的是请求处理线程池。
  • Nio2Endpoint中接收请求仍采用多线程处理,以Future的方式阻塞调用。
  • 当接收到请求后,构造Nio2SocketWrapper以及SocketProcessor并且提交到请求处理线程池,最终由Http11Nio2Processor(HTTP协议)完成请求处理。
  • Nio2Endpoint通过Nio2Channel封装了AsynchronousSocketChannel和读写ByteBugger,并提供了Nio2Channel缓存以实现ByteBuffer的重复利用。当接收到客户端请求后,Nio2Endpoint先从缓存中查找可用的Nio2Channel。如果存在,则使用当前的AsynchronousSocketChannel进行重置,否则创建新的Nio2Channel实例。
  • Nio2Endpoint只有在读取请求头时采用非阻塞方式,即CompletionHandler。在读取请求体和写响应均采用阻塞方式,即为Futrue。

    Enio2Endpoint的处理过程如下图所示:
在这里插入图片描述

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐