Netty

135 阅读23分钟

1、前言

  1. Netty 基于 NIO (NIO 是一种同步非阻塞的 I/O 模型,在 Java 1.4 中引入了 NIO )。使用 Netty 可以极大地简化并简化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面都非常优秀。
  2. 我们平常经常接触的 Dubbo、RocketMQ、Elasticsearch、gRPC、Spark 等等热门开源项目都用到了 Netty。
  3. 大部分微服务框架底层涉及到网络通信的部分都是基于 Netty 来做的,比如说 Spring Cloud 生态系统中的网关 Spring Cloud Gateway。

2、BIO、NIO、AIO 的区别

image.png

2.1、BIO

  BIO是JDK1.4之前的传统IO模型,本身是同步阻塞模式。线程发起IO请求后,一直阻塞IO,直到缓冲区数据就绪后,再进入下一步操作。针对网络通信都是一请求一应答的方式,虽然简化了上层的应用开发,但在性能和可靠性方面存在着巨大瓶颈,试想一下如果每个请求都需要新建一个线程来专门处理,那么在高并发的场景下,机器资源很快就会被耗尽。

2.2、NIO

  NIO (Non-blocking/New I/O): 同步非阻塞的 IO 模型。线程发起io请求后,立即返回(非阻塞io)。同步指的是必须等待IO缓冲区内的数据就绪,而非阻塞指的是,用户线程不原地等待IO缓冲区,可以先做一些其他操作,但是要定时轮询检查IO缓冲区数据是否就绪。

  Java中的NIO 是new IO的意思。其实是NIO加上IO多路复用技术。普通的NIO是线程轮询查看一个IO缓冲区是否就绪,而Java中的new IO指的是线程轮询地去查看一堆IO缓冲区中哪些就绪,这是一种IO多路复用的思想。IO多路复用模型中,将检查IO数据是否就绪的任务,交给系统级别的select或epoll模型,由系统进行监控,减轻用户线程负担。

  NIO主要有buffer、channel、selector三种技术的整合,通过零拷贝的buffer取得数据,每一个客户端通过channel在selector(多路复用器)上进行注册。服务端不断轮询channel来获取客户端的信息。channel上有connect,accept(阻塞)、read(可读)、write(可写)四种状态标识。根据标识来进行后续操作。所以一个服务端可接收无限多的channel。不需要新开一个线程。大大提升了性能。 IO多路复用机制

2.1、AIO

  AIO (Asynchronous I/O): 异步非阻塞的 IO 模型。述NIO实现中,需要用户线程定时轮询,去检查IO缓冲区数据是否就绪,占用应用程序线程资源,其实轮询相当于还是阻塞的,并非真正解放当前线程,因为它还是需要去查询哪些IO就绪。而真正的理想的异步非阻塞IO应该让内核系统完成,用户线程只需要告诉内核,当缓冲区就绪后,通知我或者执行我交给你的回调函数。

  AIO可以做到真正的异步的操作,但实现起来比较复杂,支持纯异步IO的操作系统非常少,目前也就windows是IOCP技术实现了,而在Linux上,底层还是是使用的epoll实现的。AIO 的应用还不是很广泛,Netty之前也尝试使用过AIO,不过又放弃了。

举例:
1. BIO:A顾客去吃海底捞,就这样干坐着等了一小时,然后才开始吃火锅
2. NIO:B顾客去吃海底捞,他一看要等挺久,于是去逛商场,每次逛一会就跑回来看有没有排到他。于是他最后既购了物,又吃上海底捞了
3. AIO:C顾客去吃海底捞,由于他是高级会员,所以店长说,你去商场随便玩吧,等下有位置,我立马打电话给你。于是C顾客不用干坐着等,也不用每过一会儿就跑回来看有没有等到,最后也吃上了海底捞

3、Netty介绍

  1. Netty 是一个 基于 NIO 的 client-server(客户端服务器)框架,使用它可以快速简单地开发网络应用程序。
  2. 它极大地简化并优化了 TCP 和 UDP 套接字服务器等网络编程,并且性能以及安全性等很多方面甚至都要更好。
  3. 支持多种协议 如 FTP,SMTP,HTTP 以及各种二进制和基于文本的传统协议。

总结:Netty 成功地找到了一种在不妥协可维护性和性能的情况下实现易于开发,性能,稳定性和灵活性的方法。

4、为什么不直接用 NIO

主要是因为 NIO 的编程模型复杂而且存在一些 BUG,并且对编程功底要求比较高。下图就是一个典型的使用 NIO 进行编程的案例:

image.png 而且,NIO 在面对断连重连、包丢失、粘包等问题时处理过程非常复杂。Netty 的出现正是为了解决这些问题,更多关于 Netty 的特点可以看下面的内容。

5、为什么要用 Netty

  1. 统一的 API,支持多种传输类型,阻塞和非阻塞的。
  2. 简单而强大的线程模型。
  3. 自带编解码器解决 TCP 粘包/拆包问题。
  4. 自带各种协议栈。
  5. 真正的无连接数据包套接字支持。
  6. 比直接使用 Java 核心 API 有更高的吞吐量、更低的延迟、更低的资源消耗和更少的内存复制。
  7. 安全性不错,有完整的 SSL/TLS 以及 StartTLS 支持。
  8. 社区活跃
  9. 成熟稳定,经历了大型项目的使用和考验,而且很多开源项目都使用到了 Netty, 比如我们经常接触的 Dubbo、RocketMQ 等等。

6、Netty 应用场景

  1. 作为 RPC 框架的网络通信工具 : 我们在分布式系统中,不同服务节点之间经常需要相互调用,这个时候就需要 RPC 框架了。不同服务指点的通信是如何做的呢?可以使用 Netty 来做。比如我调用另外一个节点的方法的话,至少是要让对方知道我调用的是哪个类中的哪个方法以及相关参数吧!
  2. 实现一个自己的 HTTP 服务器 :通过 Netty 我们可以自己实现一个简单的 HTTP 服务器,这个大家应该不陌生。说到 HTTP 服务器的话,作为 Java 后端开发,我们一般使用 Tomcat 比较多。一个最基本的 HTTP 服务器可要以处理常见的 HTTP Method 的请求,比如 POST 请求、GET 请求等等。
  3. 实现一个即时通讯系统 : 使用 Netty 我们可以实现一个可以聊天类似微信的即时通讯系统,这方面的开源项目还蛮多的,可以自行去 Github 找一找。
  4. 实现消息推送系统 :市面上有很多消息推送系统都是基于 Netty 来做的。

7、那些开源项目用到了Netty

Dubbo、RocketMQ、Elasticsearch、gRPC 等等都用到了 Netty。可以说大量的开源项目都用到了 Netty,所以掌握 Netty 有助于你更好的使用这些开源项目并且让你有能力对其进行二次开发。实际上还有很多很多优秀的项目用到了 Netty,Netty 官方也做了统计,统计结果在这里:netty.io/wiki/relate…

image.png

8、Netty 的核心组件

通过下面这张图你可以将我提到的这些 Netty 核心组件串联起来。 image.png

8.1、Bytebuf(字节容器)

网络通信最终都是通过字节流进行传输的。 ByteBuf 就是 Netty 提供的一个字节容器,其内部是一个字节数组。 当我们通过 Netty 传输数据的时候,就是通过 ByteBuf 进行的。

我们可以将 ByteBuf 看作是 Netty 对 Java NIO 提供了 ByteBuffer 字节容器的封装和抽象。

有很多小伙伴可能就要问了 : 为什么不直接使用 Java NIO 提供的 ByteBuffer 呢?

因为 ByteBuffer 这个类使用起来过于复杂和繁琐。

8.2、Bootstrap 和 ServerBootstrap(启动引导类)

Bootstrap 是客户端的启动引导类/辅助类,具体使用方法如下:

        EventLoopGroup group = new NioEventLoopGroup();
        try {
            //创建客户端启动引导/辅助类:Bootstrap
            Bootstrap b = new Bootstrap();
            //指定线程模型
            b.group(group).
                    ......
            // 尝试建立连接
            ChannelFuture f = b.connect(host, port).sync();
            f.channel().closeFuture().sync();
        } finally {
            // 优雅关闭相关线程组资源
            group.shutdownGracefully();
        }

ServerBootstrap 是服务端的启动引导类/辅助类,具体使用方法如下:

        // 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            //2.创建服务端启动引导/辅助类:ServerBootstrap
            ServerBootstrap b = new ServerBootstrap();
            //3.给引导类配置两大线程组,确定了线程模型
            b.group(bossGroup, workerGroup).
                   ......
            // 6.绑定端口
            ChannelFuture f = b.bind(port).sync();
            // 等待连接关闭
            f.channel().closeFuture().sync();
        } finally {
            //7.优雅关闭相关线程组资源
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }

从上面的示例中,我们可以看出:

  1. Bootstrap 通常使用 connet() 方法连接到远程的主机和端口,作为一个 Netty TCP 协议通信中的客户端。另外,Bootstrap 也可以通过 bind() 方法绑定本地的一个端口,作为 UDP 协议通信中的一端。
  2. ServerBootstrap通常使用 bind() 方法绑定本地的端口上,然后等待客户端的连接。
  3. Bootstrap 只需要配置一个线程组— EventLoopGroup ,而 ServerBootstrap需要配置两个线程组— EventLoopGroup ,一个用于接收连接,一个用于具体的 IO 处理。

8.3、Channel(网络操作抽象类)

Channel 接口是 Netty 对网络操作抽象类。通过 Channel 我们可以进行 I/O 操作。一旦客户端成功连接服务端,就会新建一个 Channel 同该用户端进行绑定,示例代码如下:

   //  通过 Bootstrap 的 connect 方法连接到服务端
   public Channel doConnect(InetSocketAddress inetSocketAddress) {
        CompletableFuture<Channel> completableFuture = new CompletableFuture<>();
        bootstrap.connect(inetSocketAddress).addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                completableFuture.complete(future.channel());
            } else {
                throw new IllegalStateException();
            }
        });
        return completableFuture.get();
    }

比较常用的Channel接口实现类是 :

  1. NioServerSocketChannel(服务端)
  2. NioSocketChannel(客户端)

这两个 Channel 可以和 BIO 编程模型中的ServerSocket以及Socket两个概念对应上。

8.4、EventLoop(事件循环)

8.4.1、EventLoop 介绍

EventLoop(事件循环)接口可以说是 Netty 中最核心的概念了!《Netty 实战》这本书是这样介绍它的:EventLoop 定义了 Netty 的核心抽象,用于处理连接的生命周期中所发生的事件。解释:EventLoop 的主要作用实际就是负责监听网络事件并调用事件处理器进行相关 I/O 操作(读写)的处理。

8.4.2、Channel 和 EventLoop 的关系

Channel 为 Netty 网络操作(读写等操作)抽象类,EventLoop 负责处理注册到其上的Channel 的 I/O 操作,两者配合进行 I/O 操作。

8.4.3、EventloopGroup 和 EventLoop 的关系

EventLoopGroup 包含多个 EventLoop(每一个 EventLoop 通常内部包含一个线程),它管理着所有的 EventLoop 的生命周期。并且, EventLoop 处理的 I/O 事件都将在它专有的 Thread 上被处理,即 Thread 和 EventLoop 属于 1 : 1 的关系,从而保证线程安全。下图是 Netty NIO 模型对应的 EventLoop 模型。通过这个图应该可以将EventloopGroup、EventLoop、 Channel三者联系起来。

image.png

8.5、ChannelHandler是消息的具体处理器,主要负责处理客户端/服务端接收和发送的数据。

当 Channel 被创建时,它会被自动地分配到它专属的 ChannelPipeline。 一个Channel包含一个 ChannelPipeline。 ChannelPipeline 为 ChannelHandler 的链,一个 pipeline 上可以有多个 ChannelHandler。

我们可以在 ChannelPipeline 上通过 addLast() 方法添加一个或者多个ChannelHandler (一个数据或者事件可能会被多个 Handler 处理) 。当一个 ChannelHandler 处理完之后就将数据交给下一个 ChannelHandler 。

当 ChannelHandler 被添加到的 ChannelPipeline 它得到一个 ChannelHandlerContext,它代表一个 ChannelHandler 和 ChannelPipeline 之间的“绑定”。 ChannelPipeline 通过 ChannelHandlerContext来间接管理 ChannelHandler 。

image.png

8.6、ChannelHandler(消息处理器) 和 ChannelPipeline(ChannelHandler 对象链表)

下面这段代码使用过 Netty 的小伙伴应该不会陌生,我们指定了序列化编解码器以及自定义的 ChannelHandler 处理消息。

        b.group(eventLoopGroup)
                .handler(new ChannelInitializer<SocketChannel>() {
                    @Override
                    protected void initChannel(SocketChannel ch) {
                        ch.pipeline().addLast(new NettyKryoDecoder(kryoSerializer, RpcResponse.class));
                        ch.pipeline().addLast(new NettyKryoEncoder(kryoSerializer, RpcRequest.class));
                        ch.pipeline().addLast(new KryoClientHandler());
                    }
                });

8.7、ChannelFuture(操作执行结果)

public interface ChannelFuture extends Future<Void> {
    Channel channel();

    ChannelFuture addListener(GenericFutureListener<? extends Future<? super Void>> var1);
     ......

    ChannelFuture sync() throws InterruptedException;
}

Netty 中所有的 I/O 操作都为异步的,我们不能立刻得到操作是否执行成功。

不过,你可以通过 ChannelFuture 接口的 addListener() 方法注册一个 ChannelFutureListener,当操作执行成功或者失败时,监听就会自动触发返回结果。

ChannelFuture f = b.connect(host, port).addListener(future -> {
  if (future.isSuccess()) {
    System.out.println("连接成功!");
  } else {
    System.err.println("连接失败!");
  }
}).sync();

并且,你还可以通过ChannelFuture 的 channel() 方法获取连接相关联的Channel 。

Channel channel = f.channel();

另外,我们还可以通过 ChannelFuture 接口的 sync()方法让异步的操作编程同步的。

//bind()是异步的,但是,你可以通过 sync()方法将其变为同步。
ChannelFuture f = b.bind(port).sync();

8.8、NioEventLoopGroup 默认的构造函数会起多少线程?

回顾我们在上面写的服务器端的代码:

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();

为了搞清楚NioEventLoopGroup 默认的构造函数 到底创建了多少个线程,我们来看一下它的源码。

    /**
     * 无参构造函数。
     * nThreads:0
     */
    public NioEventLoopGroup() {
        //调用下一个构造方法
        this(0);
    }

    /**
     * Executor:null
     */
    public NioEventLoopGroup(int nThreads) {
        //继续调用下一个构造方法
        this(nThreads, (Executor) null);
    }

    //中间省略部分构造函数

    /**
     * RejectedExecutionHandler():RejectedExecutionHandlers.reject()
     */
    public NioEventLoopGroup(int nThreads, Executor executor, final SelectorProvider selectorProvider,final SelectStrategyFactory selectStrategyFactory) {
       //开始调用父类的构造函数
        super(nThreads, executor, selectorProvider, selectStrategyFactory, RejectedExecutionHandlers.reject());
    }

一直向下走下去的话,你会发现在 MultithreadEventLoopGroup 类中有相关的指定线程数的代码,如下:

    // 从1,系统属性,CPU核心数*2 这三个值中取出一个最大的
    //可以得出 DEFAULT_EVENT_LOOP_THREADS 的值为CPU核心数*2
    private static final int DEFAULT_EVENT_LOOP_THREADS = Math.max(1, SystemPropertyUtil.getInt("io.netty.eventLoopThreads", NettyRuntime.availableProcessors() * 2));

    // 被调用的父类构造函数,NioEventLoopGroup 默认的构造函数会起多少线程的秘密所在
    // 当指定的线程数nThreads为0时,使用默认的线程数DEFAULT_EVENT_LOOP_THREADS
    protected MultithreadEventLoopGroup(int nThreads, ThreadFactory threadFactory, Object... args) {
        super(nThreads == 0 ? DEFAULT_EVENT_LOOP_THREADS : nThreads, threadFactory, args);
    }

综上,我们发现 NioEventLoopGroup 默认的构造函数实际会起的线程数为 CPU核心数*2 。

另外,如果你继续深入下去看构造函数的话,你会发现每个NioEventLoopGroup对象内部都会分配一组NioEventLoop,其大小是 nThreads, 这样就构成了一个线程池, 一个NIOEventLoop 和一个线程相对应,这和我们上面说的 EventloopGroup 和 EventLoop关系这部分内容相对应。

9、Reactor 线程模型

Reactor 是一种经典的线程模型,Reactor 模式基于事件驱动,特别适合处理海量的 I/O 事件。Reactor 线程模型分为单线程模型、多线程模型以及主从多线程模型。

9.1、单线程 Reactor

所有的 IO 操作都由同一个 NIO 线程处理。 image.png 单线程 Reactor 的优点是对系统资源消耗特别小,但是,没办法支撑大量请求的应用场景并且处理请求的时间可能非常慢,毕竟只有一个线程在工作嘛!所以,一般实际项目中不会使用单线程 Reactor 。

为了解决这些问题,演进出了 Reactor 多线程模型。

9.2、多线程 Reactor

一个线程负责接受请求,一组 NIO 线程处理 IO 操作。

image.png 大部分场景下多线程 Reactor 模型是没有问题的,但是在一些并发连接数比较多(如百万并发)的场景下,一个线程负责接受客户端请求就存在性能问题了。

为了解决这些问题,演进出了主从多线程 Reactor 模型。

9.3、主从多线程 Reactor

一组 NIO 线程负责接受请求,一组 NIO 线程处理 IO 操作。

image.png

10、Netty 线程模型

Reactor 模式基于事件驱动,采用多路复用将事件分发给相应的 Handler 处理,非常适合处理海量 IO 的场景。
在 Netty 主要靠 NioEventLoopGroup 线程池来实现具体的线程模型的 。我们实现服务端的时候,一般会初始化两个线程组:

  1. bossGroup :接收连接。
  2. workerGroup :负责具体的处理,交由对应的 Handler 处理。

10.1、单线程模型

一个线程需要执行处理所有的 accept、read、decode、process、encode、send 事件。对于高负载、高并发,并且对性能要求比较高的场景不适用。

对应到 Netty 代码是下面这样的,使用 NioEventLoopGroup 类的无参构造函数设置线程数量的默认值就是 CPU 核心数 *2 。

  //1.eventGroup既用于处理客户端连接,又负责具体的处理。
  EventLoopGroup eventGroup = new NioEventLoopGroup(1);
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
            boobtstrap.group(eventGroup, eventGroup)
            //......

10.2、多线程模型

一个 Acceptor 线程只负责监听客户端的连接,一个 NIO 线程池负责具体处理: accept、read、decode、process、encode、send 事件。满足绝大部分应用场景,并发连接量不大的时候没啥问题,但是遇到并发连接大的时候就可能会出现问题,成为性能瓶颈。
对应到 Netty 代码是下面这样的:

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
  //3.给引导类配置两大线程组,确定了线程模型
  b.group(bossGroup, workerGroup)
    //......

image.png

10.3、主从多线程模型

从一个主线程 NIO 线程池中选择一个线程作为 Acceptor 线程,绑定监听端口,接收客户端连接的了链接,其他线程负责后续的接入认证等工作。连接建立完成后,Sub NIO 线程池负责具体处理 IO 读写。如果多线程模型无法满足你的需求的时候,可以考虑使用主从多线程模型。

// 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
  //2.创建服务端启动引导/辅助类:ServerBootstrap
  ServerBootstrap b = new ServerBootstrap();
  //3.给引导类配置两大线程组,确定了线程模型
  b.group(bossGroup, workerGroup)
    //......

image.png

11、Netty启动过程

11.1、服务端

        // 1.bossGroup 用于接收连接,workerGroup 用于具体的处理
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            //2.创建服务端启动引导/辅助类:ServerBootstrap
            ServerBootstrap b = new ServerBootstrap();
            //3.给引导类配置两大线程组,确定了线程模型
            b.group(bossGroup, workerGroup)
                    // (非必备)打印日志
                    .handler(new LoggingHandler(LogLevel.INFO))
                    // 4.指定 IO 模型
                    .channel(NioServerSocketChannel.class)
                    .childHandler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) {
                            ChannelPipeline p = ch.pipeline();
                            //5.可以自定义客户端消息的业务处理逻辑
                            p.addLast(new HelloServerHandler());
                        }
                    });
            // 6.绑定端口,调用 sync 方法阻塞知道绑定完成
            ChannelFuture f = b.bind(port).sync();
            // 7.阻塞等待直到服务器Channel关闭(closeFuture()方法获取Channel 的CloseFuture对象,然后调用sync()方法)
            f.channel().closeFuture().sync();
        } finally {
            //8.优雅关闭相关线程组资源
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }

简单解析一下服务端的创建过程具体是怎样的:
1.首先创建了两个 NioEventLoopGroup 对象实例:bossGroup 和 workerGroup。

  • bossGroup : 用于处理客户端的 TCP 连接请求。
  • workerGroup : 负责每一条连接的具体读写数据的处理逻辑,真正负责 I/O 读写操作,交由对应的 Handler 处理。
举个例子:我们把公司的老板当做 bossGroup,员工当做 workerGroup,bossGroup 在外面接完活之后,扔给 workerGroup 去处理。一般情况下我们会指定 bossGroup 的 线程数为 1(并发连接量不大的时候) ,workGroup 的线程数量为 CPU 核心数 *2 。另外,根据源码来看,使用 NioEventLoopGroup 类的无参构造函数设置线程数量的默认值就是 CPU 核心数 *2 。

2.接下来 我们创建了一个服务端启动引导/辅助类: ServerBootstrap,这个类将引导我们进行服务端的启动工作。

3.通过 .group() 方法给引导类 ServerBootstrap 配置两大线程组,确定了线程模型。通过下面的代码,我们实际配置的是多线程模型,这个在上面提到过。

    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();

4.通过channel()方法给引导类 ServerBootstrap指定了 IO 模型为NIO

  • NioServerSocketChannel :指定服务端的 IO 模型为 NIO,与 BIO 编程模型中的ServerSocket对应
  • NioSocketChannel : 指定客户端的 IO 模型为 NIO, 与 BIO 编程模型中的Socket对应

5.通过 .childHandler()给引导类创建一个ChannelInitializer ,然后指定了服务端消息的业务处理逻辑 HelloServerHandler 对象 6.调用 ServerBootstrap 类的 bind()方法绑定端口

11.2、客户端

        //1.创建一个 NioEventLoopGroup 对象实例
        EventLoopGroup group = new NioEventLoopGroup();
        try {
            //2.创建客户端启动引导/辅助类:Bootstrap
            Bootstrap b = new Bootstrap();
            //3.指定线程组
            b.group(group)
                    //4.指定 IO 模型
                    .channel(NioSocketChannel.class)
                    .handler(new ChannelInitializer<SocketChannel>() {
                        @Override
                        public void initChannel(SocketChannel ch) throws Exception {
                            ChannelPipeline p = ch.pipeline();
                            // 5.这里可以自定义消息的业务处理逻辑
                            p.addLast(new HelloClientHandler(message));
                        }
                    });
            // 6.尝试建立连接
            ChannelFuture f = b.connect(host, port).sync();
            // 7.等待连接关闭(阻塞,直到Channel关闭)
            f.channel().closeFuture().sync();
        } finally {
            group.shutdownGracefully();
        }

继续分析一下客户端的创建流程:

  1. 创建一个 NioEventLoopGroup 对象实例;
  2. 创建客户端启动的引导类是 Bootstrap;
  3. 通过 .group() 方法给引导类 Bootstrap 配置一个线程组;
  4. 通过channel()方法给引导类 Bootstrap指定了 IO 模型为NIO;
  5. 通过 .childHandler()给引导类创建一个ChannelInitializer ,然后指定了客户端消息的业务处理逻辑 HelloClientHandler 对象;
  6. 调用 Bootstrap 类的 connect()方法进行连接,这个方法需要指定两个参数,inetHost(ip 地址),inetPort(端口号)。
    public ChannelFuture connect(String inetHost, int inetPort) {
        return this.connect(InetSocketAddress.createUnresolved(inetHost, inetPort));
    }
    public ChannelFuture connect(SocketAddress remoteAddress) {
        ObjectUtil.checkNotNull(remoteAddress, "remoteAddress");
        this.validate();
        return this.doResolveAndConnect(remoteAddress, this.config.localAddress());
    }

connect 方法返回的是一个 Future 类型的对象。

public interface ChannelFuture extends Future<Void> {
  ......
}

也就是说这个方是异步的,我们通过 addListener 方法可以监听到连接是否成功,进而打印出连接信息。具体做法很简单,只需要对代码进行以下改动:

ChannelFuture f = b.connect(host, port).addListener(future -> {
  if (future.isSuccess()) {
    System.out.println("连接成功!");
  } else {
    System.err.println("连接失败!");
  }
}).sync();

12、什么是 TCP 粘包/拆包?有什么解决办法呢?

TCP 粘包/拆包 就是你基于 TCP 发送数据的时候,出现了多个字符串“粘”在了一起或者一个字符串被“拆”开的问题。比如你多次发送:“你好,你真帅啊!哥哥!”,但是客户端接收到的可能是下面这样的:

image.png

12.1、解决办法

1.使用 Netty 自带的解码器

●LineBasedFrameDecoder : 发送端发送数据包的时候,每个数据包之间以换行符作为分隔,LineBasedFrameDecoder 的工作原理是它依次遍历 ByteBuf 中的可读字节,判断是否有换行符,然后进行相应的截取。
●DelimiterBasedFrameDecoder : 可以自定义分隔符解码器,LineBasedFrameDecoder 实际上是一种特殊的 DelimiterBasedFrameDecoder 解码器。
●FixedLengthFrameDecoder: 固定长度解码器,它能够按照指定的长度对消息进行相应的拆包。如果不够指定的长度,则空格补全
●LengthFieldBasedFrameDecoder:基于长度字段的解码器,发送的数据中包含在发送的数据中。

2.自定义序列化编解码器

在 Java 中自带的有实现 Serializable 接口来实现序列化,但由于它性能、安全性等原因一般情况下是不会被使用到的。

通常情况下,我们使用 Protostuff、Hessian2、json 序列方式比较多,另外还有一些序列化性能非常好的序列化方式也是很好的选择:

  • 专门针对 Java 语言的:Kryo,FST 等等
  • 跨语言的:Protostuff(基于 protobuf 发展而来),ProtoBuf,Thrift,Avro,MsgPack 等等

13、Netty 长连接、心跳机制

13.1、TCP 长连接和短连接

TCP 在进行读写之前,server 与 client 之间必须提前建立一个连接。建立连接的过程,需要我们常说的三次握手,释放/关闭连接的话需要四次挥手。这个过程是比较消耗网络资源并且有时间延迟的。

所谓,短连接说的就是 server 端 与 client 端建立连接之后,读写完成之后就关闭掉连接,如果下一次再要互相发送消息,就要重新连接。短连接的优点很明显,就是管理和实现都比较简单,缺点也很明显,每一次的读写都要建立连接必然会带来大量网络资源的消耗,并且连接的建立也需要耗费时间。

长连接说的就是 client 向 server 双方建立连接之后,即使 client 与 server 完成一次读写,它们之间的连接并不会主动关闭,后续的读写操作会继续使用这个连接。长连接的可以省去较多的 TCP 建立和关闭的操作,降低对网络资源的依赖,节约时间。对于频繁请求资源的客户来说,非常适用长连接。

13.2、Netty 中心跳机制

在 TCP 保持长连接的过程中,可能会出现断网等网络异常出现,异常发生的时候, client 与 server 之间如果没有交互的话,它们是无法发现对方已经掉线的。为了解决这个问题, 我们就需要引入 心跳机制 。

心跳机制的工作原理是: 在 client 与 server 之间在一定时间内没有数据交互时, 即处于 idle 状态时, 客户端或服务器就会发送一个特殊的数据包给对方, 当接收方收到这个数据报文后, 也立即发送一个特殊的数据报文, 回应发送方, 此即一个 PING-PONG 交互。所以, 当某一端收到心跳消息后, 就知道了对方仍然在线, 这就确保 TCP 连接的有效性.

TCP 实际上自带的就有长连接选项,本身是也有心跳包机制,也就是 TCP 的选项:SO_KEEPALIVE。 但是,TCP 协议层面的长连接灵活性不够。所以,一般情况下我们都是在应用层协议上实现自定义心跳机制的,也就是在 Netty 层面通过编码实现。通过 Netty 实现心跳机制的话,核心类是 IdleStateHandler 。

14、Netty 的零拷贝

零复制(英语:Zero-copy;也译零拷贝)技术是指计算机执行操作时,CPU 不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。在 OS 层面上的 Zero-copy 通常指避免在 用户态(User-space) 与 内核态(Kernel-space) 之间来回拷贝数据。而在 Netty层面 ,零拷贝主要体现在对于数据操作的优化。

Netty中的运用:

  1. 使用 Netty 提供的 CompositeByteBuf 类, 可以将多个ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了各个 ByteBuf 之间的拷贝;
  2. ByteBuf 支持 slice 操作, 因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf, 避免了内存的拷贝;
  3. 通过 FileRegion 包装的FileChannel.transferTo 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel, 避免了传统通过循环 write 方式导致的内存拷贝问题。