引言
本文在阅读竹子大佬文章的基础上,以小白视角,从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
:读写网络IO
中UDP
数据的通道抽象类。SocketChannel
:读写网络IO
中TCP
数据的通道抽象类。ServerSocketChannel
:类似于BIO
的ServerSocket
,用于监听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选择器
Selector
是NIO
的核心组件,它可以负责监控一个或多个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 增强---动态扩容机制
NIO
的Buffer
其内部的实现有些傻,每个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
:开启TCP
的Nagle
算法,会将多个小包合并成一个大包发送。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()
这两个基础后,咱们就可基于这两个玩意儿,去实现一个简单的心跳机制,最基本的功能实现如下:
- 客户端:在闲置一定时间后,能够主动给服务端发送心跳包。
- 服务端:能够主动检测到未发送数据包的闲置连接,并中断连接。