Netty基础入门实战

3,491 阅读37分钟

引言

本文在阅读竹子大佬文章的基础上,以小白视角,从0-1进行netty入门实践。 原文地址:juejin.cn/post/718284…

现如今的开发环境中,分布式/微服务架构大行其道,而分布式/微服务的根基在于网络编程,而Netty恰恰是Java网络编程领域的无冕之王。Netty这个框架相信大家定然听说过,其在Java网络编程中的地位,好比JavaEE中的Spring

Netty框架,其实这个框架是基于Java原生NIO技术的进一步封装,在其中对Java-NIO技术做了进一步增强,作者充分结合了Reactor线程模型,将Netty变为了一个基于异步事件驱动的网络框架,Netty从诞生至今共发布了五个大版本,但目前最常用的反而并非是最新的5.x系列,而是4.x系列的版本,原因在于Netty本身就是基于Java-NIO封装的,而JDK本身又很稳定,再加上5.x版本并未有太大的性能差异,因此4.x系列才是主流。

再回过头来思考一个问题:为什么Netty要二次封装原生NIO呢?相信看过NIO源码的小伙伴都清楚,原生的NIO设计的特别繁琐,而且还存在一系列安全隐患,因此Netty则是抱着简化NIO、解决隐患、提升性能等目的而研发的。

但在学习之前,大家最好有Java-IO体系、多路复用模型等相关知识的储备。

一、IO模型篇之从BIO、NIO、AIO

1、IO基本概念综述

对于IO知识,想要真正的去理解它,需要结合多线程、网络、操作系统等多方面的知识,IO最开始的定义就是指计算机的输入流和输出流,在这里主体为计算机本身,当然主体也可以是一个程序。

PS:从外部设备(如U盘、光盘等)中读取数据,这可以被称为输入,而在网络中读取一段数据,这也可以被称为输入。

最初的IO流也只有阻塞式的输入输出,但由于时代的不断进步,技术的不停迭代,慢慢的IO也会被分为很多种。

1.1、IO的分类

IO以不同的维度划分,可以被分为多种类型,比如可以从工作层面划分成磁盘IO(本地IO)和网络IO

  • 磁盘IO:指计算机本地的输入输出,从本地读取一张图片、一段音频、一个视频载入内存,这都可以被称为是磁盘IO
  • 网络IO:指计算机网络层的输入输出,比如请求/响应、下载/上传等,都能够被称为网络IO

也可以从工作模式上划分,例如常听的BIO、NIO、AIO,还可以从工作性质上分为阻塞式IO与非阻塞式IO,亦或从多线程角度也可被分为同步IO与异步IO

1.2、IO工作原理

无论是Java还是其他的语言,本质上IO读写操作的原理是类似的,编程语言开发的程序,一般都是工作在用户态空间,但由于IO读写对于计算机而言,属于高危操作,所以OS不可能100%将这些功能开放给用户态的程序使用,所以正常情况下的程序读写操作,本质上都是在调用OS内核提供的函数:read()、 write()。 也就是说,在程序中试图利用IO机制读写数据时,仅仅只是调用了内核提供的接口函数而已,本质上真正的IO操作还是由内核自己去完成的。

IO工作的过程如下:

  • ①首先在网络的网卡上或本地存储设备中准备数据,然后调用read()函数。
  • ②调用read()函数后,由内核将网络/本地数据读取到内核缓冲区中。
  • ③读取完成后向CPU发送一个中断信号,通知CPU对数据进行后续处理。
  • CPU将内核中的数据写入到对应的程序缓冲区或网络Socket接收缓冲区中。
  • ⑤数据全部写入到缓冲区后,应用程序开始对数据开始实际的处理。

在上述中提到了一个CPU中断信号的概念,这其实属于一种I/O的控制方式,IO控制方式目前主要有三种:忙等待方式、中断驱动方式以及 DMA 直接存储器方式,不过无论是何种方式,本质上的最终作用是相同的,都是读取数据的目的。

在上述IO工作过程中,其实大体可分为两部分:准备阶段和复制阶段,准备阶段是指数据从网络网卡或本地存储器读取到内核的过程,而复制阶段则是将内核缓冲区中的数据拷贝至用户态的进程缓冲区。常听的BIO、NIO、AIO之间的区别,就在于这两个过程中的操作是同步还是异步的,是阻塞还是非阻塞的。

2、Linux的五种IO模型浅析

2.1、同步阻塞式IO-BIO

locking-IO)即同步阻塞模型,这也是最初的IO模型,也就是当调用内核的read()函数后,内核在执行数据准备、复制阶段的IO操作时,应用线程都是阻塞的,所以本次IO操作则被称为同步阻塞式IO,如下:

BIO如何处理并发问题:

很简单,采用多线程实现,包括最初的IO模型也的确是这样实现的,也就是当出现一个新的IO调用时,服务器就会多一条线程去处理,因此会出现如下情况:

BIO这种模型中,为了支持并发请求,通常情况下会采用“请求:线程”1:1的模型,那此时会带来很大的弊端:

  • ①并发过高时会导致创建大量线程,而线程资源是有限的,超出后会导致系统崩溃。
  • ②并发过高时,就算创建的线程数未达系统瓶颈,但由于线程数过多也会造成频繁的上下文切换。

2.2、同步非阻塞式IO-NIO

目前大多数的NIO技术并非采用这种多线程的模型,而是基于单线程的多路复用模型实现的,Java中支持的NIO模型亦是如此。

多路复用模型该模型是基于文件描述符File Descriptor实现的,在Linux中提供了select、poll、epoll等一系列函数实现该模型,结构如下:

2.3、多路复用模型

在多路复用模型中,内核仅有一条线程负责处理所有连接,所有网络请求/连接(Socket)都会利用通道Channel注册到选择器上,然后监听器负责监听所有的连接,过程如下:

当出现一个IO操作时,会通过调用内核提供的多路复用函数,将当前连接注册到监听器上,当监听器发现该连接的数据准备就绪后,会返回一个可读条件给用户进程,然后用户进程拷贝内核准备好的数据进行处理(这里实际是读取Socket缓冲区中的数据)。

2.4、信号驱动模型

信号驱动IO模型(Signal-Driven-IO)是一种偏异步IO的模型,在该模型中引入了信号驱动的概念,在用户进程中首先会创建一个SIGIO信号处理程序,然后基于信号的模型进行处理,如下:

在该模型中,首先用户进程中会创建一个Sigio信号处理程序,然后会系统调用sigaction信号处理函数,紧接着内核会直接让用户进程中的线程返回,用户进程可在这期间干别的工作,当内核中的数据准备好之后,内核会生成一个Sigio信号,通知对应的用户进程数据已准备就绪,然后由用户进程在触发一个recvfrom的系统调用,从内核中将数据拷贝出来进行处理。

2.5、异步非阻塞式IO-AIO

AIO(Asynchronous-Non-Blocking-IO)异步非阻塞模型,该模型是真正意义上的异步非阻塞式IO,代表数据准备与复制阶段都是异步非阻塞的:

3.Java中NIO详解

BIO就是Java的传统IO模型,与其相关的实现都位于java.io包下,其通信原理是客户端、服务端之间通过Socket套接字建立管道连接,然后从管道中获取对应的输入/输出流,最后利用输入/输出流对象实现发送/接收信息,在上述Java-BIO的通信过程中,如若客户端一直没有发送消息过来,服务端则会一直等待下去,从而服务端陷入阻塞状态。同理,由于客户端也一直在等待服务端的消息,如若服务端一直未响应消息回来,客户端也会陷入阻塞状态。

Java-NIO则是JDK1.4中新引入的API,它在BIO功能的基础上实现了非阻塞式的特性,其所有实现都位于java.nio包下。NIO是一种基于通道、面向缓冲区的IO操作,相较BIO而言,它能够更为高效的对数据进行读写操作,同时与原先的BIO使用方式也大有不同。

Java-NIO是基于多路复用模型实现的,其中存在三大核心理念:Buffer (缓冲区)、 Channel (通道)、 Selector (选择器) ,与BIO还有一点不同在于:由于BIO模型中数据传输是阻塞式的,因此必须得有一条线程维护对应的Socket连接,在此期间如若未读取到数据,该线程就会一直阻塞下去。而NIO中则可以用一条线程来处理多个Socket连接,不需要为每个连接都创建一条对应的线程维护。

3.1、Buffer缓冲区

缓冲区其实本质上就是一块支持读/写操作的内存,底层是由多个内存页组成的数组,我们可以将其称之为内存块,在Java中这块内存则被封装成了Buffer对象,需要使用可直接通过已提供的API对这块内存进行操作和管理。

对于Java中缓冲区的定义,首先要明白,当缓冲区被创建出来后,同一时刻只能处于读/写中的一个状态,同一时间内不存在即可读也可写的情况。理解这点后再来看看它的成员变量,重点理解下述三个成员:

  • pasition:表示当前操作的索引位置(下一个要读/写数据的下标)。
  • capacity:表示当前缓冲区的容量大小。
  • limit:表示当前可允许操作的最大元素位置(不是下标,是正常数字)。

上个逻辑图来理解一下三者之间的关系,如下:

通过上述这个例子应该能很直观的感受出三者之间的关系,pasition是变化的,每次都会记录着下一个要操作的索引下标,当发生模式切换时,操作位会置零,因为模式切换代表新的开始。

简单了解了一下成员变量后,再来看看其中提供的一些成员方法,重点记住clear()、flip()方法,这两个方法都可以让缓冲区发生模式转换,flip()可以从写模式切换到读模式,而clear()方法本质上是清空缓冲区的意思,但清空后就代表着缓冲区回归“初始化”了,因此也可以从读模式转换到最初的写模式。

  • ①先创建对应类型的缓冲区
  • ②通过put这类方法往缓冲区中写入数据
  • ③调用flip()方法将缓冲区转换为读模式
  • ④通过get这类方法从缓冲区中读取数据
  • ⑤调用clear()、compact()方法清空缓冲区数据

xxxBuffer.allocateDirect()方法创建本地缓冲区使用,也可以通过isDirect()方法来判断一个缓冲区是否基于本地内存创建。

3.2、Channel通道

NIO中的通道与BIO中的流对象类似,但BIO中要么是输入流,要么是输出流,通常流操作都是单向传输的。而通道的功能也是用于传输数据,但它却是一个双向通道,代表着我们即可以从通道中读取对端数据,也可以使用通道向对端发送数据。

Channel通道仅被定义成了一个接口,其中提供的方法也很简单,因为具体的实现都在其子类下,Channel中常用的子类如下:

  • FileChannel:用于读取、写入、映射和操作本地文件的通道抽象类。
  • DatagramChannel:读写网络IOUDP数据的通道抽象类。
  • SocketChannel:读写网络IOTCP数据的通道抽象类。
  • ServerSocketChannel:类似于BIOServerSocket,用于监听TCP连接的通道抽象类。

Channel通道在Java中是三层定义:顶级接口→二级抽象类→三级实现类

例:ServerSocketChannel、SocketChannel

// 服务端通道抽象类
public abstract class ServerSocketChannel
    extends AbstractSelectableChannel
    implements NetworkChannel
{
    // 构造方法:需要传递一个选择器进行初始化构建
    protected ServerSocketChannel(SelectorProvider provider);
    // 打开一个ServerSocketChannel通道
    public static ServerSocketChannel open() throws IOException;
    // 绑定一个IP地址作为服务端
    public final ServerSocketChannel bind(SocketAddress local);
    // 绑定一个IP并设置并发连接数大小,超出后的连接全部拒绝
    public abstract ServerSocketChannel bind(SocketAddress local, int backlog);
    // 监听客户端连接的方法(会发生阻塞的方法)
    public abstract SocketChannel accept() throws IOException;
    // 获取一个ServerSocket对象
    public abstract ServerSocket socket();
    // .....省略其他方法......
}
public abstract class SocketChannel extends AbstractSelectableChannel
    implements ByteChannel, ScatteringByteChannel, 
               GatheringByteChannel, NetworkChannel{
    // 打开一个通道
    public static SocketChannel open();
    // 根据指定的远程地址,打开一个通道
    public static SocketChannel open(SocketAddress remote);
    // 如果调用open()方法时未给定地址,可以通过该方法连接远程地址
    public abstract boolean connect(SocketAddress remote);
    // 将当前通道绑定到本地套接字地址上
    public abstract SocketChannel bind(SocketAddress local);
    // 把当前通道注册到Selector选择器上:
    // sel:要注册的选择器、ops:事件类型、att:共享属性。
    public final SelectionKey register(Selector sel,int ops,Object att);
    // 省略其他......
    // 关闭通道    
    public final void close();
    
    // 向通道中写入数据,数据通过缓冲区的方式传递
    public abstract int write(ByteBuffer src);
    // 根据给定的起始下标和数量,将缓冲区数组中的数据写入到通道中
    public abstract long write(ByteBuffer[] srcs,int offset,int length);
    // 向通道中批量写入数据,批量写入一个缓冲区数组    
    public final long write(ByteBuffer[] srcs);
    // 从通道中读取数据(读取的数据放入到dst缓冲区中)
    public abstract int read(ByteBuffer dst);
    // 根据给定的起始下标和元素数据,在通道中批量读取数据
    public abstract long read(ByteBuffer[] dsts,int offset,int length);
    // 从通道中批量读取数据,结果放入dits缓冲区数组中
    public final long read(ByteBuffer[] dsts);
    
    // 返回当前通道绑定的本地套接字地址
    public abstract SocketAddress getLocalAddress();
    // 判断目前是否与远程地址建立上了连接关系
    public abstract boolean isConnected();
    // 判断目前是否与远程地址正在建立连接
    public abstract boolean isConnectionPending();
    // 获取当前通道连接的远程地址,null代表未连接
    public abstract SocketAddress getRemoteAddress();
    // 设置阻塞模式,true代表阻塞,false代表非阻塞
    public final SelectableChannel configureBlocking(boolean block);
    // 判断目前通道是否为打开状态
    public final boolean isOpen();
}

SocketChannel所提供的方法大体分为三类:

  • ①管理类:如打开通道、连接远程地址、绑定地址、注册选择器、关闭通道等。
  • ②操作类:读取/写入数据、批量读取/写入、自定义读取/写入等。
  • ③查询类:检查是否打开连接、是否建立了连接、是否正在连接等。

3.3、Selector选择器

SelectorNIO的核心组件,它可以负责监控一个或多个Channel通道,并能够检测出那些通道中的数据已经准备就绪,可以支持读取/写入了,因此一条线程通过绑定一个选择器,就可以实现对多个通道进行管理,最终达到一条线程处理多个连接的效果,能够在很大程度上提升网络连接的效率。

当想要实现非阻塞式IO时,那必然需要用到Selector选择器,它可以帮我们实现一个线程管理多个连接的功能。但如若想要使用选择器,那需先将对应的通道注册到选择器上,然后再调用选择器的select方法去监听注册的所有通道。

不过在向选择器注册通道时,需要为通道绑定一个或多个事件,注册后选择器会根据通道的事件进行切换,只有当通道读/写事件发生时,才会触发读写,因而可通过Selector选择器实现一条线程管理多个通道。当然,选择器一共支持4种事件:

  • SelectionKey.OP_READ/1:读取就绪事件,通道内的数据已就绪可被读取。
  • SelectionKey.OP_WRITE/4:写入就绪事件,一个通道正在等待数据写入。
  • SelectionKey.OP_CONNECT/8:连接就绪事件,通道已成功连接到服务端。
  • SelectionKey.OP_ACCEPT/16:接收就绪事件,服务端通道已准备好接收新的连接。

当一个通道注册时,会为其绑定对应的事件,当该通道触发了一个事件,就代表着该事件已经准备就绪,可以被线程操作了。当然,如果要为一条通道绑定多个事件,那可通过位或操作符拼接。

注意: ①并非所有的通道都可使用选择器,比如FileChannel无法支持非阻塞特性,因此不能与Selector一起使用(使用选择器的前提是:通道必须处于非阻塞模式)。 ②同时,并非所有的事件都支持任意通道,比如OP_ACCEPT事件则仅能提供给ServerSocketChannel使用。

3.4 NIO案例

二、Netty 入门案例

再回过头来思考一个问题:为什么Netty要二次封装原生NIO呢?原生的NIO设计的特别繁琐,而且还存在一系列安全隐患,因此Netty则是抱着简化NIO、解决隐患、提升性能等目的而研发的。

三、Netty核心组件介绍

1、启动器-ServerBootstrap、Bootstrap

2、事件组-EventLoopGroup、EventLoop

EventLoop这东西翻译过来就是事件循环的意思,你可以把它理解成NIO中的Selector选择器,实际它本质上就是这玩意儿,因为内部会维护一个Selector,然后由一条线程会循环处理Channel通道上发生的所有事件,所以每个EventLoop对象都可以看成一个单线程执行器。

EventLoopGroup则可以理解成一个有序的定时调度线程池,负责管理所有的EventLoop

3、Netty中的增强版通道(ChannelFuture)

NioServerSocketChannel:通用的NIO通道模型,也是Netty的默认通道。

EpollServerSocketChannel:对应Linux系统下的epoll多路复用函数。

KQueueServerSocketChannel:对应Mac系统下的kqueue多路复用函数。

OioServerSocketChannel:对应原本的BIO模型,用的较少,一般用原生的。

Netty中的绑定、连接等这些操作都是异步的,我们可以通过监听的方式实现全异步

ChannelFuture cf = client.connect("127.0.0.1", 8888);
cf.addListener((ChannelFutureListener) cfl -> {
    // 这里可以用cf,也可以用cfl,返回的都是同一个channel通道
    cf.channel().writeAndFlush("...");
});

当通过connect()方法与服务端建立连接时,Netty会将这个任务交给当前Bootstrap绑定的EventLoopGroup中的线程执行,因此建立连接的过程是异步的,所以会返还一个ChannelFuture对象给我们,而此时可以通过该对象的addListener()方法编写成功回调逻辑,当连接建立成功后,会由对应的线程来执行其中的代码,因此可以实现全过程的异步操作。

4.通道处理器(Handler)

Handler可谓是整个Netty框架中最为重要的一部分,它的职责主要是用于处理Channel通道上的各种事件,所有的处理器都可被大体分为两类:

  • 入站处理器:一般都是ChannelInboundHandlerAdapter以及它的子类实现。
  • 出站处理器:一般都是ChannelOutboundHandlerAdapter以及它的子类实现。

4.1、pipeline处理器链表

4.2、自定义出/入站处理器

// 自定义的入站处理器
public class ZhuziHandler extends ChannelInboundHandlerAdapter {
    public ZhuziHandler() {
        super();
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
        // 在这里面编写处理入站msg的核心代码.....
        // (如果要自定义msg的处理逻辑,请记住去掉下面这行代码)
        super.channelRead(ctx, msg);
    }
}
// 自定义的通道初始化器
public class ServerInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel socketChannel) throws Exception {
        // 设置编码器、解码器、处理器
        ChannelPipeline pipeline = socketChannel.pipeline();
        pipeline.addLast("decoder", new StringDecoder());
        pipeline.addLast("encoder", new StringEncoder());
        pipeline.addLast("handler", new ZhuziHandler());
    }
}
server
    .group(group)
    .channel(NioServerSocketChannel.class)
    .childHandler(new ServerInitializer())
    .bind("127.0.0.1",8888);

5、ByteBuffer

其作用主要是用来作为服务端和客户端之间传输数据的容器,NIO中的ByteBuffer支持使用堆内存、本地(直接)内存来创建,而Netty-ByteBuf也同样如此,如下:

  • ByteBufAllocator.DEFAULT.heapBuffer(cap):使用堆内存来创建ByteBuf对象。
  • ByteBufAllocator.DEFAULT.directBuffer(cap):使用本地内存来创建ByteBuf对象。

5.1、NettyByteBuffer 增强---池化技术

使用池化技术后,一方面能有效避免OOM问题产生,同时还可以省略等待创建缓冲区的时间,那Netty中的池化技术,什么时候会开启呢?这个要分平台!

  • Android系统默认会采用非池化技术,而其他系统,如Linux、Mac、Windows等会默认启用。

但上述这条原则是Netty4.1版本之后才加入的,因为4.1之前的版本,其内部的池化技术还不够完善,所以4.1之前的版本默认会禁用池化技术。当然,如果你在某些平台下想自行决定是否开启池化,可通过下述参数控制:

  • -Dio.netty.allocator.type=unpooled:关闭池化技术。
  • -Dio.netty.allocator.type=pooled:开启池化技术。

5.2 、NettyByteBuffer 增强---动态扩容机制

NIOBuffer其内部的实现有些傻,每个Buffer对象都拥有一根limit指针,这根指针用于控制读取/写入模式,因此在使用NIO-Buffer时,每次写完缓冲区后,都需要调用flip()方法来反转指针,以此来确保NIO-Buffer的正常读写。

由于Java-NIO中的Buffer设计有些缺德,因此在使用NIO的原生Buffer对象时,就显得额外麻烦,必须要遵从如下步骤:

  • ①先创建对应类型的缓冲区
  • ②通过put这类方法往缓冲区中写入数据
  • ③调用flip()方法将缓冲区转换为读模式
  • ④通过get这类方法从缓冲区中读取数据
  • ⑤调用clear()、compact()方法清空缓冲区数据

而正是由于Java-NIO原生的Buffer设计的不合理,因此Netty中直接重构了整个缓冲区组件,在Netty-ByteBuf中,存在四个核心属性:

  • initialCapacity:初始容量,创建缓冲区时指定的容量大小,默认为256字节。
  • maxCapacity:最大容量,当初始容量不足以供给使用时,ByteBuf的最大扩容限制。
  • readerIndex:读取指针,默认为0,当读取一部分数据时,指针会随之移动。
  • writerIndex:写入指针,默认为0,当写入一部分数据时,指针会随之移动。

PS:在使用完一个ByteBuf对象后,明确后续不会用到该对象时,一定要记得手动调用release()清空引用计数,否则会导致该缓冲区长久占用内存,最终引发内存泄漏。

ReferenceCountUtil.release()时,由于String并未实现ReferenceCounted接口,所以无法对该msg进行释放,最终就会造成内存泄漏问题。

5.3、NettyByteBuffer 增强---零拷贝实现

5.3.1、零拷贝介绍

所谓的零拷贝,并不是不需要经过数据拷贝,而是减少内存拷贝的次数

在没有零拷贝之前,客户端要下载的文件都位于Nginx所在的服务器磁盘中,如果当一个客户端请求下载某个资源文件时,这时需要经过的步骤如下:

一次文件下载传统的IO流程,需要经过四次切态,四次数据拷贝( **CPU、DMA**各两次) ,而所谓的零拷贝,并不是指不需要经过数据拷贝,而是指减少其中的数据拷贝次数。

Linux中提供了多种零拷贝的实现:

  • MMAP共享内存 + write()系统函数。

如果内核缓冲区和用户缓冲区使用了MMAP共享内存,那当DMA控制器将数据拷贝至内核缓冲区时,因为这里的内核缓冲区,本质是一个虚拟内存地址指向用户缓冲区,所以DMA会直接将磁盘数据拷贝至用户缓冲区,这就减少了一次内核缓冲区到用户缓冲区的CPU拷贝过程,后续直接调用write()函数把数据写到Socket缓冲区即可,因此这也是一种零拷贝的体现。

  • sendfile()内核函数。

相较于原本的MMAP+write()的方式,使用sendfile()函数来处理IO请求,这显然性能更佳,因为这里不仅仅减少了一次CPU拷贝,而且还减少了两次切态的过程。

  • ③结合DMA-Scatter/Gather Copy收集拷贝功能实现的sendfile()函数。(需要硬件支持)

优化后的sendfile()函数,拷贝数据时只需要告知out_fd、in_fd、count即可,然后DMA控制器会直接将数据从磁盘拷贝至网卡,而无需经过CPU将数据拷贝至Socket缓冲区这一步。

  • splice()内核函数。

splice()函数的作用和DMA-Scatter/Gather版的sendfile()函数完全相同,但与其不同的是:splice() 函数不仅不需要硬件支持,而且能够做到两个文件描述符之间的数据零拷贝,实现的过程是基于一端的管道文件描述符,在两个FD之间搭建pipeline管道,从而实现两个FD之间的数据零拷贝。

5.3.2、NIO零拷贝

Java-NIO中,主要有三个方面用到了零拷贝技术:

  • MappedByteBuffer.map():底层调用了操作系统的mmap()内核函数。
  • DirectByteBuffer.allocateDirect():可以直接创建基于本地内存的缓冲区。
  • FileChannel.transferFrom()/transferTo():底层调用了sendfile()内核函数。

5.3.3、Netty零拷贝

Netty中的零拷贝与前面操作系统层面的零拷贝不同,它是一种用户进程级别的零拷贝体现,主要也包含三方面:

Netty的发送、接收数据的ByteBuf缓冲区,默认会使用堆外本地内存创建,采用直接内存进行Socket读写,数据传输时无需经过二次拷贝。如果使用传统的堆内存进行Socket网络数据读写,JVM需要先将堆内存中的数据拷贝一份到直接内存,然后才写入Socket缓冲区中,相较于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

Netty的文件传输采用了transferTo()/transferFrom()方法,它可以直接将文件缓冲区的数据发送到目标Channel(Socket),底层就是调用了sendfile()内核函数,避免了文件数据的CPU拷贝过程。

Netty提供了组合、拆解ByteBuf对象的API,咱们可以基于一个ByteBuf对象,对数据进行拆解,也可以基于多个ByteBuf对象进行数据合并,这个过程中不会出现数据拷贝。ByteBuf中主要有slice()、composite()这两个方法,用于拆分、合并缓冲区。

这种零拷贝方式,虽然减少了数据复制次数,但也会有一定的局限性:

①使用slice()方法拆分出的ByteBuf对象,不支持扩容,也就是切割的长度为5,最大长度也只能是5,超出长度时会抛出下标越界异常。 ②由于拆分出的ByteBuf对象,其数据依赖于原ByteBuf对象,因此当原始ByteBuf对象被释放时,拆分出的缓冲区也会不可用,所以在使用slice()方法时,要手动调用retain()/release()来增加引用计数。

③除开上述的slice()方法外,还有其他一个叫做duplicate()的零拷贝方法,它的作用是完全克隆原有ByteBuf对象,但读写指针都是独立的,并且支持自动扩容。想要将多个缓冲区合并成一个大的缓冲区,需要先创建一个CompositeByteBuf对象,接着调用它的addComponent()/addComponents()方法,将小的缓冲区添加进去即可。但在合并多个缓冲区时,addComponents()方法中的第一个参数必须为true,否则不会自动增长读写指针。

另外Netty内部还提供了一个名为Unpooled的工具类,这主要是针对于非池化缓冲区的工具类,内部也提供了一系列wrappend开头的方法,可以用来组合、包装多个ByteBuf对象或字节数组,调用对应方法时,内部也不会发生拷贝动作,这也是一类零拷贝的方法。

四、Netty进阶网络粘包、半包问题、解码器与长连接、心跳机制

1、粘包、半包

1.1 、名词解释

粘包:这种现象就如同其名,指通信双方中的一端发送了多个数据包,但在另一端则被读取成了一个数据包,比如客户端发送123、ABC两个数据包,但服务端却收成的却是123ABC这一个数据包。造成这个问题的本质原因,在前面TCP的章节中讲过,这主要是因为TPC为了优化传输效率,将多个小包合并成一个大包发送,同时多个小包之间没有界限分割造成的。

半包:指通信双方中的一端发送一个大的数据包,但在另一端被读取成了多个数据包,例如客户端向服务端发送了一个数据包:ABCDEFGXYZ,而服务端则读取成了ABCEFG、XYZ两个包,这两个包实际上都是一个数据包中的一部分,这个现象则被称之为半包问题(产生这种现象的原因在于:接收方的数据接收缓冲区过小导致的)。

1.2 、案例演示

2、粘包、半包问题的产生原因

粘包和半包问题,可能会由多方面因素导致,如下:

  • 粘包:发送12345、ABCDE两个数据包,被接收成12345ABCDE一个数据包,多个包粘在一起。

    • 应用层:接收方的接收缓冲区太大,导致读取多个数据包一起输出。
    • TCP滑动窗口:接收方窗口较大,导致发送方发出多个数据包,处理不及时造成粘包。
    • Nagle算法:由于发送方的数据包体积过小,导致多个数据包合并成一个包发送。
  • 半包:发送12345ABCDE一个数据包,被接收成12345、ABCDE两个数据包,一个包拆成多个。

    • 应用层:接收方缓冲区太小,无法存放发送方的单个数据包,因此拆开读取。
    • 滑动窗口:接收方的窗口太小,无法一次性放下完整数据包,只能读取其中一部分。
    • MSS限制:发送方的数据包超过MSS限制,被拆分为多个数据包发送。

2.1、TCP协议的滑动窗口

由于TCP是一种可靠性传输协议,所以在网络通信过程中,会采用一问一答的形式,也就是一端发送数据后,必须得到另一端返回ACK响应后,才会继续发送后续的数据。但这种一问一答的同步方式,显然会十分影响数据的传输效率。

TCP协议为了解决传输效率的问题,引入了一种名为滑动窗口的技术,也就是在发送方和接收方上各有一个缓冲区,这个缓冲区被称为“窗口”,假设发送方的窗口大小为100KB,那么发送端的前100KB数据,无需等待接收端返回ACK,可以一直发送,直到发满100KB数据为止。如果发送端在发送前100KB数据时,接收端返回了某个数据包的ACK,那此时发送端的窗口会一直向下滑动,比如最初窗口范围是0~100KB,收到ACK后会滑动到20~120KB、120~220KB....(实际上窗口的大小、范围,TCP会根据网络拥塞程度、ACK响应时间等情况来自动调整)。

同时,除开发送方有窗口外,接收方也会有一个窗口,接收方只会读取窗口范围之内的数据,如果超出窗口范围的数据并不会读取,这也就意味着不会对窗口之外的数据包返回ACK,所以发送方在未收到ACK时,对应的窗口会停止向后滑动,并在一定时间后对未返回ACK的数据进行重发。

2.2、传输层的MSS与链路层的MTU

MSS是传输层的最大报文长度限制,而MTU则是链路层的最大数据包大小限制,一般MTU会限制MSS,比如MTU=1500,那么MSS最大只能为1500减去报文头长度,以TCP协议为例,MSS最大为1500-40=1460

为什么需要这个限制呢?这是由于网络设备硬件导致的,比如任意类型的网卡,不可能让一个数据包无限增长,因为网卡会有带宽限制,比如一次性传输一个1GB的数据包,如果不限制大小直接发送,这会导致网络出现堵塞,并且超出网络硬件设备单次传输的最大限制。所以当一个数据包,超出MSS大小时,TCP协议会自动切割这个数据包,将该数据包拆分成一个个的小包,然后分批次进行传输,从而实现大文件的传输。

2.3、TCP协议的Nagle算法

如果这种体积较小的数据包在传输中经常出现,这定然会导致网络资源的浪费,毕竟数据包中只有1字节是数据,另外40个字节是报文头,如果出现1W个这样的数据包,也就意味着会产生400MB的报文头,但实际数据只占10MB,这显然是不妥当的。正是由于上述原因,因此TCP协议中引入了一种名为Nagle的算法,如若连续几次发送的数据都很小,TCP会根据算法把多个数据合并成一个包发出,从而优化网络传输的效率,并且减少对资源的占用。

2.4、应用层的接收缓冲区和发送缓冲区

应用程序为了发送/接收数据,通常都需要具备两个缓冲区,即所说的接收缓冲区和发送缓冲区,一个用来暂存要发送的数据,另一个则用来暂存接收到的数据,同时这两个缓冲区的大小,可自行调整其大小(Netty默认的接收/发送缓冲区大小为1024KB)。

// 演示半包问题的服务端
public class HalfPackageServer {

    public static void main(String[] args) {
        EventLoopGroup group = new NioEventLoopGroup();
        ServerBootstrap server = new ServerBootstrap();

        server.group(group);
        server.channel(NioServerSocketChannel.class);
        // 调整服务端的接收缓冲区大小为16字节(最小为16,无法设置更小)
        server.childOption(ChannelOption.RCVBUF_ALLOCATOR,
                new AdaptiveRecvByteBufAllocator(16,16,16));
        server.childHandler(new ServerInitializer());
        server.bind("127.0.0.1",8888);
    }
}

3、粘包、半包问题的解决方案

3.1、使用短连接解决粘包问题

这种方式解决粘包问题,实际上属于一种“投机取巧”的方案,毕竟每个数据包都采用新的连接发送,在操作系统级别来看,每个数据包都源自于不同的网络套接字,自然会分开读取。

3.2、定长帧解码器

前面聊到的短连接方式,解决粘包问题的思路属于投机取巧行为,同时也需要频繁的建立/断开连接,这无论是从资源利用率、还是程序执行的效率上来说,都并不妥当,而Netty中提供了一系列解决粘包、半包问题的实现类,即Netty的帧解码器,案例如下:

这种采用固定长度解析数据的方式,的确能够有效避免粘包、半包问题的出现,因为每个数据包之间,会以八个字节的长度作为界限,然后分割数据。但这种方式也存在三个致命缺陷:

  • ①只适用于传输固定长度范围内的数据场景,而且客户端在发送数据前,还需自己根据长度补齐数据。
  • ②如果发送的数据超出固定长度,服务端依旧会按固定长度分包,所以仍然会存在半包问题。
  • ③对于未达到固定长度的数据,还需要额外传输补齐的*号字符,会占用不必要的网络资源。

3.3、行帧解码器、分隔符帧解码器

上面说到的定长帧解码器,由于使用时存在些许限制,使用它来解析数据就并不那么灵活,尤其是针对于一些数据长度可变的场景,显得就有些许乏力,因此Netty中还提供了行帧解码器、分隔符帧解码器

这两种解码器,依旧存在些许缺点:

  • ①对于每一个读取到的字节都需要判断一下:是否为结尾的分隔符,这会影响整体性能。
  • ②依旧存在最大长度限制,当数据超出最大长度后,会自动将其分包,在数据传输量较大的情况下,依旧会导致半包现象出现。

3.4、LTC帧解码器

maxFrameLength:数据最大长度,允许单个数据包的最大长度,超出长度后会自动分包。

lengthFieldOffset:长度字段偏移量,表示描述数据长度的信息从第几个字段开始。

lengthFieldLength:长度字段的占位大小,表示数据中的使用了几个字节描述正文长度。

lengthAdjustment:长度调整数,表示在长度字段的N个字节后才是正文数据的开始。

initialBytesToStrip:头部剥离字节数,表示先将数据去掉N个字节后,再开始读取数据。

LTC解码器这种方式处理粘包、半包问题的效率最好,因为无需逐个字节判断消息边界。但实际Netty开发中,如果其他解码器更符合业务需求,也不必死死追求使用LTC解码器,毕竟技术为业务提供服务,适合自己业务的,才是最好的!

4、Netty的长连接与心跳机制

4.1 、长连接

长连接这种模式,在并发较高的情况下能够带来额外的性能收益,因为Netty服务端、客户端绑定IP端口,搭建Channel通道的过程,放到底层实际上就是TCP三次握手的过程,同理,客户端、服务端断开连接的过程,即对应着TCP的四次挥手。TCP三次握手/四次挥手,这个过程无疑是比较“重量级”的,并发情况下,频繁创建、销毁网络连接,其资源开销、性能开销会比较大,所以使用长连接的方案,能够有效减少创建和销毁网络连接的动作。

4.2、Netty调整网络参数(ChannelOption)

  • TCP_NODELAY:开启TCPNagle算法,会将多个小包合并成一个大包发送。
  • SO_KEEPALIVE:开启长连接机制,一次数据交互完后不会立马断开连接。
// 服务端代码
server.childOption(ChannelOption.SO_KEEPALIVE, true);

// 客户端代码
client.option(ChannelOption.SO_KEEPALIVE, true);

TCP默认每两小时会发送一次心跳检测,查看对端是否还存活,如果对端由于网络故障导致下线,TCP会自动断开与对方的连接。

4.3、Netty的心跳机制

4.3.1、Netty TCP传输层心跳问题

Netty的长连接,其实本质上并不是Netty提供的长连接实现,而是通过调整参数,借助传输层TCP协议提供的长连接机制,从而实现服务端与客户端的长连接支持。不过TCP虽然提供了长连接支持,但其心跳机制并不够完善,Why?其实答案很简单,因为心跳检测的间隔时间太长了,每隔两小时才检测一次。两小时太长了,无法有效检测到机房断电、机器重启、网线拔出、防火墙更新等情况,假设一次心跳结束后,对端就出现了这些故障,依靠TCP自身的心跳频率,需要等到两小时之后才能检测到问题。而这些已经失效的连接应当及时剔除,否则会长时间占用服务端资源,毕竟服务端的可用连接数是有限的。

4.3.2、心跳机制的实现思路分析

光依靠TCP的心跳机制,这无法保障咱们的应用稳健性,因此一般开发中间件也好、通信程序也罢、亦或是RPC框架等,都会在应用层再自实现一次心跳机制,而所谓的心跳机制,也并不是特别高大上的东西,实现的思路有两种:

  • 服务端主动探测:每间隔一定时间后,向所有客户端发送一个检测信号,过程如下:

    • 假设目前有三个节点,A为服务端,B、C都为客户端。

      • A:你们还活着吗?
      • B:我还活着!
      • C:.....(假设挂掉了,无响应)
    • A收到了B的响应,但C却未给出响应,很有可能挂了,A中断与C的连接。

  • 客户端主动告知:每间隔一定时间后,客户端向服务端发送一个心跳包,过程如下:

    • 依旧是上述那三个节点。
    • B:我还活着,不要开除我!
    • C:....(假设挂掉了,不发送心跳包)
    • A:收到B的心跳包,但未收到C的心跳包,将C的网络连接断开。

一般来说,一套健全的心跳机制,都会结合上述两种方案一起实现,也就是客户端定时向服务端发送心跳包,当服务端未收到某个客户端心跳包的情况下,再主动向客户端发起探测包,这一步主要是做二次确认,防止由于网络拥塞或其他问题,导致原本客户端发出的心跳包丢失。

其实在Netty中提供了一个名为IdleStateHandler的类,它可以对一个通道上的读、写、读/写操作设置定时器,其中主要提供了三种类型的心跳检测:

// 当一个Channel(Socket)在指定时间后未触发读事件,会触发这个事件
public static final IdleStateEvent READER_IDLE_STATE_EVENT;
// 当一个Channel(Socket)在指定时间后未触发写事件,会触发这个事件
public static final IdleStateEvent WRITER_IDLE_STATE_EVENT;
// 上述读、写等待事件的结合体
public static final IdleStateEvent ALL_IDLE_STATE_EVENT;

Netty中,当一个已建立连接的通道,超出指定时间后还没有出现数据交互,对应的Channel就会进入闲置Idle状态,根据不同的Socket/Channel事件,会进入不同的闲置状态,而不同的闲置状态又会触发不同的闲置事件,也就是上述提到的三种闲置事件,在Netty中用IdleStateEvent事件类来表示。有了IdleState、userEventTriggered()这两个基础后,咱们就可基于这两个玩意儿,去实现一个简单的心跳机制,最基本的功能实现如下:

  • 客户端:在闲置一定时间后,能够主动给服务端发送心跳包。
  • 服务端:能够主动检测到未发送数据包的闲置连接,并中断连接。

4.3.3、案例