目录
一. 前言
二. IO 和 NIO
2.1. 面向流和面向 Buffer
2.2. 选择器
2.3. IO 和 NIO 的区别
2.4. JDK 原生 NIO 程序的问题
三. Netty 详解
3.1. Netty 的特点
3.2. Netty 的线程模型
3.2.1. 串行化处理模型
3.2.2. 并行化处理模型
3.2.3. Netty 具体线程模型
3.3. Netty 工作原理
3.3.1. Server 端工作原理
3.3.2. Client 端工作原理
3.4. Netty 的启动
3.4.1. 服务端启动流程
3.4.2. 客户端启动流程
3.5. ByteBuf
3.6. 支持池技术(如:线程池、数据库连接池)
3.6.1. ByteBufAllocator - 抽象工厂模式
3.6.2. CompositeByteBuf - 组合模式
3.6.3. ByteBufInputStream - 适配器模式
3.6.4. ReadOnlyByteBuf - 装饰器模式
3.6.5. ByteBuf - 工厂方法模式
3.7. channelHandler
3.8. NioEventLoop
3.9. 通信协议编解码
3.10. Netty 内存池和对象池
3.11. 心跳与空闲检测
3.11.1. 连接假死
3.11.2. 服务端空闲检测
3.11.3. 客户端定时心跳
3.12. 拆包粘包理论与解决
3.13. Netty 自带的拆包器
3.13.1. 固定长度的拆包器 - FixedLengthFrameDecoder
3.13.2. 行拆包器 - LineBasedFrameDecoder
3.13.3. 分隔符拆包器 - DelimiterBasedFrameDecoder
3.13.4. 基于长度域拆包器 - LengthFieldBasedFrameDecoder
四. 其他扩充
4.1. NIO EventLoopGroup 源码流程
4.2. RPC 框架的核心流程
Netty 是一款卓越的 Java框架,提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。它用较简单的抽象,隐藏 Java 网络编程底层实现的复杂性而提供一个易于使用的 API 的客户端/服务器框架。
下表总结了 Java IO 和 NIO 之间的主要区别:
IO | NIO |
---|---|
面向流 | 面向缓冲 |
阻塞 IO | 非阻塞 IO |
无 | 选择器 |
传统 IO 和 Java NIO 最大的区别是传统的 IO 是面向流,NIO 是面向 Buffer。
Java IO 面向流意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
Java NIO 的缓冲导向方法略有不同。数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。但是,还需要检查是否该缓冲区中包含所有您需要处理的数据。而且,需确保当更多的数据读入缓冲区时,不要覆盖缓冲区里尚未处理的数据。
Java NIO 的选择器允许一个单独的线程来监视多个输入通道,你可以注册多个通道使用一个选择器,然后使用一个单独的线程来“选择”通道,这些通道里已经有可以处理的输入,或者选择已经准备写入的通道,这种选择机制,使得一个单独的线程很容易来管理多个通道。
传统的 IO:
NIO:
JDK 原生也有一套网络应用程序 API,但是存在一系列问题,主要如下:
Netty 是一个异步事件驱动的网络应用框架,用于快速开发可维护的高性能服务器和客户端。Netty 对 JDK 自带的 NIO 的 API 进行了封装,解决了上述问题。
Netty 的主要特点有:高性能、可靠性、可定制性、可扩展性。
1. 高性能
2. 可靠性
1>. 链路有效监测(心跳和空闲检测)
2>. 内存保护机制
3>. 优雅停机
3. 可定制性
4. 可扩展性
可以方便进行应用层协议定制,比如 Dubbo、RocketMQ。
对于网络请求一般可以分为两个处理阶段,一是接收请求任务,二是处理网络请求。根据不同阶段处理方式分为以下几种线程模型:
这个模型中用一个线程来处理网络请求连接和任务处理,当 worker 接受到一个任务之后,就立刻进行处理,也就是说任务接受和任务处理是在同一个 worker 线程中进行的,没有进行区分。这样做存在一个很大的问题是,必须要等待某个 task 处理完成之后,才能接受处理下一个 task。
因此可以把接收任务和处理任务两个阶段分开处理,一个线程接收任务,放入任务队列,另外的线程异步处理任务队列中的任务。
由于任务处理一般比较缓慢,会导致任务队列中任务积压长时间得不到处理,这时可以使用线程池来处理。可以通过为每个线程维护一个任务队列来改进这种模型。
1. 如何理解 NioEventLoop 和 NioEventLoopGroup
2. 每个 NioEventLoop 都绑定了一个 Selector,所以在 Netty 的线程模型中,是由多个 Selector 在监听 IO 就绪事件。而 Channel 注册到 Selector。
3. 一个 Channel 绑定一个 NioEventLoop,相当于一个连接绑定一个线程,这个连接所有的ChannelHandler 都是在一个线程中执行的,避免了多线程干扰。更重要的是 ChannelPipline 链表必须严格按照顺序执行的。单线程的设计能够保证 ChannelHandler 的顺序执行。
4. 一个 NioEventLoop 的 selector 可以被多个 Channel 注册,也就是说多个 Channel 共享一个EventLoop。EventLoop 的 Selecctor 对这些 Channel 进行检查。
Server 端启动时绑定本地某个端口,将自己 NioServerSocketChannel 注册到某个 boss NioEventLoop 的 selector 上。
Server 端包含1个 boss NioEventLoopGroup 和1个 worker NioEventLoopGroup,NioEventLoopGroup 相当于1个事件循环组,这个组里包含多个事件循环 NioEventLoop,每个NioEventLoop 包含1个 selector 和1个事件循环线程。
每个 boss NioEventLoop 循环执行的任务包含3步:
每个 worker NioEventLoop 循环执行的任务包含3步:
Client 端启动时 connect 到 Server 端,建立 NioSocketChannel,并注册到某个 NioEventLoop的 selector 上。
Client 端只包含1个 NioEventLoopGroup,每个 NioEventLoop 循环执行的任务包含3步:
public class NettyServer {
public static void main(String[] args) {
NioEventLoopGroup bossGroup = new NioEventLoopGroup();
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer() {
protected void initChannel(NioSocketChannel ch) {
}
});
serverBootstrap.bind(8000);
}
}
总结:创建一个引导类,然后给他指定线程模型,IO 模型,连接读写处理逻辑,绑定端口之后,服务端就启动起来了。
对于客户端的启动来说,和服务端的启动类似,依然需要线程模型、IO 模型,以及 IO 业务处理逻辑三大参数。
public class NettyClient {
public static void main(String[] args) {
NioEventLoopGroup workerGroup = new NioEventLoopGroup();
Bootstrap bootstrap = new Bootstrap();
bootstrap
// 1.指定线程模型
.group(workerGroup)
// 2.指定 IO 类型为 NIO
.channel(NioSocketChannel.class)
// 3.IO 处理逻辑
.handler(new ChannelInitializer() {
@Override
public void initChannel(SocketChannel ch) {
}
});
// 4.建立连接
bootstrap.connect("csdn.im", 80).addListener(future -> {
if (future.isSuccess()) {
System.out.println("连接成功!");
} else {
System.err.println("连接失败!");
}
});
}
}
总结:创建一个引导类,然后给他指定线程模型,IO 模型,连接读写处理逻辑,连接上特定主机和端口,客户端就启动起来了。
ByteBuf 是一个节点容器,里面数据包括三部分:
这三段数据被两个指针给划分出来,读指针、写指针。
ByteBuf 本质上就是,它引用了一段内存,这段内存可以是堆内也可以是堆外的,然后用引用计数来控制这段内存是否需要被释放,使用读写指针来控制对 ByteBuf 的读写,可以理解为是外观模式的一种使用。
基于读写指针和容量、最大可扩容容量,衍生出一系列的读写方法,要注意 read/write 与 get/set 的区别。
多个 ByteBuf 可以引用同一段内存,通过引用计数来控制内存的释放,遵循谁 retain() 谁 release() 的原则。
ByteBuf 和 ByteBuffer 的区别:
ByteBuf 和 设计模式如下。关于设计模式可以参见《24大设计模式总结》。
在 Netty 的世界里,ByteBuf 实例通常应该由 ByteBufAllocator 来创建。
CompositeByteBuf 可以让我们把多个 ByteBuf 当成一个大 Buf 来处理,ByteBufAllocator 提供了 compositeBuffer() 工厂方法来创建 CompositeByteBuf。CompositeByteBuf 的实现使用了组合模式。
ByteBufInputStream 使用适配器模式,使我们可以把 ByteBuf 当做 Java 的 InputStream 来使用。同理,ByteBufOutputStream 允许我们把 ByteBuf 当做 OutputStream 来使用。
ReadOnlyByteBuf 用适配器模式把一个 ByteBuf 变为只读,ReadOnlyByteBuf 通过调用Unpooled.unmodifiableBuffer(ByteBuf) 方法获得:
我们很少需要直接通过构造函数来创建 ByteBuf 实例,而是通过 Allocator 来创建。从装饰器模式可以看出另外一种获得 ByteBuf 的方式是调用 ByteBuf 的工厂方法,比如:
channelHandler 只会对感兴趣的事件进行拦截和处理,Servlet 的 Filter 过滤器,负责对 IO 事件或者 IO 操作进行拦截和处理,它可以选择性地拦截和处理自己感兴趣的事件,也可以透传和终止事件的传递。
Pipeline 与 channelHandler 它们通过责任链设计模式来组织代码逻辑,并且支持逻辑的动态添加和删除。
ChannelHandler 有两大子接口:
这两个子接口分别有对应的默认实现,ChannelInboundHandlerAdapter,和 ChanneloutBoundHandlerAdapter,它们分别实现了两大接口的所有功能,默认情况下会把读写事件传播到下一个 handler。
事件的传播
AbstractChannel 直接调用了 Pipeline 的 write() 方法,因为 write 是个 outbound 事件,所以DefaultChannelPipeline 直接找到 tail 部分的 context,调用其 write() 方法:
context 的 write() 方法沿着 context 链往前找,直至找到一个 outbound 类型的 context 为止,然后调用其 invokeWrite() 方法:
NioEventLoop 除了要处理 IO 事件,主要还有:
非 IO 操作和 IO 操作各占默认值50%,底层使用 Selector(多路复用器)。
Selector BUG 出现的原因:若 Selector 的轮询结果为空,也没有 wakeup 或新消息处理,则发生空轮询,CPU 使用率100%。
Netty 的解决办法:
通信协议是为了服务端与客户端交互,双方协商出来的满足一定规则的二进制格式:
通信协议的设计:
内存池是指为了实现内存池的功能,设计一个内存结构 Chunk,其内部管理着一个大块的连续内存区域,将这个内存区域切分成均等的大小,每一个大小称之为一个 Page。将从内存池中申请内存的动作映射为从 Chunk 中申请一定数量 Page。为了方便计算和申请 Page,Chunk 内部采用完全二叉树的方式对 Page 进行管理。
对象池是指 Recycler 整个对象池的核心实现由 ThreadLocal 和 Stack 及 WrakOrderQueue 构成,接着来看 Stack 和 WrakOrderQueue 的具体实现,最后概括整体实现。
整个设计上核心的几点:
连接假死的现象是:在某一端(服务端或者客户端)看来,底层的 TCP 连接已经断开了,但是应用程序并没有捕获到,因此会认为这条连接仍然是存在的,从 TCP 层面来说,只有收到四次握手数据包或者一个 RST 数据包,连接的状态才表示已断开。
假死导致两个问题:
通常,连接假死由以下几个原因造成的:
如果能一直收到客户端发来的数据,那么可以说明这条连接还是活的,因此,服务端对于连接假死的应对策略就是空闲检测。
简化一下,我们的服务端只需要检测一段时间内,是否收到过客户端发来的数据即可,Netty 自带的 IdleStateHandler 就可以实现这个功能。
IdleStateHandler 的构造函数有四个参数,其中第一个表示读空闲时间,指的是在这段时间内如果没有数据读到,就表示连接假死;第二个是写空闲时间,指的是在这段时间如果没有写数据,就表示连接假死;第三个参数是读写空闲时间,表示在这段时间内如果没有产生数据读或者写,就表示连接假死。写空闲和读写空闲为0,表示我们不关心者两类条件;最后一个参数表示时间单位。在我们的例子中,表示的是:如果 15 秒内没有读到数据,就表示连接假死。
在一段时间之内没有读到客户端的数据,是否一定能判断连接假死呢?并不能为了防止服务端误判,我们还需要在客户端做点什么。
服务端在一段时间内没有收到客户端的数据有两种情况:
所以我们要排除第二种情况就能保证连接自然就是假死的,定期发送心跳到服务端。
实现了每隔 5 秒,向服务端发送一个心跳数据包,这个时间段通常要比服务端的空闲检测时间的一半要短一些,我们这里直接定义为空闲检测时间的三分之一,主要是为了排除公网偶发的秒级抖动。
为了排除是否是因为服务端在非假死状态下确实没有发送数据,服务端也要定期发送心跳给客户端。
TCP 是个“流”协议,所谓流,就是没有界限的一串数据。TCP 底层并不了解上层业务数据的具体含义,它会根据 TCP 缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被 TCP 拆分成多个包进行发送,也有可能把多个小的包封装成一个大的数据包发送,这就是所谓的 TCP 粘包和拆包的问题。
解决方法:
如果你的应用层协议非常简单,每个数据包的长度都是固定的,比如 100,那么只需要把这个拆包器加到 Pipeline 中,Netty 会把一个个长度为 100 的数据包(ByteBuf)传递到下一个 channelHandler。
从字面意思来看,发送端发送数据包的时候,每个数据包之间以换行符作为分隔,接收端通过 LineBasedFrameDecoder 将粘过的 ByteBuf 拆分成一个个完整的应用层数据包。
DelimiterBasedFrameDecoder 是行拆包器的通用版本,只不过我们可以自定义分隔符。
这种拆包器是最通用的一种拆包器,只要你的自定义协议中包含长度域字段,均可以使用这个拆包器来实现应用层拆包。
RPC 的目标就是要 2~8 这些步骤都封装起来,让用户对这些细节透明。Java 一般使用动态代理方式实现远程调用。