您当前的位置: 首页 > 
  • 0浏览

    0关注

    674博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

netty 重要原理分享

沙漠一只雕得儿得儿 发布时间:2021-12-03 13:50:01 ,浏览量:0

  • 回顾linux网络IO
    • 遗留问题
  • Netty特点
    • 异步非阻塞通信
    • 零拷贝
    • 内存池
      • 总结
        • 内存分配流程
        • 内存释放
      • 个人看法
    • 高效的 Reactor 线程模型
    • 无锁化的串行设计理念
    • 高效的并发编程
    • 高性能的序列化框架
    • 灵活的 TCP 参数配置能力

回顾linux网络IO

遗留问题

epoll 支持两种事件触发模式,分别是边缘触发(edge-triggered,ET)和水平触发(level-triggered,LT)。

这两个术语还挺抽象的,其实它们的区别还是很好理解的。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;

  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

Netty特点 异步非阻塞通信

在 IO 编程过程中,当需要同时处理多个客户端接入请求时,可以利用多线程或者 IO 多路复用技术进行处理。

Netty 的 IO 线程 NioEventLoop 由于聚合了多路复用器 Selector,可以同时并发处理成百上千个客户端 Channel,由于读写操作都是非阻塞的,这就可以充分提升 IO 线程的运行效率,避免由于频繁 IO 阻塞导致的线程挂起。另外,由于 Netty 采用了异步通信模式,一个 IO 线程可以并发处理 N 个客户端连接和读写操作,这从根本上解决了传统同步阻塞 IO 一连接一线程模型,架构的性能、弹性伸缩能力和可靠性都得到了极大的提升。

零拷贝

Netty 的“零拷贝”主要体现在如下三个方面:

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

  2. Netty 提供了组合 Buffer 对象,可以聚合多个 ByteBuffer 对象,用户可以像操作一个 Buffer 那样方便的对组合 Buffer 进行操作,避免了传统通过内存拷贝的方式将几个小 Buffer 合并成一个大的 Buffer。

  3. Netty 的文件传输采用了 

内存池

随着 JVM 虚拟机和 JIT 即时编译技术的发展,对象的分配和回收是个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty 提供了基于内存池的缓冲区重用机制

netty内存池的层级结构,主要分为

  • PoolArena
  • ChunkList
  • Chunk
  • Page
  • Subpage

按顺序依次访问q050、q025、q000、qInit、q075,遍历PoolChunkList内PoolChunk链表判断是否有PoolChunk能分配内存

宏观上来说,Netty对内存的管理分为两个层面。在为线程分配内存的过程中,会首先查找线程私有缓存(默认为线程开启缓存,可配置关闭),当私有缓存不满足需求时,会在内存池中查找合适的内存空间,提供给线程。:

  • 线程私有缓存 Cache 线程私有缓存因为可以避免多线程请求内存时的竞争,所以效率很高,但是也存在一些缺陷:最大缓存容量小,每个线程默认32k;使用不当可能会造成内存泄漏.
  • 全局的内存 Arena 全局共享的内存池支持堆内存和堆外内存(Direct Buffer)的申请和回收,其内存管理的粒度有以下几个单位:
    • Chunk:Netty向操作系统申请内存是以Chunk为单位申请的,内存分配也是基于Chunk。Chunk是Page为单元的集合,默认16M。
    • Page: 内存管理的最小单元,默认8K
    • SubPage: Page内内存分配单元。

Netty逻辑上将内存大小分为了tiny, small, normal, huge 几个单位。申请内存大于Chunk size 为huge,此时不在内存池中管理,由JVM负责处理;当Client申请的内存大于一个Page的大小(normal), 在Chunk内进行分配; 对tiny&small大小的内存,在一个Page页内进行分配。针对上述几种粒度的内存块的管理,其实现上包含以下几个组件(类):

  • PoolArena:内存分配中心
  • PoolChunk:负责Chunk内的内存分配
  • PoolSubpage:负责Page内的内存分配

一个chunk默认由211个页面构成,因为一个page 8k,所以默认完全二叉树11层。

Netty内存池分配出来的内存空间不是Client申请size的大小,而是大于size的最小2的幂次方(size > 512)或者是16的倍数。比如Client申请100byte的内存,那么返回的将是128byte。Netty会在入口处对申请的大小统一做规整化的处理,来保证分配出来的内存都是2的幂次方,这样做有两点好处:内存保持对齐,不会有很零散的内存碎片,这点和操作系统的内存管理类似;其次可以基于2的幂次方在二进制上的特性,大量运用位运算提升效率。后面的详细流程中我们将会看到。

Netty使用两个字节数组来表示两棵二叉树

  • memoryMap存放分配信息
    • 节点编号从1开始,省略0是因为这样更容易计算父子关系: 子节点加倍,父节点减半
    • 节点上的数字作为数组索引即id
    • 随着节点分配值改变
  • depthMap存放节点的高度信息
    • 节点编号从0开始
    • 节点上的数字作为值
    • 初始状态时,memoryMap和depthMap相等, 即memoryMap[512] = depthMap[512] = 9
    • 初始化后值不再改变

  1. private final byte[] memoryMap; //表示完全二叉树,共有4096个

  2. private final byte[] depthMap; //表示节点的层高,共有4096个

  1. memoryMap[i] = depthMap[i]:表示该节点下面的所有叶子节点都可用,这是初始状态
  2. memoryMap[i] = depthMap[i] + 1:表示该节点下面有一部分叶子节点被使用,但还有一部分叶子节点可用
  3. memoryMap[i] = maxOrder + 1 = 12:表示该节点下面的所有叶子节点不可用

其实归根到底,就是解决以下问题:

  1. 内存池管理算法是怎么做到申请效率,怎么减少内存碎片
  2. 高负载下内存池不断扩展,如何做到内存回收
  3. 内存池跟对象池作为全局数据,在多线程环境下如何减少锁竞争

为了减少线程间的竞争,Netty会提前创建多个PoolArena(默认生成数量 = 2 * CPU核心数),当线程首次请求池化内存分配,会找被最少线程持有的PoolArena,并保存线程局部变量PoolThreadCache中,实现线程与PoolArena的关联绑定(PoolThreadLocalCache#initialValue()方法)

Java自带的ThreadLocal实现线程局部变量的原理是:基于Thread的ThreadLocalMap类型成员变量,该变量中map的key为ThreadLocal,value-为需要自定义的线程局部变量值。调用ThreadLocal#get()方法时,会通过Thread.currentThread()获取当前线程访问Thread的ThreadLocalMap中的值

Netty设计了ThreadLocal的更高性能替代类:FastThreadLocal,需要配套继承Thread的类FastThreadLocalThread一起使用,基本原理是将原来Thead的基于ThreadLocalMap存储局部变量,扩展为能更快速访问的数组进行存储(Object[] indexedVariables),每个FastThreadLocal内部维护了一个全局原子自增的int类型的数组index

其他:

池化后内存的申请跟释放必然是成对出现的,那么如何做内存泄漏检测,特别是跨线程之间的申请跟释放是如何处理的?

因为采取线程级cache机制,涉及到线程结束时对象的释放,其本质机制是虚引用。从ThreadLocal、WeakHashMap(Map)都是虚引用的用法

总结

内存分配流程

Netty进行分配时,首先从对象池获取ByteBuf,之后从线程本地缓存MemoryRegionCache中查找内存页,再从Arena的内存池中查找,最后查找Chunk,分配SubPage,最后初始化Bytebuf对象。

从线程的本地缓存中获取PoolThreadCache对象,如果没有,则选择使用空间最少的Arena创建PoolThreadCache实例并保存至线程本地

使用PoolThreadCache的Arena从对象池中获取ByteBuf,对象池中默认没有释放的对象,会创建新对象。Arena根据HAS_UNSAFE判断是PooledUnsafeHeapByteBuf还是PooledHeapByteBuf进行相应处理。

根据请求的内存大小,判断其规格:tiny/small/normal/huge

  1.  若大小为tiny(0,512),从本地缓存的PoolThreadCache的MemoryRegionCache中查看是否有释放后的内存可以重用,若有则初始化PooledByteBuf;本地缓存池中没有可重用内存,先根据大小定位到在Arena的tinySubpagePools的位置idx,然后在其中查找可用的PoolSubpage,如果找到内存页,则使用内存页的chunk的初始化PoolSubpage。
  2.  若大小为small[512,pagesize],逻辑与tiny类似
  3.  tiny和small在MemoryRegionCache和tinySubpagePools/smallSubpagePools中未找到可用分配的内存页则会调用allocateNormal寻找chunk
  4.  若大小为normal(pagesize,chunksize],会先从MemoryRegionCache中查找可用的回收后的缓存,如果未找到则会调用allocateNormal寻找chunk
  5. 寻找chunk时,先从q050->q025->q000->qInit->q075查找可用的chunk,如果没有找到,会创建PoolChunk对象的实例,创建chunk时会分配实际内存(heap使用byte[],direct使用ByteBuffer.allocateDirect)。找到chunk后,在chunk中查找或创建内存页,最后返回一个Handle。Handle是一个long型整数,记录了chunk和内部的偏移。

chunk寻找内存页的过程:根据请求大小计算idx,并找到tinySubpagePools或smallSubpagePools中idx位置的链表head;再根据大小计算在chunk的memoryMap叶子节点的index,如果chunk的subpages数组中index位置为空,说明没有创建PoolSubpage,创建新的内存页之后,并将其加入到chunk的subpages数组。最后修改subpage中的bitmap,讲该内存页加入到tinySubpagePools或smallSubpagePools对应位置的链表中。最后计算出分配出的内存的Handle,0x4000000000000000L | (long) bitmapIdx

关注
打赏
1657159701
查看更多评论
立即登录/注册

微信扫码登录

0.0395s