netty是由JBoss开发开源的异步的基于事件驱动的网络通信框架,由于它的高性能和高可靠性在java社区被广泛使用,很多著名的中间件和框架都使用netty作为底层的网络通信框架实现远程调用,比如dubbo,zookeeper底层都使用netty框架完成网络通信。
目前主流使用的版本是netty4,netty3到netty4是一个非常大的版本更新,包括IO事件处理流程和线程模型在netty4都进行了非常大的重构,此外在netty4还提出了全新的内存管理,使用了全新的内存池来管理内存的分配和回收,通过在应用中缓存适当的内存块来缓解频繁的内存分配和GC给系统带来负担,提高IO事件处理性能。本文通过解读netty内存池源码来分析netty池的实现原理。
netty内存池参考了slab内存分配和Buddy(伙伴)分配算法,先简单了解一下这两个算法:
slab内存分配
传统的malloc-free内存分配算法容易产生内存碎片,随着内存中内存碎片越来越多,可用的大块连续内存会越来越少,这会极大影响内存分配的效率,到最后会出现总内存空间够多却无法分配内存的情况。slab分配算法的思路是划好预先规定大小的内存块,分配内存时整块内存一次分配,归还时也会归还整块内存,这样由于内存块是固定大小内存碎片问题会大大的缓解,这样缺点也很明显容易造成内存浪费,比如内存块大小是1M,即使分配1个字节也会占用1M的内存块,缓解内存浪费的策略是,预先划定多种尺寸的内存块,内存分配时,根据待分配的内存大小找到最贴近的内存块分配,通过实际的使用场景调尺寸参数可以有效的减少内存碎片和内存浪费。著名的缓存中间件memcached就是使用slab作为内存分配算法。
Buddy(伙伴)分配
Buddy分配算法的目标也是要解决内存碎片,它的大致思路是,在内存分配的过程中,不断的把大内存块的分裂两个大小相等的小内存块,直到小内存块大小贴近待分配的内存大小,归还内存时合并相邻的空闲内存块直到没有相邻的空闲内存块可合并为止,保证系统中有足够多的连续的大内存块。
netty内存池实现原理
netty内存池可以分配堆内存和非堆内存(Direct内存),内存分配的核心算法是类似的,本文从堆内存分配代码入手来学习整个内存池的原理。netty框架处理IO事件时,使用ByteBuf承载数据,本文结合给ByteBuf分配内存的过程剖析netty内存的实现原理。ByteBuf的内存分配由PooledByteBufAllocator来执行,最终的内存分配工作会被委托给PoolArena,堆内存分配的PoolArena实现是HeapArena。
netty通常被用于高并发系统,多线程竞争加锁会影响内存分配的效率,为了缓解高并发时的线程竞争,netty允许使用者创建多个分配器(PoolArena)来分离线程竞争,提高内存分配效率。可通过PooledByteBufAllocator构造子中的nHeapArena参数来设置PoolArena的数量,或者直接取框架中的默认值,通过以下代码决定默认值,默认值根据CPU核心数、JVM最大可用内存以及默认内存块(PoolChunk)大小等参数来计算这个默认值,计算逻辑是:
1)获取系统变量io.netty.allocator.numHeapArenas,通过System.setProperty("io.netty.allocator.numHeapArenas",xxx)或者增加JVM启动参数-Dio.netty.allocator.numHeapArenas=xxx设置,如果设置了值,把这个值当做Arena的个数。
2)如果没有设置io.netty.allocator.numHeapArenas系统变量,计算CPU核心数*2和JVM最大可用内存/默认内存块大小/2/3,取其中一个较少的值当做PoolArena的个数。
确定PoolArena个数之后框架会创建一个PoolArena数组,数组中所有的PoolArena都会用来执行内存分配。线程申请内存分配时,线程会在这个PoolArena数组中挑选一个当前被占用次数最少的Arena执行内存分配。
此外,netty使用扩展的线程对象FastThreadLocalThread来优化ThreadLocal性能,具体的优化思路是:默认的ThreadLocal使用ThreadLocalMap存储线程局部变量,它的实现方式类似于HashMap,需要计算hashCode定位到线程局部变量所在Entry的索引,而FastThreadLocalThread使用FastThreadLocal代替ThreadLocal,FastThreadLocalThread用一个数组来维护线程变量,每个FastThreadLocal维护一个index,该index就是线程局部变量在数组中的位置,线程变量直接通过index访问无需计算hashCode,FastThreadLocal的优势是减少了hashCode的计算过程,虽然性能只会有轻微的提升,但在高并发系统中,即使只是轻微的提升也会成倍放大。
内存单元
借鉴slab分配算法,内存池中划分成多种大小的内存块,内存池中包含两种分配单元页(page)和块(chunk)。
页大小(pageSize)有几种设置方式:
1.通过PooledByteBufAllocator构造子中的pageSize参数设置;
2.通过系统变量io.netty.allocator.pageSize设置,
3.如果1和2都没有设置取默认值8192即8K。
这个pageSize大小不能随意设置是有限制的,首先它必须大于4096(4K),而且必须是2的整数次幂,不满足这种要求的pageSize框架会直接报错。
page单元中包含下面两种型号的内存块:
1.tiny块:小于512个字节的内存块,这种型号的内存分配时只能按照16的整数倍分配,大小(0,16]区间分配16个字节,(16,32]区间分配32字节,(32,48]区间分配48字节;
2.small块:大小在[512,pageSize]区间,必需是512*2的指数倍,只能是512,1024,2048这种大小。
在PoolArena中有tinySubpagePools和smallSubpagePools两个数组,每个数组存放一个PoolSubpage链表,tinySubpagePools中的PoolSubpage链表都用于管理tiny块的分配信息,smallSubpagePools中的链表管理small块的分配信息。
tinySubpagePools:大小为32;
smallSubpagePools:大小由pageSize来计算,计算方式是:pageShifts=(32-1-pageSize对应二进制数高位0的个数);smallSubpagePools.size=pageShifts-9,pageSize默认值8192,由此计算默认的smallSubpagePools大小等于4。
tinySubpagePools和smallSubpagePools数组的每个元素都是一个PoolSubpage链表的头指针,初始状态下每个链表只有一个头指针head,head的pre和next都指向自己,该状态下等同于空链表。
数组中的每个位置的PoolSubpage链表串用来控制同一种尺寸的内存分配,tiny块的大小必须是16的整数倍,tinySubpagePools[0]用来控制大小16的内存分配,tinySubpagePools[1]用来控制大小32的内存分配,tinySubpagePools[31]用来控制大小256的内存分配,smallSubpagePools数组同理,只不过他管理的内存大小是512的2次幂倍数。
链表中每一个PoolSubpage(head节点除外)管理一个子页的分配信息,PoolSubpage中有几个重要属性:
1)elemSize,分配的内存大小,PoolSubpage初始化之后这个值是固定的,也就是说一旦初始化之后该子页只能分配elemSize大小的内存。
2)maxNumElems,内存页最多能被分配多少次,也即一页可划分成多少大小相同的内存单元,maxNumElems=pageSize/elemSize。
3)numAvail,内存页还能分配多少次,它的初始值等同于maxNumElems,分配一次值递减。
4)nextAvail,内存页内的待分配块的位置,每次内存分配时会执行一遍nextAvail计算。
5)bitmap,位图数组,大小为pageSize/1024,每个元素是一个长整形数数,记录内存页的分配信息,每个二进制位都代表页内的一个内存单元,当二进制位为1表示对应的内存块被分配过,第一个元素对应0-63内存单元,第二个元素对应64-127内存单元等等,bitmap[0]=0b0000000...0001111表示0,1,2,3这四个内存单元都已经被分配出去了。这个bitmap用来辅助计算下一个分配块的索引也即上面的nextAvail参数。
6)memoryMapIdx,页所在memoryMap树叶子结点位置索引。
块大小(chunkSize)是由pageSize和maxOrder参数计算而来,计算公式是chunkSize=pageSize*(2的maxOrder次幂),maxOrder可通过下面几种方式设置:
1.PooledByteBufAllocator构造子的maxOrder参数;
2.io.netty.allocator.maxOrder系统变量设置,只能设置0-14范围内的值;
3.如1和2都未设置取默认值11,也就是说一个chunk大小默认等于2的11次方个page。
块单元只包含一种型号,normal型号,大小在(pageSize,chunkSize]区间,对于small和normal型号的内存块,分配的内存大小必需是512*2的指数倍,只能是512,1024,2048这种大小。
PoolArena中创建了6个PoolChunkList,每个PoolChunkList是一个PoolChunk链表,PoolChunk用来管理normal块分配,列表的具体信息如下:
qInit:剩余内存0-25%的PoolChunk链表
q000:剩余内存1-50%的PoolChunk链表
q025:剩余内存25-75%的PoolChunk链表
q050:剩余内存50-100%个PoolChunk链表
q075:剩余内存75-100%个PoolChunk链表
q100:剩余内存100%chunk列表
这六个PoolChunkList由双向链表串联,这个六个链表结构关系如下:
PoolChunk中有几个重要属性:
1)memory,代表PoolChunk中所有可分配的物理内存,它是内存请求者的最终目标,在HeapArena中它就是一个chunkSize大小的byte数组,分配之后这个byte数组中的某一段可被请求者使用。
2)memoryMap数组,数组元素是一个8位的byte,
memoryMap数组的大小等于maxSubpageAllocs*2,但是实际上只会使用maxSubpageAllocs*2-1个位置,第一个位置(索引0的位置)未被使用,该数组可以抽象成一颗二叉树,每个非叶子节点都有两个子节点,所有叶子结点的深度均同,由上面的分析可知maxSubpageAllocs=2^maxOrder,所以该树的深度为maxOrder+1。PoolChunk初始化时填充该memoryMap数组,每个元素的值被设置成节点所在树深度减1,拿maxSubpageAllocs=16来举例,初始化之后memoryMap[0]=0,memoryMap[1]=1,memoryMap[2]=1,memoryMap[3]=2等等,每个元素管理一个内存块的分配信息,数组元素值标识该逻辑块的分配情况。
通过这种方式把一个PoolChunk划分成包含maxSubpageAllocs*2-1个可分配的内存块的二叉树结构,每个节点管理一个固定大小的内存块,每个叶子结点管理一个页内存块,父节点管理的内存大小是子节点的2倍,根节点管理一个chunkSize大小的内存块,整颗树总共可分配chunkSize大小的内存。
3)depthMap,byte数据,数组大小和memoryMap一样,由于memoryMap中元素的值在内存分配过程中会发生变化,depthMap记录每个索引对应的节点的深度。
4)subpages,页数组,标识PoolChunk中所有可分配的页,数组的大小maxSubpageAllocs即PoolChunk中最多可分配页的数量,由上面得知chunkSize=pageSize*(2^maxOrder),所有该数组大小为maxSubpageAllocs=2^maxOrder,这个数组的大小即为memoryMap树叶子节点的个数,数组中的每个元素也对应memoryMap树的一个叶子节点。
内存分配流程
内存池采用了slab分配思路,分配时只会分配固定大小的内存块,会根据使用者所请求的内存大小进行计算,匹配大小最接近的标准化内存单元。分下面几种情况:
1)请求内存大小超过chunkSize,说明已经超出了一个chunk能分配的范围,这种内存内存池无法分配应由JVM分配,直接返回原始大小。
2)请求内存大小不小于512,返回一个512的2次幂倍数当做最终的内存大小,即原始大小是512时返回512,在(512,1024]区间时返回1024,在(1024,2048]区间时返回2048。
3)请求内存大小小于512,返回一个16的整数倍,即原始大小在(0,16]区间返回16,在(16,32]区间返回32,在(32,48]区间返回48。
在执行内存分配时会优先在线程本地进行分配,线程本地分配失败之后才会进行全局分配,初始状态下,线程本地是没有内存单元可分配的,只能走全局分配,先来看下全局分配流程。
执行内存分配时,根据使用者请求的内存大小区分块内(PoolChunk)分配和页内(PoolSubpage)分配:
块内分配(PoolChunk)
大小大于pageSize的内存走块内分配,需要一个PoolChunk实例来执行块内内存分配,分配流程如下:
1)如PoolArena中PoolChunkList链表非空,遍历一遍PoolArena中PoolChunkList链表中的PoolChunk,从q050链表开始遍历,遍历顺序是q050->q025->q000->qInit->q075,在遍历到的PoolChunk中尝试执行内存分配,过程如下:
1.1)对标准化后的内存大小求对数,计算出对应大小内存块所在树中的深度d,检查memoryMap索引为1的元素,检查数组元素值是否大于d,如果条件成立说明该PoolChunk无法已无法再分配请求大小的内存。
1.2)遍历树,找到节点在memoryMap数组中的值等于d的节点,该节点即可用来执行当前请求的内存分配,返回该节点位置。
1.3)修改1.2步骤中匹配到的memoryMap中的节点,修改该它的元素值为maxOrder+1,标识该节点对应的内存块已被分配,并且更新该节点所有祖先节点对应元素的值,分两种情况:一、父节点的两个子节点都被分配,父节点的值更新成子节点的值;二、只有一个子节点被分配,父节点的值更新成未分配子节点的值。从这里也可以看出,当父节点值和两个子节点的值一致时说明该节点下已无内存可分配了,深度越大对应的内存块越小,所以当根节点的元素值大于深度d时,说明深度d节点对应的内存大小已无法被该PoolChunk分配了。
这种分配方式类似buddy分配,某个结点被选中执行分配任务之后,它的父节点的状态也会被修改,父节点也无法分配父节点初始大小的内存了,等同于在分配过程中父节点被分裂成两个相同大小的子节点,其中一个分配掉了。
1.4)PoolChunk中的可用内存freeBytes,扣减掉分配掉的数量。
1.5)返回一个handle,该handle为被分配节点在memoryMap中的索引。
1.6)拿到handle之后,初始化PooledByteBuf,主要设置几个参数:
memory:即PoolChunk中的memory,标识物理内存的byte数组
handle:上面返回的handle,memoryMap数组索引
offset:该PooledByteBuf可使用内存在memory数组中的偏移量,memory数组中从offset这个位置开始可被PooledByteBuf使用,offset计算方式:分配到的内存块是树中相同层从左往右数的第n个,该层对应的内存块大小是m,那么offset=m*(n-1),该值通过memoryMap索引可以计算。
length:请求的内存大小
maxLength:实际分配的内存大小,因为内存时按固定大小的块分配,所以实际分配的内存可能会比请求的大小要大。记录这个的目的是在返还内存的时候要返还相同大小的内存。
cache请求内存线程的线程缓存PoolThreadCache,归还时内存可能会直接归还到该线程缓存。
设置完这些参数之后,memory数组中offset开始length长度的这段位置就可以被PooledByteBuf使用设值,length到maxLength这段位置虽然分配了,但是PooledByteBuf不会使用,属于被浪费的内存,如下图所示,分配了绿色+红色部分内存,但是实际上只会使用绿色部分内存。
2)PoolChunkList链表中的PoolChunk都无法分配指定大小的内存时,框架会创建一个PoolChunk,并重复上面的分配流程,分配结束之后,根据该PoolChunk中剩余的可用空间把实例插入到指定的PoolChunkList链表中。
页内分配(PoolSubpage)
对于大小不大于pageSize的内存请求会在页内分配,页内分配信息有PoolSubpage来管理,分配流程如下:
1)从PoolArena的PoolSubpage池tinySubpagePools或smallSubpagePools获取可用的PoolSubpage实例,初始状态下,这些PoolSubpage池是没有有效PoolSubpage实例的,框架会创建一个。
上面说过PoolChunk中的memoryMap树的叶子结点对应一个页单元,执行页内分配时要先找到memoryMap树中的一个叶子结点,查找该叶子节点memoryMapIdx的过程即执行一遍PoolChunk分配流程,PoolSubpage对应memoryMap树的一个叶子节点,确定memoryMapIdx之后也会更新所以相关祖先节点的值标明祖先节点下有子节点执行过内存分配了,然后创建一个PoolSubpage实例,把memoryMapIdx,PoolSubpage链表的head,pageSize等参数设置到实例中,并且把该实例插入到PoolSubpage链表的head节点之后。
2)PoolSubpage池中有有效PoolSubpage实例时,如果head节点的next指向的不是自己,说明该位置有有效的PoolSubpage实例。通过请求的内存大小确定分配的是tiny还是small,并计算该内存大小所在池数组中的位置,获取链表head节点的next节点,可用该next节点中的PoolSubpage管理页内内存分配
3)获取到PoolSubpage实例之后,由该实例执行内存分配,根据PoolSubpage中的nextAvail找到可分配的内存单元索引,即bitmap索引,生成一个长整形数handle返回,handle的组成是:高63-64位固定01标识页内分配,高33-62位bitmap索引,低32位memoryMap数组索引。确定nextAvail的过程即遍历bitmap数组中每个长整型数的每一个bit,找到第一个值是0的bit位,由该bit位的位置和整形数在bitmap数组中的位置确定最终的nextAvail,假如确定是bitmap[0]的第2个bit位,则返回的nextAvail=1,假如确定是bitmap[1]的第2个bit位,则返回的nextAvail=64+1=65。
返回handle之后用该handle去初始化PoolByteBuf,跟块内分配相比,有两个参数不同:
1)offset,页内分配的offset,由页所在块在memoryMap树中的offset和页内offset一起确定,页在树中的offset计算方式是:假设页是树的第3个叶子节点,页大小是4096,那么页offset=4096*2,该offset的值等于页所在offset+handle*elemSize相加的结果为最终的offset;
2)maxLength,实际分配的内存大小,等于PoolSubpage的elemSize属性;
同样地由offset和length可圈定memory中可使用的内存,PoolSubpage执行过内存分配动作之后,会被挂到tinySubpagePools或smallSubpagePools链表的头部,当PoolSubpage中所有的内存都被分配完即numAvail=0之后,该PoolSubpage会从SubpagePools链表中删除,分配过的PoolSubpage会保存在PoolChunk的subpages数组中。
线程局部分配
内存池使用PoolThreadCache存储内存局部变量,PoolThreadCache中有三个MemoryRegionCache数组:
1)tinySubPageHeapCaches,缓存tiny内存块,大小32;
2)smallSubPageHeapCaches,缓存small内存块,大小pageShifts-9,pageShifts的计算方法上面介绍过了,默认值是13,该数组的默认大小是4;
3)normalHeapCaches,缓存normal内存块,数组的大小计算方式稍微复杂一点,计算方式如下:
取chunkSize和maxCachedBufferCapacity的最小值n,maxCachedBufferCapacity通过系统变量io.netty.allocator.maxCachedBufferCapacity设置,默认值32*1024;
对n/pageSize求对数m;
p=m+1,如果p>1取p作为数组大小,否则取1作为数组大小。
数组的每个元素缓存同一种尺寸的内存块,MemoryRegionCache中有个队列queue,queue中的元素是Entry,每个Entry中有两个重要属性:chunk和handle。初始状态下线程缓存是空的,只有触发内存回收动作时,满足条件的内存归还给线程时才会添加实例到数组中。
在分配过程中,会按照请求的内存大小计算定位到对应的MemoryRegionCache和该大小在数组中的位置,确定MemoryRegionCache之后,如果queue中有Entry,则直接返回该Entry中的chunk和handle,从上面的全局分配流程可以知道,有了这个两个参数就能确定最终可以使用的内存段。如果线程本地分配失败,执行全局分配流程。
内存回收
大小不超过chunkSize的内存分配都由内存池完成,所以内存回收不涉及GC,内存回收时,内存会直接归还到内存池。框架为了减少高并发下的线程竞争提高内存分配效率,所归还的内存一般不会直接规还到公用分配区,而是直接归还给当时请求内存分配的线程,在上面介绍PoolByteBuf时了解到了,构造PoolByteBuf会传入一个PoolThreadCache对象,这个对象就是线程的局部变量。
根据回收的内存大小能确定该回收到哪个MemoryRegionCache数组中的哪个位置,MemoryRegionCache中有个队列queue,回收的内存块生成一个Entry按先后顺序放入该队列中,Entry中包含PoolChunk和handle。
内存回收流程如下:
1)根据回收内存的大小决定决定回收到哪个MemoryRegionCache数组中,并且计算出数组索引;
2)如果位置是有效位置,把PoolChunk和handle封装到Entry中插入到queue队列中,有效位置即步骤1计算出的索引在数组大小范围之内;
3)如是无效位置或queue队列已满,说明无法回收到线程中,内存归还给公有分配区。
归还公有分配区流程如下:
1)块内分配内存,设置该内存块对应memoryMap树节点的值设会该节点的深度,重新更新该节点的所有祖先节点,祖先节点的值,当祖先节点的有子节点被分配,祖先节点值设置成子节点较小的那个值,当祖先节点没有子节点被分配,把祖先节点值还原成祖先节点坐在深度。
2)内存归还之后PoolChunk的可用内存发生了变化,检查PoolChunk的可使用内存是否还符合它坐在PoolChunkList的可用率范围,如果不满足需要迁移到满足要求的PoolChunkList中。
3)页内分配,出来需要还原PoolChunk和memoryMap树的状态,还要还原PoolSubpage的状态,把bitmap数组中对应长整型对应的bit还原成0,numAvail为0时,把PoolSubpage加回到PoolSubpage池的链表头结点只有,numAvail增1。
PoolThreadCache有对应大小的内存块时,下次该线程分配内存可直接在线程内部分配,当该线程内存分配动作非常活跃时,这样会明显的提高分配效率,但是如果它不活跃时会浪费内存,所以内存池会监控该线程,随时做好把内存从线程缓存中删除的准备。
PooledByteBufAllocator中有个trimTask定时任务对这些数组中的MemoryRegionCache进行监控,这个定时任务的执行周期由io.netty.allocation.cacheTrimIntervalMillis系统变量设置,默认是0,检查线程分配活动是否活跃的逻辑是:在设置的毫秒数之内,总分配次数allocations小于队列queue长度,说明该线程的分配活动是不活跃的,应该释放掉内存供其它线程分配。当io.netty.allocation.cacheTrimIntervalMillis没有设置或者设置成不大于0的整数时,该监控任务不会执行。
在执行内存分配时,当内存分配的次数超过阀值freeSweepAllocationThreshold时也会进行一次活跃度检查,并释放不活跃线程缓存中空闲的内存,活跃的判断条件同样是比较总分配次数allocations和队列长度的大小关系,freeSweepAllocationThreshold通过io.netty.allocator.cacheTrimInterval系统变量设置,默认值8192。