【NettyNetty简介

2023年 9月 25日 68.5k 0

Java NIO通信基础详解

Java NIO简介

在1.4版本之前,Java IO类库是阻塞IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为JAVA NIO。New IO类库的目标,就是要让Java支持非阻塞IO,基于这个原因,更多的人喜欢称Java NIO为非阻塞IO(Non-Block IO),称“老的”阻塞式Java IO为OIO(Old IO)。总体上说,NIO弥补了原来面向流的OIO同步阻塞的不足,它为标准Java代码提供了高速的、面向缓冲区的IO。

Java NIO由以下三个核心组件组成:

    • Channel(通道)
    • Buffer(缓冲区)
    • Selector(选择器)

如果理解了第1章的四种IO模型,大家一眼就能识别出来,Java NIO,属于第三种模型—— IO多路复用模型。当然,Java NIO组件,提供了统一的API,为大家屏蔽了底层的不同操作系统的差异。

后面的章节,我们会对以上的三个Java NIO的核心组件,展开详细介绍。先来看看Java的NIO和OIO的简单对比。

NIO和OIO的对比

在Java中,NIO和OIO的区别,主要体现在三个方面:

(1)OIO是面向流(Stream Oriented)的,NIO是面向缓冲区(Buffer Oriented)的。

何谓面向流,何谓面向缓冲区呢?

OIO是面向字节流或字符流的,在一般的OIO操作中,我们以流式的方式顺序地从一个流(Stream)中读取一个或多个字节,因此,我们不能随意地改变读取指针的位置。而在NIO操作中则不同,NIO中引入了Channel(通道)和Buffer(缓冲区)的概念。读取和写入,只需要从通道中读取数据到缓冲区中,或将数据从缓冲区中写入到通道中。NIO不像OIO那样是顺序操作,可以随意地读取Buffer中任意位置的数据。

(2)OIO的操作是阻塞的,而NIO的操作是非阻塞的。

NIO如何做到非阻塞的呢?大家都知道,OIO操作都是阻塞的,例如,我们调用一个read方法读取一个文件的内容,那么调用read的线程会被阻塞住,直到read操作完成。

而在NIO的非阻塞模式中,当我们调用read方法时,如果此时有数据,则read读取数据并返回;如果此时没有数据,则read直接返回,而不会阻塞当前线程。NIO的非阻塞,是如何做到的呢?其实在上一章,答案已经揭晓了,NIO使用了通道和通道的多路复用技术。

(3)OIO没有选择器(Selector)概念,而NIO有选择器的概念。

NIO的实现,是基于底层的选择器的系统调用。NIO的选择器,需要底层操作系统提供支持。而OIO不需要用到选择器。

NIO的三个核心组件

通道(Channel)

在OIO中,同一个网络连接会关联到两个流:一个输入流(Input Stream),另一个输出流(Output Stream)。通过这两个流,不断地进行输入和输出的操作。

在NIO中,同一个网络连接使用一个通道表示,所有的NIO的IO操作都是从通道开始的。一个通道类似于OIO中的两个流的结合体,既可以从通道读取,也可以向通道写入。

Selector选择器

首先,回顾一个基础的问题,什么是IO多路复用?指的是一个进程/线程可以同时监视多个文件描述符(一个网络连接,操作系统底层使用一个文件描述符来表示),一旦其中的一个或者多个文件描述符可读或者可写,系统内核就通知该进程/线程。在Java应用层面,如何实现对多个文件描述符的监视呢?需要用到一个非常重要的Java NIO组件——Selector选择器。

选择器的神奇功能是什么呢?它是一个IO事件的查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。

实现IO多路复用,从具体的开发层面来说,首先把通道注册到选择器中,然后通过选择器内部的机制,可以查询(select)这些注册的通道是否有已经就绪的IO事件(例如可读、可写、网络连接完成等)。

一个选择器只需要一个线程进行监控,换句话说,我们可以很简单地使用一个线程,通过选择器去管理多个通道。这是非常高效的,这种高效来自于Java的选择器组件Selector,以及其背后的操作系统底层的IO多路复用的支持。

与OIO相比,使用选择器的最大优势:系统开销小,系统不必为每一个网络连接(文件描述符)创建进程/线程,从而大大减小了系统的开销。

缓冲区(Buffer)

应用程序与通道(Channel)主要的交互操作,就是进行数据的read读取和write写入。为了完成如此大任,NIO为大家准备了第三个重要的组件——NIO Buffer(NIO缓冲区)。通道的读取,就是将数据从通道读取到缓冲区中;通道的写入,就是将数据从缓冲区中写入到通道中。

下面从缓冲区开始,详细介绍NIO的Buffer(缓冲区)、Channel(通道)、Selector(选择器)三大核心组件。

Reactor反应器模式

本书的原则:从基础讲起。Reactor反应器模式是高性能网络编程在设计和架构层面的基础模式。为什么呢?只有彻底了解反应器的原理,才能真正构建好高性能的网络应用,才能轻松地学习和掌握Netty框架。同时,反应器模式也是BAT级别大公司必不可少的面试题。

Reactor反应器模式为何如此重要

在详细介绍什么是Reactor反应器模式之前,首先说明一下它的重要性。

到目前为止,高性能网络编程都绕不开反应器模式。很多著名的服务器软件或者中间件都是基于反应器模式实现的。

比如说,“全宇宙最有名的、最高性能”的Web服务器Nginx,就是基于反应器模式的;如雷贯耳的Redis,作为最高性能的缓存服务器之一,也是基于反应器模式的;目前火得“一塌糊涂”、在开源项目中应用极为广泛的高性能通信中间件Netty,更是基于反应器模式的。

从开发的角度来说,如果要完成和胜任高性能的服务器开发,反应器模式是必须学会和掌握的。从学习的角度来说,反应器模式相当于高性能、高并发的一项非常重要的基础知识,只有掌握了它,才能真正掌握Nginx、Redis、Netty等这些大名鼎鼎的中间件技术。正因为如此,在大的互联网公司如阿里、腾讯、京东的面试过程中,反应器模式相关的问题是经常出现的面试问题。

总之,反应器模式是高性能网络编程的必知、必会的模式。

为什么学习Reactor反应器模式

本书的目标,是学习基于Netty的开发高性能通信服务器。为什么在学习Netty之前,首先要学习Reactor反应器模式呢?

写多了代码的程序员都知道,Java程序不是按照顺序执行的逻辑来组织的。代码中所用到的设计模式,在一定程度上已经演变成了代码的组织方式。越是高水平的Java代码,抽象的层次越高,到处都是高度抽象和面向接口的调用,大量用到继承、多态的设计模式。

在阅读别人的源代码时,如果不了解代码所使用的设计模式,往往会晕头转向,不知身在何处,很难读懂别人的代码,对代码跟踪很成问题。反过来,如果先了解代码的设计模式,再去看代码,就会阅读得很轻松,不会那么难懂了。

当然,在写代码时,不了解设计模式,也很难写出高水平的Java代码。

本书的重要使命之一,就是帮助大家学习和掌握Netty。Netty本身很抽象,大量应用了设计模式。学习像Netty这样的“精品中的精品”,肯定也是需要先从设计模式入手的。而Netty的整体架构,就是基于这个著名反应器模式。

总之,反应器模式非常重要。首先学习和掌握反应器模式,对于学习Netty的人来说,一定是磨刀不误砍柴工。

Reactor反应器模式简介

什么是Reactor反应器模式呢?本文站在巨人的肩膀上,引用一下Doug Lea(那是一位让人无限景仰的大师,Java中Concurrent并发包的重要作者之一)在文章《Scalable IO in Java》中对反应器模式的定义,具体如下:

反应器模式由Reactor反应器线程、Handlers处理器两大角色组成:

(1)Reactor反应器线程的职责:负责响应IO事件,并且分发到Handlers处理器。

(2)Handlers处理器的职责:非阻塞的执行业务处理逻辑。

在这里,为了方便大家学习,将Doug Lea著名的文章《Scalable IO in Java》的链接地址贴出来:gee.cs.oswego.edu/dl/cpjslide…,建议大家去阅读一下,提升自己的基础知识,开阔下眼界。

从上面的反应器模式定义,看不出这种模式有什么神奇的地方。当然,从简单到复杂,反应器模式也有很多版本。根据前面的定义,仅仅是最为简单的一个版本。

如果需要彻底了解反应器模式,还得从最原始的OIO编程开始讲起。

多线程OIO的致命缺陷

在Java的OIO编程中,最初和最原始的网络服务器程序,是用一个while循环,不断地监听端口是否有新的连接。如果有,那么就调用一个处理函数来处理,示例代码如下:

while (true) {
    // 阻塞,接收连接
    socket = accept();
    // 读取数据、业务处理、写入结果
    handle(socket);
}

这种方法的最大问题是:如果前一个网络连接的handle(socket)没有处理完,那么后面的连接请求没法被接收,于是后面的请求通通会被阻塞住,服务器的吞吐量就太低了。对于服务器来说,这是一个严重的问题。

为了解决这个严重的连接阻塞问题,出现了一个极为经典模式:Connection Per Thread(一个线程处理一个连接)模式。示例代码如下:

package com.crazymakercircle.iodemo.OIO;
//...省略: 导入的Java类
class ConnectionPerThread implements Runnable {
    public void run() {
      try {
          //服务器监听socket
          ServerSocketserverSocket =
                  new ServerSocket(NioDemoConfig.SOCKET_SERVER_PORT);
          while (! Thread.interrupted()) {
              Socket socket = serverSocket.accept();
              //接收一个连接后,为socket连接,新建一个专属的处理器对象
              Handler handler = new Handler(socket);
              //创建新线程,专门负责一个连接的处理
              new Thread(handler).start();
          }
      } catch (IOException ex) { /* 处理异常 */ }
    }
    //处理器对象
    static class Handler implements Runnable {
      final Socket socket;
      Handler(Socket s) {
          socket = s;
      }
      public void run() {
          while (true) {
              try {
                  byte[] input = new byte[NioDemoConfig.SERVER_BUFFER_SIZE];
                  /* 读取数据 */
                  socket.getInputStream().read(input);
                  /* 处理业务逻辑,获取处理结果*/
                  byte[] output =null;
                  /* 写入结果 */
                  socket.getOutputStream().write(output);
              } catch (IOException ex) { /*处理异常*/ }
          }
      }
    }
}

对于每一个新的网络连接都分配给一个线程。每个线程都独自处理自己负责的输入和输出。当然,服务器的监听线程也是独立的,任何的socket连接的输入和输出处理,不会阻塞到后面新socket连接的监听和建立。早期版本的Tomcat服务器,就是这样实现的。

Connection Per Thread模式(一个线程处理一个连接)的优点是:解决了前面的新连接被严重阻塞的问题,在一定程度上,极大地提高了服务器的吞吐量。

这里有个问题:如果一个线程同时负责处理多个socket连接的输入和输入,行不行呢?

看上去,没有什么不可以。但是,实际上没有用。为什么?传统OIO编程中每一个socket的IO读写操作,都是阻塞的。在同一时刻,一个线程里只能处理一个socket,前一个socket被阻塞了,后面连接的IO操作是无法被并发执行的。所以,不论怎么处理,OIO中一个线程也只能是处理一个连接的IO操作。

Connection Per Thread模式的缺点是:对应于大量的连接,需要耗费大量的线程资源,对线程资源要求太高。在系统中,线程是比较昂贵的系统资源。如果线程数太多,系统无法承受。而且,线程的反复创建、销毁、线程的切换也需要代价。因此,在高并发的应用场景下,多线程OIO的缺陷是致命的。

如何解决Connection Per Thread模式的巨大缺陷呢?一个有效路径是:使用Reactor反应器模式。用反应器模式对线程的数量进行控制,做到一个线程处理大量的连接。它是如何做到呢?首先来看简单的版本——单线程的Reactor反应器模式。

单线程Reactor反应器模式

总体来说,Reactor反应器模式有点儿类似事件驱动模式。

在事件驱动模式中,当有事件触发时,事件源会将事件dispatch分发到handler处理器进行事件处理。反应器模式中的反应器角色,类似于事件驱动模式中的dispatcher事件分发器角色。

前面已经提到,在反应器模式中,有Reactor反应器和Handler处理器两个重要的组件:

(1)Reactor反应器:负责查询IO事件,当检测到一个IO事件,将其发送给相应的Handler处理器去处理。这里的IO事件,就是NIO中选择器监控的通道IO事件。

(2)Handler处理器:与IO事件(或者选择键)绑定,负责IO事件的处理。完成真正的连接建立、通道的读取、处理业务逻辑、负责将结果写出到通道等。

什么是单线程Reactor反应器

什么是单线程版本的Reactor反应器模式呢?简单地说,Reactor反应器和Handers处理器处于一个线程中执行。它是最简单的反应器模型,如图4-1所示。

基于Java NIO,如何实现简单的单线程版本的反应器模式呢?需要用到SelectionKey选择键的几个重要的成员方法:

方法一:void attach(Object o)

此方法可以将任何的Java POJO对象,作为附件添加到SelectionKey实例,相当于附件属性的setter方法。这方法非常重要,因为在单线程版本的反应器模式中,需要将Handler处理器实例,作为附件添加到SelectionKey实例。

方法二:Object attachment()

此方法的作用是取出之前通过attach(Object o)添加到SelectionKey选择键实例的附件,相当于附件属性的getter方法,与attach(Object o)配套使用。

这个方法同样非常重要,当IO事件发生,选择键被select方法选到,可以直接将事件的附件取出,也就是之前绑定的Handler处理器实例,通过该Handler,完成相应的处理。

总之,在反应器模式中,需要进行attach和attachment结合使用:在选择键注册完成之后,调用attach方法,将Handler处理器绑定到选择键;当事件发生时,调用attachment方法,可以从选择键取出Handler处理器,将事件分发到Handler处理器中,完成业务处理。

单线程Reactor反应器模式的缺点

单线程Reactor反应器模式,是基于Java的NIO实现的。相对于传统的多线程OIO,反应器模式不再需要启动成千上万条线程,效率自然是大大提升了。

在单线程反应器模式中,Reactor反应器和Handler处理器,都执行在同一条线程上。这样,带来了一个问题:当其中某个Handler阻塞时,会导致其他所有的Handler都得不到执行。在这种场景下,如果被阻塞的Handler不仅仅负责输入和输出处理的业务,还包括负责连接监听的AcceptorHandler处理器。这个是非常严重的问题。

为什么?一旦AcceptorHandler处理器阻塞,会导致整个服务不能接收新的连接,使得服务器变得不可用。因为这个缺陷,因此单线程反应器模型用得比较少。

另外,目前的服务器都是多核的,单线程反应器模式模型不能充分利用多核资源。总之,在高性能服务器应用场景中,单线程反应器模式实际使用的很少。

多线程的Reactor反应器模式

既然Reactor反应器和Handler处理器,挤在一个线程会造成非常严重的性能缺陷。那么,可以使用多线程,对基础的反应器模式进行改造和演进。

多线程池Reactor反应器演进

多线程池Reactor反应器的演进,分为两个方面:

(1)首先是升级Handler处理器。既要使用多线程,又要尽可能的高效率,则可以考虑使用线程池。

(2)其次是升级Reactor反应器。可以考虑引入多个Selector选择器,提升选择大量通道的能力。

总体来说,多线程池反应器的模式,大致如下:

(1)将负责输入输出处理的IOHandler处理器的执行,放入独立的线程池中。这样,业务处理线程与负责服务监听和IO事件查询的反应器线程相隔离,避免服务器的连接监听受到阻塞。

(2)如果服务器为多核的CPU,可以将反应器线程拆分为多个子反应器(SubReactor)线程;同时,引入多个选择器,每一个SubReactor子线程负责一个选择器。这样,充分释放了系统资源的能力;也提高了反应器管理大量连接,提升选择大量通道的能力。

反应器模式和生产者消费者模式对比

相似之处:在一定程度上,反应器模式有点类似生产者消费者模式。在生产者消费者模式中,一个或多个生产者将事件加入到一个队列中,一个或多个消费者主动地从这个队列中提取(Pull)事件来处理。

不同之处在于:反应器模式是基于查询的,没有专门的队列去缓冲存储IO事件,查询到IO事件之后,反应器会根据不同IO选择键(事件)将其分发给对应的Handler处理器来处理。

反应器模式和观察者模式(Observer Pattern)对比

相似之处在于:在反应器模式中,当查询到IO事件后,服务处理程序使用单路/多路分发(Dispatch)策略,同步地分发这些IO事件。观察者模式(Observer Pattern)也被称作发布/订阅模式,它定义了一种依赖关系,让多个观察者同时监听某一个主题(Topic)。这个主题对象在状态发生变化时,会通知所有观察者,它们能够执行相应的处理。

不同之处在于:在反应器模式中,Handler处理器实例和IO事件(选择键)的订阅关系,基本上是一个事件绑定到一个Handler处理器;每一个IO事件(选择键)被查询后,反应器会将事件分发给所绑定的Handler处理器;而在观察者模式中,同一个时刻,同一个主题可以被订阅过的多个观察者处理。

反应器模式的优点和缺点

作为高性能的IO模式,反应器模式的优点如下:

  • 响应快,虽然同一反应器线程本身是同步的,但不会被单个连接的同步IO所阻塞;
  • 编程相对简单,最大程度避免了复杂的多线程同步,也避免了多线程的各个进程之间切换的开销;
  • 可扩展,可以方便地通过增加反应器线程的个数来充分利用CPU资源。

反应器模式的缺点如下:

  • 反应器模式增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
  • 反应器模式需要操作系统底层的IO多路复用的支持,如Linux中的epoll。如果操作系统的底层不支持IO多路复用,反应器模式不会有那么高效。
  • 同一个Handler业务线程中,如果出现一个长时间的数据读写,会影响这个反应器中其他通道的IO处理。例如在大文件传输时,IO操作就会影响其他客户端(Client)的响应时间。因而对于这种操作,还需要进一步对反应器模式进行改进。

Netty原理与基础

解密Netty中的Reactor反应器模式

在前面的章节中,已经反复说明:设计模式是Java代码或者程序的重要组织方式,如果不了解设计模式,学习Java程序往往找不到头绪,上下求索而不得其法。故而,在学习Netty组件之前,我们必须了解Netty中的反应器模式是如何实现的。

现在,现回顾一下Java NIO中IO事件的处理流程和反应器模式的基础内容。

回顾Reactor反应器模式中IO事件的处理流程

一个IO事件从操作系统底层产生后,在Reactor反应器模式中的处理流程如图6-1所示。

整个流程大致分为4步,具体如下:

第1步:通道注册。IO源于通道(Channel)。IO是和通道(对应于底层连接而言)强相关的。一个IO事件,一定属于某个通道。但是,如果要查询通道的事件,首先要将通道注册到选择器。只需通道提前注册到Selector选择器即可,IO事件会被选择器查询到。

第2步:查询选择。在反应器模式中,一个反应器(或者SubReactor子反应器)会负责一个线程;不断地轮询,查询选择器中的IO事件(选择键)。

第3步:事件分发。如果查询到IO事件,则分发给与IO事件有绑定关系的Handler业务处理器。

第4步:事件处理。完成真正的IO操作和业务处理,这一步由Handler业务处理器负责。

以上4步,就是整个反应器模式的IO处理器流程。其中,第1步和第2步,其实是Java NIO的功能,反应器模式仅仅是利用了Java NIO的优势而已。

题外话:上面的流程比较重要,是学习Netty的基础。如果这里看不懂,作为铺垫,请先回到反应器模式的详细介绍部分,回头再学习一下反应器模式。

Netty中的Channel通道组件

Channel通道组件是Netty中非常重要的组件,为什么首先要说的是Channel通道组件呢?原因是:反应器模式和通道紧密相关,反应器的查询和分发的IO事件都来自于Channel通道组件。

Netty中不直接使用Java NIO的Channel通道组件,对Channel通道组件进行了自己的封装。在Netty中,有一系列的Channel通道组件,为了支持多种通信协议,换句话说,对于每一种通信连接协议,Netty都实现了自己的通道。

另外一点就是,除了Java的NIO, Netty还能处理Java的面向流的OIO(Old-IO,即传统的阻塞式IO)。

Decoder与Encoder重要组件

大家知道,Netty从底层Java通道读到ByteBuf二进制数据,传入Netty通道的流水线,随后开始入站处理。

在入站处理过程中,需要将ByteBuf二进制类型,解码成Java POJO对象。这个解码过程,可以通过Netty的Decoder解码器去完成。

在出站处理过程中,业务处理后的结果(出站数据),需要从某个Java POJO对象,编码为最终的ByteBuf二进制数据,然后通过底层Java通道发送到对端。

在编码过程中,需要用到Netty的Encoder编码器去完成数据的编码工作。本章专门为大家解读,什么是Netty的编码器和解码器。

本章专门为大家解读,什么是Netty的编码器和解码器。

Decoder原理与实践

什么叫作Netty的解码器呢?

首先,它是一个InBound入站处理器,解码器负责处理“入站数据”。

其次,它能将上一站Inbound入站处理器传过来的输入(Input)数据,进行数据的解码或者格式转换,然后输出(Output)到下一站Inbound入站处理器。

一个标准的解码器将输入类型为ByteBuf缓冲区的数据进行解码,输出一个一个的Java POJO对象。Netty内置了这个解码器,叫作ByteToMessageDecoder,位在Netty的io.netty.handler.codec包中。

强调一下,所有的Netty中的解码器,都是Inbound入站处理器类型,都直接或者间接地实现了ChannelInboundHandler接口。

ByteToMessageDecoder解码器

ByteToMessageDecoder是一个非常重要的解码器基类,它是一个抽象类,实现了解码的基础逻辑和流程。ByteToMessageDecoder继承自ChannelInboundHandlerAdapter适配器,是一个入站处理器,实现了从ByteBuf到Java POJO对象的解码功能。

Encoder原理与实践

在Netty中,什么叫编码器呢?首先,编码器是一个Outbound出站处理器,负责处理“出站”数据;其次,编码器将上一站Outbound出站处理器传过来的输入(Input)数据进行编码或者格式转换,然后传递到下一站ChannelOutboundHandler出站处理器。

编码器与解码器相呼应,Netty中的编码器负责将“出站”的某种Java POJO对象编码成二进制ByteBuf,或者编码成另一种Java POJO对象。

参考文档

Netty、Redis、Zookeeper高并发实战-尼恩-微信读书

相关文章

服务器端口转发,带你了解服务器端口转发
服务器开放端口,服务器开放端口的步骤
产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
如何使用 WinGet 下载 Microsoft Store 应用
百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

发布评论