支撑百万级并发,Netty如何实现高性能内存管理

Netty做为一款高性能网络应用程序框架,实现了一套高性能内存管理机制java

经过学习其中的实现原理、算法、并发设计,有利于咱们写出更优雅、更高性能的代码;当使用Netty时碰到内存方面的问题时,也能够更高效定位排查出来node

本文基于Netty4.1.43.Final介绍其中的内存管理机制算法

ByteBuf分类

Netty使用ByteBuf对象做为数据容器,进行I/O读写操做,Netty的内存管理也是围绕着ByteBuf对象高效地分配和释放segmentfault

当讨论ByteBuf对象管理,主要从如下方面进行分类:数组

  • Pooled 和 Unpooled

Unpooled,非池化内存每次分配时直接调用系统 API 向操做系统申请ByteBuf须要的一样大小内存,用完后经过系统调用进行释放
Pooled,池化内存分配时基于预分配的一整块大内存,取其中的部分封装成ByteBuf提供使用,用完后回收到内存池中缓存

tips: Netty4默认使用Pooled的方式,可经过参数-Dio.netty.allocator.type=unpooled或pooled进行设置
  • Heap 和 Direct

Heap,指ByteBuf关联的内存JVM堆内分配,分配的内存受GC 管理
Direct,指ByteBuf关联的内存在JVM堆外分配,分配的内存不受GC管理,须要经过系统调用实现申请和释放,底层基于Java NIO的DirectByteBuffer对象安全

note: 使用堆外内存的优点在于,Java进行I/O操做时,须要传入数据所在缓冲区起始地址和长度,因为GC的存在,对象在堆中的位置每每会发生移动,致使对象地址变化,系统调用出错。为避免这种状况,当基于堆内存进行I/O系统调用时,须要将内存拷贝到堆外,而直接基于堆外内存进行I/O操做的话,能够节省该拷贝成本

池化(Pooled)对象管理

非池化对象(Unpooled),使用和释放对象仅须要调用底层接口实现,池化对象实现则复杂得多,能够带着如下问题进行研究:微信

  • 内存池管理算法是如何实现高效内存分配释放,减小内存碎片
  • 高负载下内存池不断申请/释放,如何实现弹性伸缩
  • 内存池做为全局数据,在多线程环境下如何减小锁竞争

1 算法设计

1.1 总体原理

Netty先向系统申请一整块连续内存,称为chunk,默认大小chunkSize = 16Mb,经过PoolChunk对象包装。为了更细粒度的管理,Netty将chunk进一步拆分为page,默认每一个chunk包含2048个page(pageSize = 8Kb)网络

不一样大小池化内存对象的分配策略不一样,下面首先介绍申请内存大小在(pageSize/2, chunkSize]区间范围内的池化对象的分配原理,其余大对象和小对象的分配原理后面再介绍。在同一个chunk中,Netty将page按照不一样粒度进行多层分组管理:多线程

  • 第1层,分组大小size = 1*pageSize,一共有2048个组
  • 第2层,分组大小size = 2*pageSize,一共有1024个组
  • 第3层,分组大小size = 4*pageSize,一共有512个组

...

当请求分配内存时,将请求分配的内存数向上取值到最接近的分组大小,在该分组大小的相应层级中从左至右寻找空闲分组
例如请求分配内存对象为1.5 pageSize,向上取值到分组大小2 pageSize,在该层分组中找到彻底空闲的一组内存进行分配,以下图:

当分组大小2 * pageSize的内存分配出去后,为了方便下次内存分配,分组被标记为所有已使用(图中红色标记),向上更粗粒度的内存分组被标记为部分已使用(图中黄色标记)

1.2 算法结构

Netty基于平衡树实现上面提到的不一样粒度的多层分组管理

当须要建立一个给定大小的ByteBuf,算法须要在PoolChunk中大小为chunkSize的内存中,找到第一个可以容纳申请分配内存的位置

为了方便快速查找chunk中能容纳请求内存的位置,算法构建一个基于byte数组(memoryMap)存储的彻底平衡树,该平衡树的多个层级深度,就是前面介绍的按照不一样粒度对chunk进行多层分组:

树的深度depth从0开始计算,各层节点数,每一个节点对应的内存大小以下:

depth = 0, 1 node,nodeSize = chunkSize
depth = 1, 2 nodes,nodeSize = chunkSize/2
...
depth = d, 2^d nodes, nodeSize = chunkSize/(2^d)
...
depth = maxOrder, 2^maxOrder nodes, nodeSize = chunkSize/2^{maxOrder} = pageSize

树的最大深度为maxOrder(最大阶,默认值11),经过这棵树,算法在chunk中的查找就能够转换为:

当申请分配大小为chunkSize/2^k的内存,在平衡树高度为k的层级中,从左到右搜索第一个空闲节点

数组的使用域从index = 1开始,将平衡树按照层次顺序依次存储在数组中,depth = n的第1个节点保存在memoryMap[2^n] 中,第2个节点保存在memoryMap[2^n+1]中,以此类推。

能够根据memoryMap[id]的值得出节点的使用状况,memoryMap[id]值越大,剩余的可用内存越少

  • memoryMap[id] = depth_of_id:id节点空闲, 初始状态,depth_of_id的值表明id节点在树中的深度
  • memoryMap[id] = maxOrder + 1:id节点所有已使用,节点内存已彻底分配,没有一个子节点空闲
  • depth_of_id < memoryMap[id] < maxOrder + 1:id节点部分已使用,memoryMap[id] 的值 x,表明id的子节点中,第一个空闲节点位于深度x,在深度[depth_of_id, x)的范围内没有任何空闲节点

1.3 申请/释放内存

当申请分配内存,会首先将请求分配的内存大小归一化(向上取值),经过PoolArena#normalizeCapacity()方法,取最近的2的幂的值​,例如8000byte归一化为8192byte( chunkSize/2^11 ),8193byte归一化为16384byte(chunkSize/2^10)

处理内存申请的算法在PoolChunk#allocateRun方法中,当分配已归一化处理后大小为chunkSize/2^d的内存,即须要在depth = d的层级中找到第一块空闲内存,算法从根节点开始遍历 (根节点depth = 0, id = 1),具体步骤以下:

  • 步骤1 判断是否当前节点值memoryMap[id] > d

若是是,则没法从该chunk分配内存,查找结束

  • 步骤2 判断是否节点值memoryMap[id] == d,且depth_of_id == h

若是是,当前节点是depth = d的空闲内存,查找结束,更新当前节点值为memoryMap[id] = max_order + 1,表明节点已使用,并遍历当前节点的全部祖先节点,更新节点值为各自的左右子节点值的最小值;若是否,执行步骤3

  • 步骤3 判断是否当前节点值memoryMap[id] <= d,且depth_of_id < h

若是是,则空闲节点在当前节点的子节点中,则先判断左子节点memoryMap[2 * id] <=d(判断左子节点是否可分配),若是成立,则当前节点更新为左子节点,不然更新为右子节点,而后重复步骤2

参考示例以下图,申请分配了chunkSize/2的内存

note:图中虽然index = 2的子节点memoryMap[id] = depth_of_id,但实际上节点内存已分配,由于算法是从上往下开始遍历,因此在实际处理中,节点分配内存后仅更新祖先节点的值,并无更新子节点的值

释放内存时,根据申请内存返回的id,将 memoryMap[id]更新为depth_of_id,同时设置id节点的祖先节点值为各自左右节点的最小值

1.4 巨型对象内存管理

对于申请分配大小超过chunkSize的巨型对象(huge),Netty采用的是非池化管理策略,在每次请求分配内存时单首创建特殊的非池化PoolChunk对象进行管理,内部memoryMap为null,当对象内存释放时整个Chunk内存释放,相应内存申请逻辑在PoolArena#allocateHuge()方法中,释放逻辑在PoolArena#destroyChunk()方法中

1.5 小对象内存管理

当请求对象的大小reqCapacity <= 496,归一化计算后方式是向上取最近的16的倍数,例如15规整为1五、40规整为4八、490规整为496,规整后的大小(normalizedCapacity)小于pageSize的小对象可分为2类:
微型对象(tiny):规整后为16的整倍数,如1六、3二、4八、...、496,一共31种规格
小型对象(small):规整后为2的幂的,有5十二、102四、204八、4096,一共4种规格

这些小对象直接分配一个page会形成浪费,在page中进行平衡树的标记又额外消耗更多空间,所以Netty的实现是:先PoolChunk中申请空闲page,同一个page分为相同大小规格的小内存进行存储

这些page用PoolSubpage对象进行封装,PoolSubpage内部有记录内存规格大小(elemSize)、可用内存数量(numAvail)和各个小内存的使用状况,经过long[]类型的bitmap相应bit值0或1,来记录内存是否已使用

note:应该有读者注意到,Netty申请池化内存进行归一化处理后的值更大了,例如1025byte会归一化为2048byte,8193byte归一化为16384byte,这样是否是形成了一些浪费?能够理解为是一种取舍,经过归一化处理,使池化内存分配大小规格化,大大方便内存申请和内存、内存复用,提升效率

2 弹性伸缩

前面的算法原理部分介绍了Netty如何实现内存块的申请和释放,单个chunk比较容量有限,如何管理多个chunk,构建成可以弹性伸缩内存池?

2.1 PoolChunk管理

为了解决单个PoolChunk容量有限的问题,Netty将多个PoolChunk组成链表一块儿管理,而后用PoolChunkList对象持有链表的head

将全部PoolChunk组成一个链表的话,进行遍历查找管理效率较低,所以Netty设计了PoolArena对象(arena中文是舞台、场所),实现对多个PoolChunkList、PoolSubpage的管理,线程安全控制、对外提供内存分配、释放的服务

PoolArena内部持有6个PoolChunkList,各个PoolChunkList持有的PoolChunk的使用率区间不一样:

// 容纳使用率 (0,25%) 的PoolChunk
private final PoolChunkList<T> qInit;
// [1%,50%) 
private final PoolChunkList<T> q000;
// [25%, 75%) 
private final PoolChunkList<T> q025;
// [50%, 100%) 
private final PoolChunkList<T> q050;
// [75%, 100%) 
private final PoolChunkList<T> q075;
// 100% 
private final PoolChunkList<T> q100;

6个PoolChunkList对象组成双向链表,当PoolChunk内存分配、释放,致使使用率变化,须要判断PoolChunk是否超过所在PoolChunkList的限定使用率范围,若是超出了,须要沿着6个PoolChunkList的双向链表找到新的合适PoolChunkList,成为新的head;一样的,当新建PoolChunk并分配完内存,该PoolChunk也须要按照上面逻辑放入合适的PoolChunkList中

分配归一化内存normCapacity(大小范围在[pageSize, chunkSize]) 具体处理以下:

  • 按顺序依次访问q050、q02五、q000、qInit、q075,遍历PoolChunkList内PoolChunk链表判断是否有PoolChunk能分配内存
  • 若是上面5个PoolChunkList有任意一个PoolChunk内存分配成功,PoolChunk使用率发生变动,从新检查并放入合适的PoolChunkList中,结束
  • 不然新建一个PoolChunk,分配内存,放入合适的PoolChunkList中(PoolChunkList扩容)
note:能够看到分配内存依次优先在q050 -> q025 -> q000 -> qInit -> q075的PoolChunkList的内分配,这样作的好处是,使分配后各个区间内存使用率更多处于[75,100)的区间范围内,提升PoolChunk内存使用率的同时也兼顾效率,减小在PoolChunkList中PoolChunk的遍历

当PoolChunk内存释放,一样PoolChunk使用率发生变动,从新检查并放入合适的PoolChunkList中,若是释放后PoolChunk内存使用率为0,则从PoolChunkList中移除,释放掉这部分空间,避免在高峰的时候申请过内存一直缓存在池中(PoolChunkList缩容)

PoolChunkList的额定使用率区间存在交叉,这样设计是由于若是基于一个临界值的话,当PoolChunk内存申请释放后的内存使用率在临界值上下徘徊的话,会致使在PoolChunkList链表先后来回移动

2.2 PoolSubpage管理

PoolArena内部持有2个PoolSubpage数组,分别存储tiny和small规格类型的PoolSubpage:

// 数组长度32,实际使用域从index = 1开始,对应31种tiny规格PoolSubpage
private final PoolSubpage<T>[] tinySubpagePools;
// 数组长度4,对应4种small规格PoolSubpage
private final PoolSubpage<T>[] smallSubpagePools;

相同规格大小(elemSize)的PoolSubpage组成链表,不一样规格的PoolSubpage链表的head则分别保存在tinySubpagePools 或者 smallSubpagePools数组中,以下图:

当须要分配小内存对象到PoolSubpage中时,根据归一化后的大小,计算出须要访问的PoolSubpage链表在tinySubpagePools和smallSubpagePools数组的下标,访问链表中的PoolSubpage的申请内存分配,若是访问到的PoolSubpage链表节点数为0,则建立新的PoolSubpage分配内存而后加入链表

PoolSubpage链表存储的PoolSubpage都是已分配部份内存,当内存所有分配完或者内存所有释放完的PoolSubpage会移出链表,减小没必要要的链表节点;当PoolSubpage内存所有分配完后再释放部份内存,会从新将加入链表

PoolArean内存池弹性伸缩可用下图总结:

3 并发设计

内存分配释放不可避免地会遇到多线程并发场景,不管是PoolChunk的平衡树标记或者PoolSubpage的bitmap标记都是多线程不安全,如何在线程安全的前提下尽可能提高并发性能?

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

note: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

此外,Netty还设计了缓存机制提高并发性能:当请求对象内存释放,PoolArena并无立刻释放,而是先尝试将该内存关联的PoolChunk和chunk中的偏移位置(handler变量)等信息存入PoolThreadLocalCache中的固定大小缓存队列中(若是缓存队列满了则立刻释放内存);
当请求内存分配,PoolArena会优先访问PoolThreadLocalCache的缓存队列中是否有缓存内存可用,若是有,则直接分配,提升分配效率

总结

Netty池化内存管理的设计借鉴了Facebook的jemalloc,同时也与Linux内存分配算法Buddy算法和Slab算法也有类似之处,不少分布式系统、框架的设计均可以在操做系统的设计中找到原型,学习底层原理是颇有价值的

下一篇,介绍Netty堆外内存泄漏问题的排查

参考

《scalable memory allocation using jemalloc —— Facebook》
https://engineering.fb.com/co...

《Netty入门与实战:仿写微信 IM 即时通信系统》
https://juejin.im/book/5b4bc2...

更多精彩,欢迎关注公众号 分布式系统架构
相关文章
相关标签/搜索