沾衣欲湿杏花雨,吹面不寒杨柳风。随着政策的放开,周围🐑了的朋友越来越多,大家也从开始的惶恐不安到现在的既得之,则治之,反正早晚的事,有新加坡的同事说那面有些人已经反复得了几次了,因此也不用惊慌,冬天已经来了,春天还会远吗?
一、PoolChunk介绍
Netty底层的内存分配和管理主要是由PoolChunk实现,大于16M的PoolChunk不放入内存中,今天主要给大家介绍下PoolChunk的两种实现以及当前正在用的版本是如何处理内存的。正片开始之前,先来一些开胃菜,有一些相关概念需要搞明白。
1.1. jemalloc3算法
在Netty 4.1.52版本之前,PoolChunk引入的是jemalloc3算法。内部维护了一颗平衡二叉树,默认由2048个page组成,一个page为8KB,整个Chunk默认为16MB,其二叉树结构如下图所示:
当分配的内存大于2^13B=8KB时,可以通过内存指计算对应的层级:int level = 11 - (log2(normCapacity)-13),其中normCapacity表示要分配的内存大小,大于等于8Kb并且为8KB的整数倍。比如要申请大小为16KB的内存,level = 11 - (log2(8196 * 2) - 13) = 11 - (log2(2^14) - 13 = 10,因此只能在小于等于10层上寻找还未分配的节点。
在PoolChunk中,用一个数组memoryMap为了所有节点及其对应的高度值。当节点被全部分配完成时,高度值会变成12,表示目前已经被占用,不可再分配,并且会循环递归更新其他所有父节点的高度值。
虽然jemalloc3对内存规格进行区域划分,目的也是为了减少内存碎片,但是最坏的情况会出现50%内存浪费,比如我想要申请17B的大小内存模块,通过计算后只能申请32B,即需要占用4个叶子结点,这种情况就很浪费内存。因此jemalloc4通过构造复杂的SizeClasses让每个size的跨度变的更小,从而减少内存浪费。
二、jemalloc4版本内存管理
2.1 几个重要属性
- LongPriorityQueue
- 该数据结构是Netty内部实现关于Long基本类型的优先级队列,属于小项堆,可以通过poll方法获取小项堆内部最小的handle值,表示我们每次申请内存都是从最低位置开始。PoolChunk内部维护了一个LongPriorityQueue[] runsAvail数组,所有存储在该数组的对象handle都表示一个可用的run,用来表示若干个page组成的内存块,默认长度是40
- LongLongHashMap
- 用来存储long类型的kv的hashMap,底层采用线性探测法,Netty使用该map用来存储某个run的首页偏移量和句柄值的映射关系
- Long handle
- 一共64位,高15位表示pageOffset,后面15位表示pageSize,通过pageOffset + pageSize就能定位一个run区域,由pageSize个page组成,起始地址是pageOffset,对于ByteBuffer对象来说,内部会有一个long型的memoryAddress绝对地址,因此可以通过绝地地址 + 偏移量定义任何page的实际地址
- 一共64位,高15位表示pageOffset,后面15位表示pageSize,通过pageOffset + pageSize就能定位一个run区域,由pageSize个page组成,起始地址是pageOffset,对于ByteBuffer对象来说,内部会有一个long型的memoryAddress绝对地址,因此可以通过绝地地址 + 偏移量定义任何page的实际地址
- run
- 由若干个连续的page组成的内存块地址,可以被long型的hand表示。PoolChunck会管理若干个不连续的run,存储到runAvail数组里。
2.2. 内存分配原理
上层实现整体没变,依然首先从本地缓存中分配,分配失败的则委托给PoolArea进行内存分配,最终委托给PoolChunk进行内存分配,Netty在jemalloc4算法中取消了Tiny规则,因此,只剩下Small、Normal以及Huge三种内存规格,整体内存分配逻辑如下
- 首先申请16M内存,并划分成2048个page
- 然后通过SizeClasses工具类,根据用户申请的内存大小计算出对应的index
- 如果index<=37,则表示当前为small级别分配
- 如果index<75,则表示当前是Normal级别分配
- 对于Normal级别来说,所需要的内存大小为pageSize的整数倍,PoolChunk会从能满足分配的run数组中得到若干个page。当某一个 run 分配若干个 page 之后,可能还会有剩余空闲的 page,那么它根据剩余的空闲 pages 数量会从 LongPriorityQueue[] 数组选取一个合适的 LongPriorityQueue 存放全新的 run
- 如果index>75,则表示当前是Huge级别分配
- 对于 Small 级别的内存分配,经过 SizeClass 规格化后得到规格值 size,然后求得 size 和 pageSize 最小公倍数 j,j 一定是 pageSize 的整数倍。然后再按照 Normal 级别的内存分配方式从第一个适合的 run 中分配 (j/pageSize) 数量的 page。
2.3. 内存分配的入口
前文也分析过,PoolArena#allocate() 方法为内存分配的入口,实现如下:
private void allocate(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity) {
final int sizeIdx = size2SizeIdx(reqCapacity);
if (sizeIdx <= smallMaxSizeIdx) {
tcacheAllocateSmall(cache, buf, reqCapacity, sizeIdx);
} else if (sizeIdx < nSizes) {
tcacheAllocateNormal(cache, buf, reqCapacity, sizeIdx);
} else {
int normCapacity = directMemoryCacheAlignment > 0
? normalizeSize(reqCapacity) : reqCapacity;
allocateHuge(buf, normCapacity);
}
}
通过SizeClasses的size2SizeIdx方法计算出对应的sizeIdx,然后根据sizeIdx的不同值选择不同的处理方式。
2.4. 分配Normal级别内存块
分配Normal级别内存块整体时序图如下:
当分配的内存块大小在(28kB,16MB]时,则调用tcacheAllocateNormal方法进行Normal级别内存块分配。源码如下:
private void tcacheAllocateNormal(PoolThreadCache cache, PooledByteBuf<T> buf, final int reqCapacity,
final int sizeIdx) {
// 首先尝试从本地线程缓存(线程私有变量,不需要加锁)分配内存
if (cache.allocateNormal(this, buf, reqCapacity, sizeIdx)) {
return;
}
lock();
try {
//托给PoolChunk对象完成内存分配
allocateNormal(buf, reqCapacity, sizeIdx, cache);
++allocationsNormal;
} finally {
unlock();
}
}
allocateNormal源码如下,首先从自己管理的几个不同内存利用率的双向链表中获取,获取到则直接返回,否则创建一个新的chunk对象用来进行内存分配。
private void allocateNormal(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache threadCache) {
// 首先尝试从PoolChunkList中分配,如果分配成功,则直接返回
if (q050.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
q025.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
q000.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
qInit.allocate(buf, reqCapacity, sizeIdx, threadCache) ||
q075.allocate(buf, reqCapacity, sizeIdx, threadCache)) {
return;
}
// 创建一个新PoolChunk对象
PoolChunk<T> c = newChunk(pageSize, nPSizes, pageShifts, chunkSize);
// 使用这个新对象进行内存分配
boolean success = c.allocate(buf, reqCapacity, sizeIdx, threadCache);
// 添加到init链表中去
qInit.add(c);
}
allocate源码如下:
boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {
// 最终分配成功的内存块的句柄
final long handle;
// 当前分配的内存规格是small
if (sizeIdx <= arena.smallMaxSizeIdx) {
// small
handle = allocateSubpage(sizeIdx);
if (handle < 0) {
return false;
}
assert isSubpage(handle);
} else {
// 分配Norma级别内存块,大小为pageSize的整数倍
int runSize = arena.sizeIdx2size(sizeIdx);
// 核心方法
handle = allocateRun(runSize);
if (handle < 0) {
return false;
}
assert !isSubpage(handle);
}
// 尝试从cachedNioBuffers缓存中获取ByteBuffer对象并在ByteBuf对象初始化时使用
ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;
// 初始化ByteBuf对象
initBuf(buf, nioBuffer, handle, reqCapacity, cache);
return true;
}
allacateRun源码如下:
private long allocateRun(int runSize) {
// 根据规格计算所需page数量
int pages = runSize >> pageShifts;
// 根据page的数量确定page的起始索引值,索引值对应runAvail数组起始的位置
int pageIdx = arena.pages2pageIdx(pages);
// 针对runsAvail并发操作加锁
runsAvailLock.lock();
try {
// 从LongPriorityQueue[] 数组中找到最合适的run用于当前的内存分配请求。
int queueIdx = runFirstBestFit(pageIdx);
if (queueIdx == -1) {
return -1;
}
// 获取对应的LongPriorityQueue,包含若干个run
LongPriorityQueue queue = runsAvail[queueIdx];
// 从LongPriorityQueue小项堆中获取可用的run 句柄
long handle = queue.poll();
// 把handle从 小项堆中移除
removeAvailRun(queue, handle);
if (handle != -1) {
// 拆分handle
// 一部分用户当前内存的申请
// 另一部分剩余的空闲内存块,会放入合适的LongPriorityQueue中,等待下次分配
handle = splitLargeRun(handle, pages);
}
int pinnedSize = runSize(pageShifts, handle);
// 更新剩余的空间值
freeBytes -= pinnedSize;
// 返回成功申请的句柄信息
return handle;
} finally {
runsAvailLock.unlock();
}
}
以下图(图片地址)为例,详解下内存分配以及split的过程。
- 当PoolChunk对象初始化,会向runsAvailMap和runsAvail写入一个句柄值,默认值是35184372088832L,它表示这么一个run:起始页偏移量为0,共有2048 个页,当前run 未被使用且不属于subpage。
- runsAvailMap可以看成是一个HashMap,它的key对应pageOffset偏移量,value对应ru 的句柄值。它是用于存储首页和末页的句柄值。每次PoolChunk分配内存时会动态更新或删除,runsAvailMap用于run合并时通过偏移量快速定位到可用的run。
- runsAvail则是一个小顶堆,但是PoolChunk是维护一个LongPriorityQueue[] 数组,用来存储空闲的run的 handle值。典型的空间换时间,如果所有的可用的run存储在一个LongPriorityQueue对象中,每次查询时就会非常慢(比较每一个handle值是否满足)。因此,根据handle所管理的page数量进行划分,比如某个run所管理的空闲 page数量为2048,那就把它放入索引值为39的LongPriorityQueue对象里。
- 当 PoolChunk 为了满足用户申请的 32KB(4 个page)大小的内存,从初始的 handle 中拆分 4 个用于当前的内存分配,因此,原本只有一个 run 现在被拆分成两个。
- 其中一个处于已使用状态的 run 的句柄值为
77309411328L,它表示起始页偏移量为 0,包含 4 个可用的 page,当前使用状态为已使用 - 而另一个 run 的句柄值为
2286915466297344L,它表示起始页偏移量为 4,包含 2024 个可用的 page,当前使用状态为未使用。而它的信息也会同步更新 runsAvailMap 和 runsAvail 两个数据结构中,以供后续分配时使用
- 其中一个处于已使用状态的 run 的句柄值为
2.5. 分配Small级别内存块
在jemalloc3中,Small级别的内存块大小是不能超过8KB的,方便内存管理,也能减少内存碎片,但是只能等分一个Page;而jemalloc4中,内存范围为[16Byte,28Kb],同时改成了等分N个page。
N的取值是拆分规格值和 pageSize 的最小公倍数再除以 pageSize。
例如:申请内存大小为 28KB,需要等分 7 个 page,因为它 28KB=28672 和 pageSize=8192 的最小公倍数为 57344(57344/8192=7)。这样就能满足类型 28KB 这种不是 2 的次幂的内存大小。从而减少给非2的次幂大小的内存分配造成的空间浪费。
内存管理方面jemalloc3和jemalloc4没什么区别,都是通过构建一个PoolSubpage对象,内部维护一个long[]数组来记录每一等分的使用情况。
Small级别内存分配整体时序图如下:
核心方法为allocateSubpage,源码如下: private long allocateSubpage(int sizeIdx) {
// 从「PoolArena」获取索引对应的「PoolSubpage」。
// 在「SizeClasses」中划分为 Small 级别的一共有 39 个,
// 所以在 PoolArena#smallSubpagePools数组长度也为39,数组索引与sizeIdx一一对应
PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);
// PoolSubpage 链表是共享变量,需要加锁
synchronized (head) {
// 获取拆分规格值和pageSize的最小公倍数
int runSize = calculateRunSize(sizeIdx);
// 申请若干个page,runSize是pageSize的整数倍
long runHandle = allocateRun(runSize);
if (runHandle < 0) {
// 分配失败
return -1;
}
// 实例化「PoolSubpage」对象
int runOffset = runOffset(runHandle);
assert subpages[runOffset] == null;
int elemSize = arena.sizeIdx2size(sizeIdx);
PoolSubpage<T> subpage = new PoolSubpage<T>(head, this, pageShifts, runOffset,
runSize(pageShifts, runHandle), elemSize);
// 由PoolChunk记录新创建的PoolSubpage,数组索引值是首页的偏移量,这个值是唯一的,也是记录在句柄值中
// 因此,在归还内存时会通过句柄值找到对应的PoolSubpge对象
subpages[runOffset] = subpage;
// 委托PoolSubpage分配内存
return subpage.allocate();
}
}
2.6. 内存回收
- 对于subPage的回收
- 对 subpage 回收是先回收到 PoolArena 对象的 subpage pool 池中,如果发现此时的 PoolSubpage 已经没有被任何对象使用(numAvail == maxNumElems),它首先会从 subpage pool 池中移出,然后再按照 run 策略回收(因为此刻的 handle 记录着偏移量和 page 数量,所以完全有足够的回收信息)
- 对于run的回收
- 第一步会尝试不断向前合并相邻的空闲的 run,这一步会利用 runAvailMap 快速定位合适的 run,若合并成功,会重新生成 handle 句柄值,接着再向后不断合并相邻的空闲的 run 并得到新的 handle,最后再更新 {@link PoolChunk#runsAvail} 和 {@link PoolChunk#runsAvailMap} 两个数据结构,这样就完成了一次 run 的回收。
核心源码都在PoolChunk.free方法中,核心逻辑如下:
void free(long handle, int normCapacity, ByteBuffer nioBuffer) {
// 当前回收的是subpage
if (isSubpage(handle)) {
// 根据容量大小获取index
int sizeIdx = arena.size2SizeIdx(normCapacity);
// 获取subpage pool索引对应的链表的头结
PoolSubpage<T> head = arena.findSubpagePoolHead(sizeIdx);
// 获取偏移量
int sIdx = runOffset(handle);
// 通过偏移量定位PoolSubpage
PoolSubpage<T> subpage = subpages[sIdx];
assert subpage != null && subpage.doNotDestroy;
synchronized (head) {
// 调用PoolSubpage.free方法释放内存
if (subpage.free(head, bitmapIdx(handle))) {
// 返回true表示当前PoolSubpage对象还在使用不需要回收
return;
}
assert !subpage.doNotDestroy;
// 返回flase表示PoolSubpage已经没有被任何地方引用,需要回收
subpages[sIdx] = null;
}
}
// 回收run类型
// 获取page数量
int pages = runPages(handle);
synchronized (runsAvail) {
// 向前、后合并与当前run的pageOffset连续的run
long finalRun = collapseRuns(handle);
// 更新「isUsed」标志位为 0
finalRun &= ~(1L << IS_USED_SHIFT);
// 如果先前handle表示的是subpage,则需要清除标志位
finalRun &= ~(1L << IS_SUBPAGE_SHIFT);
//更新PoolChunk
// 更新PoolChunk#runsAvailMap
insertAvailRun(runOffset(finalRun), runPages(finalRun), finalRun);
// 更新剩余空闲内存块大小
freeBytes += pages << pageShifts;
}
// 回收ByteBuffer对象
if (nioBuffer != null && cachedNioBuffers != null &&
cachedNioBuffers.size() < PooledByteBufAllocator.DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {
cachedNioBuffers.offer(nioBuffer);
}
}
三、小节
这篇文章主要简单为大家介绍了下Netty内存管理相关实现。距离上一次更新已经过去了一个月,做为还未完全恢复的“杨康”,略微有些懒惰,不过新年新气象,终究还是要不断学习的。看了很多netty内存管理的资料,参考了很多文章(贴在了最后,感谢各位大佬分享),不过我觉得能吸收,就会变成自己的,与君共勉吧。