(一)Netty必学知识点 netty基础知识点总结

 Java读源码之Netty深入剖析解析netty各大组件细节/百万级性能调优/设计模式实际运用

前言:这是一门对Java开发人员非常重要的课程,源码的学习方式是不可逃避的。Netty也是大型互联网公司面试必备的问题,如果没有分布式开发经验,在面试时提出自己阅读过Netty源码,并能清晰表达的话。这部分内容会是很重要的加分项

nioEventloop执行流程

Netty 源码阅读的思考------耗时业务到底该如何处理,处理耗时业务的第一种方式-------handler 种加入线程池

nettty启动的 时候会创建每一个nioeventloop线程会对应一个selcer、一个普通任务队列。里面的任务分为定时任务(优先级队列)和普通任务(多输入单输出队列),定时任务队列会判断截止时间是否到期,到期后会加载到普通任务队列里面执行,每隔64个会判断一下是否大于超时时间,定时任务队列里面维护着一个截止时间,截止时间到了,这个定时任务就会被拿出来放到普通任务队列里面执行。

     线程启动时会调用NioEventLoop的run方法,loop会不断循环一个过程参考博文

select -> processSelectedKeys(IO任务) -> runAllTasks(非IO任务)

I/O任务: 即selectionKey中ready的事件,如accept、connect、read、write等

非IO任务: 添加到taskQueue中的任务,如bind、channelActive等

针对这两种情况,Netty做了不同处理: 
- inEventLoop为true:直接调用scheduledTaskQueue队列的add方法 
- inEventLoop为false:通过execute方法,添加一个任务到taskQueue队列中, 
这个任务的作用就是调用scheduledTaskQueue队列的add方法,可以看到execute方法只是加到队列,异步的去执行这个任务,Netty的线程执行任务的时候,有个规律: 
- 一部分时间执行IO任务,一部分时间执行非IO任务。队列中的任务,属于非IO任务,所以添加之后也不会是马上执行,等待非IO时间到了才会去执行(后续具体分析)

为什么在非EventLoop线程需要通过任务去添加到队列?我们知道taskQueue队列是一个线程安全的队列(他的实现是MPSC队列,具体原理没研究过,有空学习一下再分析,这里只需只有这个队列时线程安全的),而taskQueue队列又只是只有EventLoop线程内部执行,所以这个任务是线程安全的。

就这样Netty非常巧妙的将一个非线程安全的队列的操作,转换成任务放到一个线程安全的队列中,让EventLoop线程去执行,而EventLoop只有一个线程,add的过程也不存在并发问题,

Selector BUG出现的原因

若Selector的轮询结果为空,也没有wakeup或新消息处理,则发生空轮询,CPU使用率100%,

Netty的解决办法

  • 对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,超过512次,则重建Selector

  • 没有阻塞就立马返回了,对应代码中处理时间小于超时时间,这个就叫JDK空轮询BUG

  • 若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。

  • ,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。

select方法执行原理

Boss线程作用是监听accept事件,并创建一个niosocketChannel,并通过chooser.next方法为这个channel分配一个nioEventloop线程,并把这个channe通过一个arrt参数注册到selectors上,设置为读事件。

如果这个时候有新任务的话或用户唤醒的话,就会中断阻塞

  当eventloop层检测到网络层有数据可读时(nio的selector返回相应的seleciontKeys),该事件会首先传递给unsafe,紧接着会调用pipeline.fireChannelRead(),将事件开始在pipeline中传递,该事件最先会有head handler处理(head.fireChannelRead()),该handler直接将事件透传给下一个inbound类型的用户handler(如果有的话),该事件依次向下传递,直到传递到tail handler。

Handlerhandler和child

在服务端的ServerBootstrap中增加了一个方法childHandler,它的目的是添加handler,用来监听已经连接的客户端的Channel的动作和状态。handler在初始化时就会执行,而childHandler会在客户端成功connect后才执行,这是两者的区别。

netty中pipeline

pipeline实在创建channel的时候被创建,是责任链设计模式,双向链表结构,有头结点和尾节点。里面数据结构是通过channelhand层层处理,Netty中,可以注册多个handler。ChannelInboundHandler按照注册的先后顺序执行;ChannelOutboundHandler按照注册的先后顺序逆序执行,

注意异常的传播机制不分出和入,一律都是从头到尾传播(如果在每个处理类中写了fire触发下一个)

Netty中的所有handler都实现自ChannelHandler接口。按照输出输出来分,分为ChannelInboundHandler、ChannelOutboundHandler两大类。ChannelInboundHandler对从客户端发往服务器的报文进行处理,一般用来执行解码、读取客户端数据、进行业务处理等;ChannelOutboundHandler对从服务器发往客户端的报文进行处理,一般用来进行编码、发送报文到客户端。

 

1 Channel的分类,新连接是怎么注册到nioeventloop线程里面,nioEeentLoop是什么时候启动的

channel是对socket的封装,主要分为客户端channel和服务端channel,nioEeentLoop是在服务器端启动绑定端口、或者新连接接入通过chooser绑定一个nioEventLoop的时候启动的。

boss线程接受accpent,创建niosockerchannel,一个连接就是一个channel,然后调用next方法获取nioeventloop线程及select,并把这个连接注册为读事件,work线程就是一个for循环的run方法,监听读事件,work线程的select阻塞方法如果有新任务接入或者定时任务截止时间快到了会中断,循环去处理读、写事件

总结: EventLoopGroup bossGroup = new NioEventLoopGroup()发生了以下事情:

1、 为NioEventLoopGroup创建数量为:处理器个数 x 2的,类型为NioEventLoop的实例。 每个NioEventLoop实例 都持有一个线程,以及一个类型为LinkedBlockingQueue的任务队列

2、线程的执行逻辑由NioEventLoop实现
3、每个NioEventLoop实例都持有一个selector,并对selector进行优化ServerBootstrap外观,NioServerSocketChannel创建,初始化,注册selector,绑定端口,接受新连接

参考文章

服务器接受客户端详细过程(Netty 接受请求过程源码分析 (基于4.1.23))

  1. 服务器轮询 Accept 事件,获取事件后调用 unsafe 的 read 方法,这个 unsafe 是 ServerSocket 的内部类,该方法内部由2部分组成。
  2. doReadMessages 用于创建 NioSocketChannel 对象,该对象包装 JDK 的 Nio Channel 客户端。该方法会像创建 ServerSocketChanel 类似创建相关的 pipeline , unsafe,config。
  3. 随后执行 执行 pipeline.fireChannelRead 方法,并将自己绑定到一个 chooser 选择器选择的 workerGroup 中的一个 EventLoop。并且注册一个0,表示注册成功,但并没有注册读(1)事件.
  4. 在注册的同时,调用用户程序中设置的 ChannelInitializer handler,向管道中添加一个自定义的处理器,随后立即删除这个 ChannelInitializer handler,为什么呢?因为初始化好了,不再需要。
  5. 其中再调用管道的 channelActive 方法中,会将曾经注册过的 Nio 事件改成读事件,开始真正的读监听。到此完成所有客户端连接的读前准备。

总的来说就是:接受连接----->创建一个新的NioSocketChannel----------->分配一个nioEventLoop线程,通过attrch参数注册到一个 这个nioEventloop线程对应的唯一一个 EventLoop 上--------> 注册selecot Read 事件。

  NioEventLoop实例创建时,同时会创建一个Selector(通过SelectorProvider.open()),即每个NioEventLoop都持有一个selector实例,由此可见,NioEventLoopGroup的线程池容量,就是线程的个数,也是Bootstrap中持有的Selector的数量。每个NioEventLoop实例内部Thread负责select多个Channel的IO事件(NIO Selector.select),如果某个Channel有事件发生,则在内部线程中直接使用此Channel的Unsafe实例进行底层实际的IO操作。简单而言,就是让每个NioEventLoop管理一组Channel。

    

    对于ServerBootstrap而言,创建2个NioEventLoopGroup,其中“bossGroup”为Acceptor 线程池,这个线程池只需要一个线程(大于1,事实上没有意义),它主要是负责accept客户端链接,并创建SocketChannel,此后从“workerGroup”线程池(reactor)中以轮询的方式(next)取出一个NioEventLoop实例,并将此Channel注册到NioEventLoop的selector上,此后将由此selector负责监测Channel上的读写事件(并由此NioEventLoop线程负责执行)。由此可见,对于ServerBootstrap而言,bossGroup中的一个线程的selector只关注SelectionKey.OPT_ACCEPT事件,将接收到的客户端Channel绑定到workerGroup中的一个线程,全局而言ServerSocketChannel和SocketChannel并没有公用一个Selector,ServerSocketChannel单独使用一个selector(线程),而众多SocketChannel将会被依次绑定到workerGroup中的每个Selector;这在高并发环境中非常有效,每个Selector响应事件的及时性更强,如果Selector异常(比如典型的空轮询的BUG,即Select方法唤醒后缺得到一个空的key列表,而死循环下去,CPU空转至100%,这是由于Epoll的BUG引起),只需要rebuild当前Selector即可(Nettyselect唤醒后,如果没有获取到事件keys列表且这种空唤醒的次数达到阀值),影响的Channel数量比较有限。同时这也是为什么开发者不能在IO线程中使用阻塞方法(比如wait)的原因,同一个Selector下的所有Channel公用一个线程,这种阻塞其实会导致当前线程下其他Channel(包括当前Channel)中后续事件的select无法进行,因为一个Selector中的所有selectionKey都会在此线程中依次执行。

2 默认情况下,Netty服务端启动多少线程?何时启动?Netty如何解决JDK空轮询bug?Netty如何保证异步串行无锁化?

吃透高并发线程模型

深入理解Netty无锁化串行设计,精心设计的reactor线程模型将
榨干你的cpu,打满你的网卡,让你的应用程序性能爆表

我的理解:无锁串行化是指无锁是因为只有一个线程去处理,不会存在线程之间竞争,串行化是多个channel公用一个nioeventloop,所以多个channel是串行执行的,业务中不能存在耗时的业务操作。

3 @Sharable注解

正常情况下同一个ChannelHandler,的不同的实例会被添加到不同的Channel管理的管线里面的,但是如果你需要全局统计一些信息,比如所有连接报错次数(exceptionCaught)等,这时候你可能需要使用单例的ChannelHandler,需要注意的是这时候ChannelHandler上需要添加@Sharable注解。

4 Netty是如何判断ChannelHandler类型的?对于ChannelHandler的添加应遵循什么顺序?用户手动触发事件传播,不同触发方式的区别?

明晰事件传播机制脉络

大动脉pipeline,处理器channelHandler,inbound、outbound
事件传播,异常传播

ChannelHandler有2种:ChannelInboundHandler和ChannelOutboundHandler,从字面意思上说,inbound即为入站,outbound为出站;对于Netty通道而言,从Socket通道中read数据进入Netty Handler的方向为inbound,从Netty handler向Socket通道write数据的方向为outbound。上述例子,我们也看到,Encoder为outbound,Decoder为inbound。简而言之,Socket中read到数据后,将会从tail到head依次执行链表中的inbound handler实例的相关方法(比如channelRead,channelReadComplete方法);当开发者通过channelHandlerContext向Socket写入数据时,将会从head到tail方向依次执行链表中的outbound handler实例的相关方法(比如write)。pipeline中的Head Handler是一个outbound实例,它处于outbound方向的最后一站,由此可见它必须处理那些“bind”、“connect”、“read”(非read数据,而是向Channel注册感兴趣的事件,在channel注册成功后执行)操作,以及使用unsafe操作Socket写入实际数据;Tail Handler是一个inbound实例,它是Socket read到数据后,第一个交付的inbound,不过从源码来看,tail似乎并没有做什么实质性的操作。

5 Netty内存类别有哪些?如何减少多线程内存分配之间的竞争?不同大小的内存是如何进行分配的? 

攻破内存分配机制

堆外内存,堆内,由于堆外内存创建较慢,引入内存池(不受GC影响)概念,采用引用计数方式,针对内存泄漏采用的是虚引用。随机抽取1%(默认 检测策略是简单,高级、偏执等策略)进行判断是否存在内存泄漏

每次创建ByteBuf时,创建一个虚引用对象A指向该ByteBuf对象,如果正常调用release()操作,则虚引用对象A和ByteBuf对象均被回收;如果ByteBuf对象不再使用(没有其他引用)但没有调用release()操作,则GC时虚引用对象A被加入ReferenceQueue中,通过判断队列是否为空,即可知道是否存在内存泄露。

 

内存池主要是将内存分配管理起来不经过JVM的内存分配,有效减小内存碎片避免内存浪费,同时也能减少频繁GC带来的性能影响;

内存池内存分配入口是PoolByteBufAllocator类,该类最终将内存分配委托给PoolArena进行;为了减少高并发下多线程内存分配碰撞带来的性能影响,PoolByteBufAllocator维护着一个PoolArena数组,线程通过轮询获取其中一个进行内存分配,进而实现锁分离;

内存分配的基本单元是PoolChunk,从PoolArena中分配获取一个PoolChunk,一个PoolChunk包含多个Page内存页,通过完全二叉树维护多个内存页用于内存分配;
--------------------- 

内存池byeBuff分配主要是通过pooledByteBufAllocator这个类进行的,首先拿到线程局部变量,根据线程局部变量里面的ared数组进行不同规格内存的分配,分配内存时,先从缓存池里面分配,没有的话,再从内存池分配

  • 优先从缓存中分配
  • 如果缓存中没有的话,从内存池看看有没有剩余可用的
  • 如果已申请的没有的话,再真正申请内存
  • 分段管理,每个内存大小范围使用不同的分配策略

可以看一下线程局部变量PoolThreadCache源码

Area数组分配内存的方法如代码所示

缓存池相关概念

对象池核心的几点:

  1. Stack相当于是一级缓存,同一个线程内的使用和回收都将使用一个Stack
  2. 每个线程都会有一个自己对应的Stack,如果回收的线程不是Stack的线程,将元素放入到Queue中
  3. 所有的Queue组合成一个链表,Stack可以从这些链表中回收元素(实现了多线程之间共享回收的实例)

内存管理机制,首先会预申请一大块内存Arena,Arena由许多Chunk组成,而每个Chunk默认由2048个page组成。Chunk通过AVL树的形式组织Page,每个叶子节点表示一个Page,而中间节点表示内存区域,节点自己记录它在整个Arena中的偏移地址。当区域被分配出去后,中间节点上的标记位会被标记,这样就表示这个中间节点以下的所有节点都已被分配了。大于8k的内存分配在poolChunkList中,而PoolSubpage用于分配小于8k的内存,它会把一个page分割成多段,进行内存分配。

 

 

 

 

 

Netty中,ByteBuf根据其内存分配机制,有2种实现:HeapByteBuf和DirectByteBuf(直接内存分配),这一点和NIO一样。Netty为了提升buffer的使用效率,减少因分配内存和内存管理带来的性能开支,又将ByteBuf分为:Pooled和Unpooled两类,即是否将ByteBuf基于对象池。最终ByteBuf子类为:PooledHeapByteBuf,PooledDirectByteBuf,UnpooledDirectByteBuf,UnpooledHeapByteBuf。不过和NIO ByteBuffer不同的时,ByteBuf并没有提供视图类,比如CharBuffer、IntBuffer等。

    HeapByteBuf:同NIO种的HeapByteBuffer,即堆内存buffer,内存的创建和回收速度较快,缺点是内存Copy,即当HeapByteBuf的数据需要与Socket(或者磁盘)进行数据交换时,需要将数据copy到相应的驱动器缓冲区中,效率稍低。

    DirectByteBuf:直接内存,或者说堆外内存,有操作系统分配,所以分配和销毁的速度稍慢,一般适用于“使用周期较长、可循环使用”的场景,优点是进行跨介质数据交换时无需数据Copy,速度稍高一些。

    通常Socket数据直接操作端使用DirectByteBuf,上层业务处理阶段(比如编解码)可以使用HeapByteBuf。

    ByteBuffer还有一个比较不友好的设计:容量不可变;即一个ByteBuffer在创建时需要指定容量(ByteBuffer.allocate(capacity)),而且将会立即申请此capacity大小的字节数组空间,此后capacity不可调整。ByteBuf则稍有不同,它在创建时指定一个maxCapacity(最大容量),不过并不会立即申请此容量大小的数组空间,当首次write数据时,才会初始一定容量的空间,此后空间动态调整直到达到maxCapacity;如果ByteBuf中已分配(或者write需要)的容量小于4M时,首先分配64个字节,此后以double的方式扩容(128,512,1024...)直到4M为止,当容量达到4M后,当空间不足时每次递增4M(而不是double);以4M为分界(为什么是4M?),采取2中扩容手段,不仅能够提高扩容效率(<4M),而且可以避免内存盲目消耗(> 4M)。

    我们会发现ByteBuf继承了一个ReferenceCounted接口,上述我们谈到的ByteBuff的子类都实现了AbstractReferenceCountedByteBuf,为什么Netty要与“引用计数器”产生关系?JVM中使用“计数器”(一种GC算法)标记对象是否“不可达”进而收回,Netty也使用了这种手段来对ByteBuf的引用进行计数,如果一个ByteBuf不被任何对象引用,那么它就需要被“回收”,这种“回收”可能是放回对象池(比如Pooled ByteBuf)或者被销毁,对于Heap类型的ByteBuf则直接将底层数组置为null,对于direct类型则直接调用本地方法释放外部内存(unsafe.freeMemory)。Netty采用“计数器”来追踪ByteBuf的生命周期,一是对Pooled ByteBuf的支撑,二是能够尽快的“发现”那些可以回收的ByteBuf(非Pooled),以便提成ByteBuf的分配和销毁的效率。(参见:“引用计数器”

4.1 为什么要有引用计数器 

Netty里四种主力的ByteBuf, 中UnpooledHeapByteBuf 底下的byte[]能够依赖JVM GC自然回收;而UnpooledDirectByteBuf底下是DirectByteBuffer,如Java堆外内存扫盲贴所述,除了等JVM GC,最好也能主动进行回收;而PooledHeapByteBuf 和 PooledDirectByteBuf,则必须要主动将用完的byte[]/ByteBuffer放回池里,否则内存就要爆掉。所以,Netty ByteBuf需要在JVM的GC机制之外,有自己的引用计数器和回收过程

  • 计数器基于 AtomicIntegerFieldUpdater,为什么不直接用AtomicInteger?因为ByteBuf对象很多,如果都把int包一层AtomicInteger花销较大,而AtomicIntegerFieldUpdater只需要一个全局的静态变量。
  • 所有ByteBuf的引用计数器初始值为1。
  • 调用release(),将计数器减1,等于零时, deallocate()被调用,各种回收。
  • 调用retain(),将计数器加1,即使ByteBuf在别的地方被人release()了,在本Class没喊cut之前,不要把它释放掉。
  • 由duplicate(), slice()和order(ByteOrder)所创建的ByteBuf,与原对象共享底下的buffer,也共享引用计数器,所以它们经常需要调用retain()来显示自己的存在。
  • 当引用计数器为0,底下的buffer已被回收,即使ByteBuf对象还在,对它的各种访问操作都会抛出异常。

4.2 内存泄漏检测 

所谓内存泄漏,主要是针对池化的ByteBuf。ByteBuf对象被JVM GC掉之前,没有调用release()去把底下的DirectByteBuffer或byte[]归还到池里,会导致池越来越大。而非池化的ByteBuf,即使像DirectByteBuf那样可能会用到System.gc(),但终归会被release掉的,不会出大事。 

Netty担心大家一定会不小心就搞出个大新闻来,因此提供了内存泄漏的监测机制。 

Netty默认就会从分配的ByteBuf里抽样出大约1%的来进行跟踪。如果泄漏,会有如下语句打印

这句话报告有泄漏的发生,提示你用-D参数,把防漏等级从默认的simple升到advanced,具体看到被泄漏的ByteBuf创建的地方和被访问的地方。 

  • 禁用(DISABLED) - 完全禁止泄露检测,省点消耗。
  • 简单(SIMPLE) - 默认等级,告诉我们取样的1%的ByteBuf是否发生了泄露,但总共一次只打印一次,看不到就没有了。
  • 高级(ADVANCED) - 告诉我们取样的1%的ByteBuf发生泄露的地方。每种类型的泄漏(创建的地方与访问路径一致)只打印一次。
  • 偏执(PARANOID) - 跟高级选项类似,但此选项检测所有ByteBuf,而不仅仅是取样的那1%。在高压力测试时,对性能有明显影响。

5.零拷贝及应用: 

零拷贝— 很贴切 — 的技巧来消除这些拷贝。使用零拷贝的应用程序要求内核直接将数据从磁盘文件拷贝到套接字,而无需通过应用程序。零拷贝不仅大大地提高了应用程序的性能,而且还减少了内核与用户模式间的上下文切换。就是在操作数据时, 不需要将数据 buffer 从一个内存区域拷贝到另一个内存区域. 因为少了一次内存的拷贝, 因此 CPU 的效率就得到的提升。

Netty的“零拷贝”主要体现在三个方面:
        Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写
        为什么说是虚拟的呢,因为CompositeChannelBuffer并没有将多个ChannelBuffer真正的组合起来,而只是保存了他们的引用,这样就避免了数据的拷贝,实现了Zero Copy. CompositeChannelBuffer实际上就是将一系列的Buffer通过数组保存起来,然后实现了ChannelBuffer 的接口,使得在上层看来,操作这些Buffer就像是操作一个单独的Buffer一样
Netty的文件传输采用了transferTo方法,它可以直接将文件缓冲区的数据发送到目标Channel,避免了传统通过循环write方式导致的内存拷贝问题

Netty 的 Zero-copy 体现在如下几个个方面:

1.Netty的接收和发送ByteBuffer采用DIRECT BUFFERS,使用堆外直接内存进行Socket读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存(HEAP BUFFERS)进行Socket读写,JVM会将堆内存Buffer拷贝一份到直接内存中,然后才写入Socket中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。

2.Netty 提供了 CompositeByteBuf 类, 它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf, 避免了传统通过内存拷贝的方式将几个小Buffer合并成一个大的Buffer。

3.通过 FileRegion 包装的FileChannel.tranferTo方法 实现文件传输, 可以直接将文件缓冲区的数据发送到目标 Channel,避免了传统通过循环write方式导致的内存拷贝问题。  

4.通过 wrap 操作, 我们可以将 byte[] 数组、ByteBuf、ByteBuffer等包装成一个 Netty ByteBuf 对象, 进而避免了拷贝操作

Zero Copy的模式中,避免了数据在用户空间和内存空间之间的拷贝,从而提高了系统的整体性能。Linux中的sendfile()以及Java NIO中的FileChannel.transferTo()方法都实现了零拷贝的功能,而在Netty中也通过在FileRegion中包装了NIO的FileChannel.transferTo()方法实现了零拷贝。

而在Netty中还有另一种形式的零拷贝(CompositeByteBuf ),即Netty允许我们将多段数据合并为一整段虚拟数据供用户使用,而过程中不需要对数据进行拷贝操作(CompositeChannelBuffer类的作用是将多个ChannelBuffer组成一个虚拟的ChannelBuffer来进行操作。为什么说是虚拟的呢,因为CompositeChannelBuffer并没有将多个ChannelBuffer真正的组合起来,而只是保存了他们的引用,这样就避免了数据的拷贝,实现了Zero Copy。 )

 CompositeByteBuf 代码实现(零拷贝)

假设我们有一份协议数据, 它由头部和消息体组成, 而头部和消息体是分别存放在两个 ByteBuf 中的, 即:

ByteBuf header = ...
ByteBuf body = ...

我们在代码处理中, 通常希望将 header 和 body 合并为一个 ByteBuf, 方便处理, 那么通常的做法是:

ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
allBuf.writeBytes(header);
allBuf.writeBytes(body);

可以看到, 我们将 header 和 body 都拷贝到了新的 allBuf 中了, 这无形中增加了两次额外的数据拷贝操作了.

那么有没有更加高效优雅的方式实现相同的目的呢? 我们来看一下 CompositeByteBuf 是如何实现这样的需求的吧.

ByteBuf header = ...
ByteBuf body = ...

CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();
compositeByteBuf.addComponents(true, header, body);

6.TCP 粘包/拆包的原因及解决方法?

  • TCP是以流的方式来处理数据,一个完整的包可能会被TCP拆分成多个包进行发送,也可能把小的封装成一个大的数据包发送。

  • TCP粘包/分包的原因:

    • 应用程序写入的字节大小大于套接字发送缓冲区的大小,会发生拆包现象,而应用程序写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘包现象;
    • 进行MSS大小的TCP分段,当TCP报文长度-TCP头部长度>MSS的时候将发生拆包
    • 以太网帧的payload(净荷)大于MTU(1500字节)进行ip分片。
  • 解决方法

    • 消息定长:FixedLengthFrameDecoder类
    • 包尾增加特殊字符分割:行分隔符类:LineBasedFrameDecoder或自定义分隔符类 :DelimiterBasedFrameDecoder
    • 将消息分为消息头和消息体:LengthFieldBasedFrameDecoder类。分为有头部的拆包与粘包、长度字段在前且有头部的拆包与粘包、多扩展头部的拆包与粘包。

编解码顶层抽象,定长解码器,行解码器,分隔符解码器,基于
长度域解码器全面分析,编码抽象,writeAndFlush深入分析

自定义长度域LenthFieldBaseFrameDecode参数有4个 第一个 长度偏移(就是从哪里开始计算长度单位)、第二个是长度几个字节占一个长度,第三个是长度调整(这里有个公式就是:定义长度+lengthAdjust=实际消息体长度)、第四个比较简单就是忽略前面几个长度,开始计算

 

7:NIOEventLoopGroup执行过程

       、主要优化在哪: Netty通过反射将selectedKeySet与sun.nio.ch.SelectorImpl中的两个field selectedKeys和publicSelectedKeys绑定,大家知道SelectorImpl原来的selectedKeys和publicSelectedKeys数据结构是HashSet,而HashSet的数据结构是数组+链表,新的数据结构是由2个数组A、B组成,初始大小是1024,避免了HashSet扩容带来的性能问题。除了扩容外,遍历效率也是一个原因,对于需要遍历selectedKeys的全部元素, 数组效率无疑是最高的。
--------------------- 

理过程主要分为三部分,顺序处理线程的任务队列,可以避免并发问题,简化了线程操作,在一个线程中处理I/O事件和处理任务(各占比50%),可以极大提高cpu利用率,但是这也要求程序员特别注意,如果你的业务非常耗时,需要自行建立一个线程池异步执行业务。

这里写图片描述

  • NioEventLoopGroup(其实是MultithreadEventExecutorGroup) 内部维护一个类型为 EventExecutor children [], 默认大小是处理器核数 * 2, 这样就构成了一个线程池,初始化EventExecutor时NioEventLoopGroup重载newChild方法,所以children元素的实际类型为NioEventLoop。

  • 线程启动时调用SingleThreadEventExecutor的构造方法,执行NioEventLoop类的run方法,首先会调用hasTasks()方法判断当前taskQueue是否有元素。如果taskQueue中有元素,执行 selectNow() 方法,最终执行selector.selectNow(),该方法会立即返回。如果taskQueue没有元素,执行 select(oldWakenUp) 方法

  • select ( oldWakenUp) 方法解决了 Nio 中的 bug,selectCnt 用来记录selector.select方法的执行次数和标识是否执行过selector.selectNow(),若触发了epoll的空轮询bug,则会反复执行selector.select(timeoutMillis),变量selectCnt 会逐渐变大,当selectCnt 达到阈值(默认512),则执行rebuildSelector方法,进行selector重建,解决cpu占用100%的bug。

  • rebuildSelector方法先通过openSelector方法创建一个新的selector。然后将old selector的selectionKey执行cancel。最后将old selector的channel重新注册到新的selector中。rebuild后,需要重新执行方法selectNow,检查是否有已ready的selectionKey。

  • 接下来调用processSelectedKeys 方法(处理I/O任务),当selectedKeys != null时,调用processSelectedKeysOptimized方法,迭代 selectedKeys 获取就绪的 IO 事件的selectkey存放在数组selectedKeys中, 然后为每个事件都调用 processSelectedKey 来处理它,processSelectedKey 中分别处理OP_READ;OP_WRITE;OP_CONNECT事件。

  • 最后调用runAllTasks方法(非IO任务),该方法首先会调用fetchFromScheduledTaskQueue方法,把scheduledTaskQueue中已经超过延迟执行时间的任务移到taskQueue中等待被执行,然后依次从taskQueue中取任务执行,每执行64个任务,进行耗时检查,如果已执行时间超过预先设定的执行时间,则停止执行非IO任务,避免非IO任务太多,影响IO任务的执行。

  • 每个NioEventLoop对应一个线程和一个Selector,NioServerSocketChannel会主动注册到某一个NioEventLoop的Selector上,NioEventLoop负责事件轮询。

  • Outbound 事件都是请求事件, 发起者是 Channel,处理者是 unsafe,通过 Outbound 事件进行通知,传播方向是 tail到head。Inbound 事件发起者是 unsafe,事件的处理者是 Channel, 是通知事件,传播方向是从头到尾。

  • ByteBuf的特点:支持自动扩容(4M),保证put方法不会抛出异常、通过内置的复合缓冲类型,实现零拷贝(zero-copy);不需要调用flip()来切换读/写模式,读取和写入索引分开;方法链;引用计数基于AtomicIntegerFieldUpdater用于内存回收;PooledByteBuf采用二叉树来实现一个内存池,集中管理内存的分配和释放,不用每次使用都新建一个缓冲区对象。UnpooledHeapByteBuf每次都会新建一个缓冲区对象。

  • 针对第二步:IO处理,Netty是做处理的,把selectKey的hashset结构用数组实现,并反射至select本身类中。首先创建了一个selector和自定义的selectedKeySet,然后通过反射的机制,把selector中的两个成员: selectedKeys和publicSelectedKeys使用我们自定义的那selectKeySet代替

  •  
    private void select(boolean oldWakenUp) throws IOException {
        Selector selector = this.selector;
        try {
            int selectCnt = 0;
            long currentTimeNanos = System.nanoTime();
            long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);
            for (;;) {
                long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
                if (timeoutMillis <= 0) {
                    if (selectCnt == 0) {
                        selector.selectNow();
                        selectCnt = 1;
                    }
                    break;
                }
 
                // If a task was submitted when wakenUp value was true, the task didn't get a chance to call
                // Selector#wakeup. So we need to check task queue again before executing select operation.
                // If we don't, the task might be pended until select operation was timed out.
                // It might be pended until idle timeout if IdleStateHandler existed in pipeline.
                if (hasTasks() && wakenUp.compareAndSet(false, true)) {
                    selector.selectNow();
                    selectCnt = 1;
                    break;
                }
  //这一步代码是设置一定时间的阻塞,看看有没有新IO时间到来
//由此可见,中断select方法主要有3个方法:定时任务、IO时间和用户唤醒
                int selectedKeys = selector.select(timeoutMillis);
                selectCnt ++;
 
                if (selectedKeys != 0 || oldWakenUp || wakenUp.get() || hasTasks() || hasScheduledTasks()) {
                    // - Selected something,
                    // - waken up by user, or
                    // - the task queue has a pending task.
                    // - a scheduled task is ready for processing
                    break;
                }
                if (Thread.interrupted()) {
                    // Thread was interrupted so reset selected keys and break so we not run into a busy loop.
                    // As this is most likely a bug in the handler of the user or it's client library we will
                    // also log it.
                    //
                    // See https://github.com/netty/netty/issues/2426
                    if (logger.isDebugEnabled()) {
                        logger.debug("Selector.select() returned prematurely because " +
                                "Thread.currentThread().interrupt() was called. Use " +
                                "NioEventLoop.shutdownGracefully() to shutdown the NioEventLoop.");
                    }
                    selectCnt = 1;
                    break;
                }
 
                long time = System.nanoTime();
                if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
                    // timeoutMillis elapsed without anything selected.
                    selectCnt = 1;
                } else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
                        selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
                    // The selector returned prematurely many times in a row.
                    // Rebuild the selector to work around the problem.
                    logger.warn(
                            "Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.",
                            selectCnt, selector);
 
                    rebuildSelector();
                    selector = this.selector;
 
                    // Select again to populate selectedKeys.
                    selector.selectNow();
                    selectCnt = 1;
                    break;
                }
 
                currentTimeNanos = time;
            }
 
            if (selectCnt > MIN_PREMATURE_SELECTOR_RETURNS) {
                if (logger.isDebugEnabled()) {
                    logger.debug("Selector.select() returned prematurely {} times in a row for Selector {}.",
                            selectCnt - 1, selector);
                }
            }
        } catch (CancelledKeyException e) {
            if (logger.isDebugEnabled()) {
                logger.debug(CancelledKeyException.class.getSimpleName() + " raised by a Selector {} - JDK bug?",
                        selector, e);
            }
            // Harmless exception - log anyway
        }
    }

上述代码 对应上图中的第一个流程,主要分为3步:

1、检查定时任务是否到期,检查是否有任务执行,二者有一就进行非阻塞select(jdk原生select)

2、如果上面都没有,则进行一定时间的阻塞式select,也就是int selectedKeys = selector.select(timeoutMillis);

3、最后判断是否空轮询,是否达到空轮询的次数,如果达到,就把重建selector,这个selector就有可能不会有空轮询的情况发生了。

netty4源码分析系列文章中分别详细介绍了echo例子中涉及到网络通讯的每一个环节,本文对echo例子中服务端和客户端依次发生的步骤做个总结:

服务端依次发生的步骤

  1. 建立服务端监听套接字ServerSocketChannel,以及对应的管道pipeline;
  2. 启动boss线程,将ServerSocketChannel注册到boss线程持有的selector中,并将注册返回的selectionKey赋值给ServerSocketChannel关联的selectionKey变量;
  3. 在ServerSocketChannel对应的管道中触发channelRegistered事件;
  4. 绑定IP和端口
  5. 触发channelActive事件,并将ServerSocketChannel关联的selectionKey的OP_ACCEPT位置为1。
  6. 客户端发起connect请求后,boss线程正在运行的select循环检测到了该ServerSocketChannel的ACCEPT事件就绪,则通过accept系统调用建立一个已连接套接字SocketChannel,并为其创建对应的管道;
  7. 在服务端监听套接字对应的管道中触发channelRead事件;
  8. channelRead事件由ServerBootstrapAcceptor的channelRead方法响应:为已连接套接字对应的管道加入ChannelInitializer处理器;启动一个worker线程,并将已连接套接字的注册任务加入到worker线程的任务队列中;
  9. worker线程执行已连接套接字的注册任务:将已连接套接字注册到worker线程持有的selector中,并将注册返回的selectionKey赋值给已连接套接字关联的selectionKey变量;在已连接套接字对应的管道中触发channelRegistered事件;channelRegistered事件由ChannelInitializer的channelRegistered方法响应:将自定义的处理器(譬如EchoServerHandler)加入到已连接套接字对应的管道中;在已连接套接字对应的管道中触发channelActive事件;channelActive事件由已连接套接字对应的管道中的inbound处理器的channelActive方法响应;将已连接套接字关联的selectionKey的OP_READ位置为1;至此,worker线程关联的selector就开始监听已连接套接字的READ事件了。
  10. 在worker线程运行的同时,Boss线程接着在服务端监听套接字对应的管道中触发channelReadComplete事件。
  11. 客户端向服务端发送消息后,worker线程正在运行的selector循环会检测到已连接套接字的READ事件就绪。则通过read系统调用将消息从套接字的接受缓冲区中读到AdaptiveRecvByteBufAllocator(可以自适应调整分配的缓存的大小)分配的缓存中;
  12. 在已连接套接字对应的管道中触发channelRead事件;
  13. channelRead事件由EchoServerHandler处理器的channelRead方法响应:执行write操作将消息存储到ChannelOutboundBuffer中;
  14. 在已连接套接字对应的管道中触发ChannelReadComplete事件;
  15. ChannelReadComplete事件由EchoServerHandler处理器的channelReadComplete方法响应:执行flush操作将消息从ChannelOutboundBuffer中flush到套接字的发送缓冲区中;

 

客户端依次发生的步骤

  1. 建立套接字SocketChannel,以及对应的管道pipeline;
  2. 启动客户端线程,将SocketChannel注册到客户端线程持有的selector中,并将注册返回的selectionKey赋值给SocketChannel关联的selectionKey变量;
  3. 触发channelRegistered事件;
  4. channelRegistered事件由ChannelInitializer的channelRegistered方法响应:将客户端自定义的处理器(譬如EchoClientHandler)按顺序加入到管道中;
  5. 向服务端发起connect请求,并将SocketChannel关联的selectionKey的OP_CONNECT位置为1;
  6. 开始三次握手,客户端线程正在运行的select循环检测到了该SocketChannel的CONNECT事件就绪,则将关联的selectionKey的OP_CONNECT位置为0,再通过调用finishConnect完成连接的建立;
  7. 触发channelActive事件;
  8. channelActive事件由EchoClientHandler的channelActive方法响应,通过调用ctx.writeAndFlush方法将消息发往服务端;
  9. 首先将消息存储到ChannelOutboundBuffer中;(如果ChannelOutboundBuffer存储的所有未flush的消息的大小超过高水位线writeBufferHighWaterMark(默认值为64 * 1024),则会触发ChannelWritabilityChanged事件)
  10. 然后将消息从ChannelOutboundBuffer中flush到套接字的发送缓冲区中;(如果ChannelOutboundBuffer存储的所有未flush的消息的大小小于低水位线,则会触发ChannelWritabilityChanged事件)

感谢慕课闪电侠,博文很优秀,参考优秀博客netty实践

  • 3
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

zhousenshan

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值