Netty基础知识
什么是Netty?
- Netty 是一款用于高效开发网络应用的 NIO 网络框架,它大大简化了网络应用的开发过程;
- 封装了JDK底层的NIO模型,提供高度可用的API,用于快速开发高性能服务端和客户端;
- 精心设计的 Reactor 线程模型支持高并发海量连接;
- 自带编解码器解决拆包和粘包问题,用户只关心业务逻辑即可;
- 自带各种协议栈,让你处理任何一种通用协议几乎都不用亲自动手。
Netty对比Java NIO有哪些优势?
- 易用性:
- 使用 JDK NIO 编程需要了解很多复杂的概念,比如 Channels、Selectors、Sockets、Buffers 等,编码复杂程度令人发指;
- Netty 在 NIO 基础上封装了更加人性化的 API,统一的 API(阻塞/非阻塞) 大大降低了开发者的上手难度;
- Netty 提供了很多开箱即用的工具,例如常用的行解码器、长度域解码器等,而这些在 JDK NIO 中都需要你自己实现。
- 稳定性:
- Netty 更加可靠稳定,修复和完善了 JDK NIO 较多已知问题,例如臭名昭著的 select 空转导致 CPU 消耗 100%,TCP 断线重连,keep-alive 检测等问题;
- 可扩展性:
- 一个是可定制化的线程模型,用户可以通过启动的配置参数选择 Reactor 线程模型;
- 另一个是可扩展的事件驱动模型,将框架层和业务层的关注点分离。大部分情况下,开发者只需要关注 ChannelHandler 的业务逻辑实现。
- 更低的资源消耗:
- 对象池复用技术。 Netty 通过复用对象,避免频繁创建和销毁带来的开销;
- 零拷贝技术。 除了操作系统级别的零拷贝技术外,Netty 提供了更多面向用户态的零拷贝技术,例如 Netty 在 I/O 读写时直接使用 DirectBuffer,从而避免了数据在堆内存和堆外内存之间的拷贝。
Netty与Tomcat的区别是什么?
- Netty 和 Tomcat 最大的区别在于对通信协议的支持;
- Tomcat 是一个 HTTP Server,它主要解决 HTTP 协议层的传输,而 Netty 不仅支持 HTTP 协议,还支持 SSH、TLS/SSL 等多种应用层的协议,而且能够自定义应用层协议。
- Tomcat 需要遵循 Servlet 规范(HTTP协议的请求/响应模型),然而 Netty 与 Tomcat 侧重点不同,所以不需要受到 Servlet 规范的约束,可以最大化发挥 NIO 特性;
- 如果仅仅需要一个 HTTP 服务器,那么推荐使用 Tomcat。术业有专攻,Tomcat 在这方面的成熟度和稳定性更好。但如果要做面向 TCP 的网络应用开发,那么 Netty 才是最佳选择。
什么是 Reactor 线程模型?
- 上图是主从Reactor多线程模型:
- MainReactor 只负责监听连接建立事件;
- SubReactor 只负责监听读写事件;
- Reactor 主线程负责通过 Acceptor 对象处理 MainReactor 监听到的连接建立事件,当Acceptor 完成网络连接的建立之后,MainReactor 会将建立好的连接分配给 SubReactor 进行后续监听;
- 当一个连接被分配到一个 SubReactor 之上时,会由 SubReactor 负责监听该连接上的读写事件。当有新的读事件(OP_READ)发生时,SubReactor就会调用对应的 Handler 读取数据,然后分发给 Worker 线程池中的线程进行处理并返回结果。待处理结束之后,Handler 会根据处理结果调用 send 将响应返回给客户端,当然此时连接要有可写事件(OP_WRITE)才能发送数据。
- Reactor的工作流程主要分为四步:
- 连接注册:Channel 建立后,注册至 Reactor 线程中的 Selector 选择器;
- 事件轮询:轮询 Selector 选择器中已注册的所有 Channel 的 I/O 事件;
- 事件分发:为准备就绪的 I/O 事件分配相应的处理线程;
- 任务处理:Reactor 线程还负责任务队列中的非 I/O 任务,每个 Worker 线程从各自维护的任务队列中取出任务异步执行。
Netty的架构与核心主件
Netty的工作模型是什么?
- Netty 抽象出两组线程池:BossGroup 专门用于接收客户端的连接,WorkerGroup 专门用于网络的读写;
- BossGroup 和 WorkerGroup 类型都是 NioEventLoopGroup,相当于一个事件循环组,其中包含多个事件循环NioEventLoop;
- NioEventLoop 表示一个不断循环的、执行处理任务的线程,每个 NioEventLoop 都有一个Selector 对象与之对应,用于监听绑定在其上的连接,这些连接上的事件由 Selector 对应的这条线程处理;
- 每个 Boss NioEventLoop 会监听 Selector 上连接建立的 accept 事件,然后处理 accept 事件与客户端建立网络连接,生成相应的 NioSocketChannel 对象,一个 NioSocketChannel 就表示一条网络连接。之后会将 NioSocketChannel 注册到某个 Worker NioEventLoop 上的 Selector 中。
- 每个 Worker NioEventLoop 会监听对应 Selector 上的 read/write 事件,当监听到 read/write 事件的时候,会通过 Pipeline 进行处理。一个 Pipeline 与一个 Channel 绑定,在 Pipeline 上可以添加多个 ChannelHandler,每个 ChannelHandler 中都可以包含一定的逻辑,例如编解码等。Pipeline 在处理请求的时候,会按照我们指定的顺序调用 ChannelHandler。
Netty的逻辑架构是怎样的?有哪些核心组件?
- Netty 可分为网络通信层、事件调度层、服务编排层,每一层各司其职。
- 网络通信层:核心组件包含BootStrap、ServerBootStrap、Channel;
- Bootstrap:客户端启动器,只绑定一个 EventLoopGroup;
- ServerBootStrap:服务端启动器,监听本地端口,会绑定两个 EventLoopGroup,分别是 Boss 和 Worker;
- Channel:可以理解为是对Socket的封装,一个Channel代表一条新连接,对于数据的读写都可以在这条连接上操作;:
- 事件调度层:核心组件包含EventLoopGroup、EventLoop;
- EventLoopGroup的本质是线程池组,一个 EventLoopGroup 往往包含一个或者多个 EventLoop。EventLoop 用于处理 Channel 生命周期内的所有 I/O 事件,如 accept、connect、read、write 等 I/O 事件;
- EventLoop 的本质是线程池,每个 EventLoop 负责处理多个 Channel;
- 服务编排层:核心组件包括 ChannelPipeline、ChannelHandler、ChannelHandlerContext;
- ChannelPipeline:负责组装各种 ChannelHandler,内部通过双向链表将不同的 ChannelHandler 链接在一起。
- ChannelHandler:字面含义上可以反映出,ChannelHandler是用来操作Channel的,ChannelPipeline与ChannelHandler组成了一种责任链模式,一般ChannelHandler是直接面对开发者的,数据的编码解码等都是由ChannelHandler完成;
- ChannelHandlerContext:用于保存 ChannelHandler 上下文,通过 ChannelHandlerContext 我们可以知道 ChannelPipeline 和 ChannelHandler 的关联关系。
Netty组件的工作流程是怎样的?
- 服务端启动初始化时有 Boss EventLoopGroup 和 Worker EventLoopGroup 两个组件,其中 Boss 负责监听网络连接事件。当有新的网络连接事件到达时,则将 Channel 注册到 Worker EventLoopGroup;
- Worker EventLoopGroup 会被分配一个 EventLoop 负责处理该 Channel 的读写事件。每个 EventLoop 都是单线程的,通过 Selector 进行事件循环;
- 当客户端发起 I/O 读写事件时,服务端 EventLoop 会进行数据的读取,然后通过 Pipeline 触发各种监听器进行数据的加工处理;
- 客户端数据会被传递到 ChannelPipeline 的第一个 ChannelInboundHandler 中,数据处理完成后,将加工完成的数据传递给下一个 ChannelInboundHandler;
- 当数据写回客户端时,会将处理结果在 ChannelPipeline 的 ChannelOutboundHandler 中传播,最后到达客户端。
EventLoop 是一种什么模型?
- EventLoop 这个概念其实并不是 Netty 独有的,它是一种事件等待和处理的程序模型,可以解决多线程资源消耗高的问题。
- 每当事件发生时,应用程序都会将产生的事件放入事件队列当中,然后 EventLoop 会轮询从队列中取出事件执行或者将事件分发给相应的事件监听者执行。事件执行的方式通常分为立即执行、延后执行、定期执行几种。
- 在Netty 中EventLoop的实现类叫做NioEventLoop,是 Reactor 线程模型的事件处理引擎。
Netty 的无锁化设计体现在哪?
- 当accept事件触发时,事件会被注册到WorkerEventLoopGroup 中的一个 NioEventLoop 上;
- 由于每个请求的Channel都只与一个NioEventLoop绑定,所以说 Channel 生命周期的所有事件处理都是线程独立的,不同的 NioEventLoop 线程之间不会发生任何交集;
- NioEventLoop 完成数据读取后,会调用绑定的 ChannelPipeline 进行事件传播,数据在传播过程中由具体的ChannelHandler处理,整个过程是串行化执行,没有线程安全问题。
Netty 是如何解决 JDK epoll 空轮询的 Bug 的?
- Selector每次执行 select 操作之前记录当前时间 currentTimeNanos;
- 然后计算本次select的截止时间deadline;
- 根据当前时间与截止时间比较,如果超时,结束本次select轮询操作;
- 如果没有超时,且任务队列中出现任务需要处理,结束select轮询开始处理任务;
- 如果没有超时,且任务队列没有任务, 调用NIO底层的select方法进行阻塞,会一直阻塞到截止时间,同时记录轮询次数。阻塞可以被外部任务唤醒;
- 阻塞结束后,如果阻塞时间小于截止时间,说明阻塞被提前唤醒,如果唤醒没有任务,说明可能触发了空轮询的Bug;
- Netty会对空轮询次数进行统计,当次数达到一定阈值(512)时,重建Selector,将老的Selector上的Channel注册到新Selector上。
ChannelPipeline中的Inbound事件与Outbound事件的区别是什么?
- ChannelPipeline是一个双向链表结构,头尾分别维护了Head节点与Tail节点,用户自定义的ChannelHandler会被插入到Head与Tail之间;
- Inbound事件与Outbound事件是ChannelPipeline最主要的两种事件传播方式,两者的主要区别是事件类型与传播顺序;
- 传播顺序:
- Inbound事件的传播顺序为:Head -> h1 -> h2 -> h3 -> Tail;
- Outbount事件的传播顺序正好相反: Tail -> h3 -> h2 -> h1 -> Head;
- 事件类型:
- Inbound事件一般指应用程序被动接收的事件,由外部触发,例如接收了新的I/O事件,Tail节点会做一些收尾工作,如资源释放等;
- Outbount一般由应用程序主动触发,例如应用程序从socket读取或写入数据,head节点会执行操作系统底层的api完成具体的动作。
ChannelPipeline中异常传播的顺序是什么?
- 异常传播顺序与ChannelHandler的注册顺序一致,与Inbound和Outbound无关。
Netty的编解码
什么叫做拆包与粘包?
- TCP 传输协议是面向流的,没有数据包界限。客户端向服务端发送数据时,可能将一个完整的报文拆分成多个小报文进行发送,也可能将多个报文合并成一个大的报文进行发送。因此就有了拆包和粘包。
Netty如何解决半包与粘包问题的?
- FixedLengthFrameDecoder 用来解决固定大小数据包的粘包问题;
- LineBasedFrameDecoder 适合对文本进行按行分包;
- DelimiterBasedFrameDecoder 适合按特殊字符作为分包标记的场景;
- LengthFieldBasedFrameDecoder 可以支持复杂的自定义协议分包等等。
内存管理与ByteBuf
JVM堆内内存与堆外内存的区别是什么?
- 堆内内存由 JVM GC 自动回收内存,降低了 Java 用户的使用难度;
- 堆外内存不受 JVM 管理,使用后需要手动释放,如果使用不当容易造成内存泄漏,且排查问题会比较困难;
- 当进行网络 I/O 操作、文件读写时,堆内内存都需要转换为堆外内存,然后再与底层设备进行交互,所以直接使用堆外内存可以减少一次内存拷贝;
- 堆外内存可以实现进程之间、JVM 多实例之间的数据共享。
Netty的零拷贝指的是什么?
- DirectByteBuffer:
- Netty提供的DirectByteBuffer,直接将数据分配到堆外内存中,避免在 Socket 读写时缓冲数据在堆外与堆内进行频繁复制;
- CompositeByteBuf:
- 对于传统的ByteBuffer,如果需要将两个ByteBuffer中的数据组合到一起,需要首先创建一个size=size1+size2大小的新的数组,然后将两个数组中的数据拷贝到新的数组中;
- Netty利用CompositeByteBuf可以避免这种内存拷贝,因为CompositeByteBuf并没有真正将多个Buffer组合起来,而是保存了它们的引用,从而实现了零拷贝;
- FileRegion:
- Netty 使用 FileRegion 实现文件传输,FileRegion 底层封装了 FileChannel#transferTo() 方法,可以将文件缓冲区的数据直接传输到目标 Channel,避免内核缓冲区和用户态缓冲区之间的数据拷贝,这属于操作系统级别的零拷贝。
Netty如何回收堆外内存?
- 首先Netty是通过DirectByteBuffer对象分配堆外内存的,在堆内存放的 DirectByteBuffer 对象并不大,仅仅包含堆外内存的地址、大小等属性,同时还会创建对应的 Cleaner 对象,这个Cleaner是专门用来回收堆外内存的;
- Cleaner是JAVA四种引用类型中PhantomReference(虚引用)的子类,PhantomReference不能单独使用,必须与ReferenceQueue联合使用,ReferenceQueue 用于保存需要回收的 Cleaner 对象;
- 当JVM发生 GC 时,DirectByteBuffer 对象被回收,此时 Cleaner 对象不再有任何引用关系,然后被添加到ReferenceQueue中,并执行clean方法,clean() 方法主要做两件事情:
- 将 Cleaner 对象从 Cleaner 链表中移除;
- 调用 unsafe.freeMemory 方法清理堆外内存;
- 总体来说,Netty是通过虚引用的特性将堆外内存对象与堆内内存对象联系起来,然后在JVM GC时进行同步回收;
JDK NIO 的 ByteBuffer 有什么缺陷?
- ByteBuffer 分配的长度是固定的,无法动态扩缩容,所以很难控制需要分配多大的容量;
- ByteBuffer 只能通过 position 获取当前可操作的位置,因为读写共用的 position 指针,所以需要频繁调用 flip、rewind 方法切换读写状态,对使用者不友好,容易出错。
Netty 的 ByteBuf 有什么优势?
- 容量可以按需动态扩展,类似于 StringBuffer;
- 读写采用了不同的指针,读写模式可以随意切换,不需要调用 flip 方法;
- 通过内置的复合缓冲类型可以实现零拷贝;
- 支持引用计数;
- 支持缓存池。
Netty 的 ByteBuf有哪些分类?
- Pooled与Unpooled(池化与非池化)
- Pooled(池化)方式每次分配内存时都会从系统预先分配好的一段内存中来取;
- Unpooled(非池化)方式每次分配内存时都会调用系统API向操作系统申请内存创建ByteBuf;
- Unsafe与非Unsafe
- Unsafe会先计算数据的内存地址+偏移量,通过unsafe对象的native API来操作数据;
- 非Unsafe不依赖JDK的unsafe对象,它是通过数组+下标方式来获取数据,或者是通过JDK 底层的ByteBuffer API进行读写,一般而言unsafe方式效率更高一些;
- Heap与Direct
- Heap代表堆上内存分配,会被JVM GC管理;
- Direct代表堆外内存分配,调用JDK底层API进行分配系统内存,效率更高,但不受GC直接控制,需要手动释放内存。
Netty 的内存规格是怎样的?
- Chunk:Netty中所有内存都是以Chunk为单位分配的,一个Chunk有16M,例如当前需要1M内存,那么就需要向系统申请一个Chunk单位的内存,然后再从这个Chunk中进一步划分;
- Page:Chunk的划分单位为Page,一个Page有8K,那么一个Chunk就可以划分出2048个Page;
- SubPage:有时候我们需要的内存远达不到一个Page的大小,那么Netty根据实际需要对Page进一步划分成SubPage。
Netty 的内存池是如何设计的?
- Netty的内存池分四种内存规格管理内存,分别为 Tiny、Small、Normal、Huge,PoolChunk 负责管理 8K 以上的内存分配,PoolSubpage 用于管理 8K 以下的内存分配。当申请内存大于 16M 时,不会经过内存池,直接分配。
- 设计了本地线程缓存机制 PoolThreadCache,用于提升内存分配时的并发性能。用于申请 Tiny、Small、Normal 三种类型的内存时,会优先尝试从 PoolThreadCache 中分配;
- PoolChunk 使用伙伴算法管理 Page,以二叉树的数据结构实现,是整个内存池分配的核心所在;
- 每调用 PoolThreadCache 的 allocate() 方法到一定次数,会触发检查 PoolThreadCache 中缓存的使用频率,使用频率较低的内存块会被释放;
- 线程退出时,Netty 会回收该线程对应的所有内存。
Netty 的对象池是如何设计的?
- Netty 为了避免多线程竞争问题,每个线程都会持有各自的 Recycler 对象池,内部通过 FastThreadLocal 来实现每个线程的私有化;
- Recycler 有两个重要的组成部分:Stack 和 WeakOrderQueue;
- 从 Recycler 获取对象时,优先从 Stack 中查找,如果 Stack 没有可用对象,会尝试从 WeakOrderQueue 迁移部分对象到 Stack 中;
- Recycler 回收对象时,分为同线程对象回收和异线程对象回收两种情况,同线程回收直接向 Stack 中添加对象,异线程回收向 WeakOrderQueue 中的 Link 添加对象;
- 对象回收都会控制回收速率,每 8 个对象会回收一个,其他的全部丢弃。