写点什么

Netty 篇之内存管理器

作者:邱学喆
  • 2022 年 5 月 02 日
  • 本文字数:3909 字

    阅读完需:约 13 分钟

Netty篇之内存管理器

一. 概述

在计算机组成结构中,内存是存储器中充当了很关键的一个角色。那么内存管理是由操作系统管理的,进程每次使用内存时,都要向操作系统申请内存,这个时候会进行一次上下文切换;如果进程频繁的调用操作系统的内存分配等内存操作 api 时,是非常低效的;所以很多开源的系统,都会在内存管理这个模块中花大量的心思去提高性能,也就衍生出应用系统的内存管理器。如 redis 使用的内存管理器 ltcmalloc、ltcmalloc_minimal

这篇主要介绍的 netty 的内存管理器,其使用的是 jemalloc 内存管理器;而这里只是介绍推外内存管理;

二. 内存管理器概述

在应用系统使用内存时,需要向操作系统提交申请内存指令;不在使用时,会向操作系统提交内存释放指令,如下图:

应用系统调用操作系统提供的方法时,都会进行一次上下文切换,性能会有所低效;

所以,一般内存管理器都会一次性向操作系统申请较大的内存区域,由业务系统自己去管理内存,这时候内存分配以及释放内存都不会调用操作系统,如下图:

然而上图的内存管理器存在性能低效问题,主要是由于并发场景导致的;为了解决并发的进行内存分配,添加了线程分配特性,减少并发场景发生,此时线程对象又是一个小型的内存管理器,如下图:

上面的流程看样子是比较简单的,然而里面的逻辑是比较复杂的;如何判断一个内存管理器是好的,一般从几个方面去衡量:

  • 内存使用率,一般会从碎片比例来看出;

  • 并发场景发生频率,

  • 上下文切换次数,即调用操作系统所提供的内存操作 api。我们可以一次性申请固定大小的内存;

在 java 语言中,我们往往会创建一个对象来封装对 ByteBuffer 的操作;每次申请内存时,都要创建这么一个对象去操作内存;为了减少对象的创建,添加了对象池特性,如图:

三. JEMalloc 内存管理器

在介绍其原理时,先介绍几个概念:PoolArena、PoolChunk 、 PoolSubpage。这三个都是对一块内存管理;然而其区别主要的内存区域大小而已;下面的图可以更加直观看到他们之间的关系;该图是借鉴网友所画的图;具有一定的参考价值;原因是高版本是不再由 tinysubpages 管理;

PoolSubpage 没有在上面的图中,主要的原因是该类的结构相对较简单,同时也不影响整体的理解:PoolArena 管理 PoolChunk,PoolChunk 管理 PoolSubpage;

JEMalloc 管理的内存,有三种类型,

  • 内存<= 28672K,这个是由 PoolSubpage 进行管理;

  • 28672K< 内存 <=16M(chunk 默认大小),则由 PoolChunk 进行管理;

  • 内存>16M,则不在其管理范围内,直接访问操作系统申请内存;

这里画个简单图来阐述其交互过程:

大于 4096 同时小于 16M(chunk 默认大小)的内存分配,那不再有 PoolSubpage 参与了;

而内存释放呢,则是上面流程图的方向操作,这里不再提供了;

这里稍微留意的是,PoolChunk 有合并操作,即是对一些被释放内存尝试做合并,以便下次提供内存分配时,提供内存的使用率;

具体的细节将在 netty 中的 Jemalloc 章节进一步介绍;

四. Netty 之 Jemalloc

上面简单介绍了 jemalloc 的一些概念以及简单的流程图,接下来将参照代码来详细的介绍其运作过程;其代码是 netty 仓库中的 4.1 分支进行解读;

4.1 SizeClasss

这个抽象类很大程度上介绍了内存管理的大部分细节,这里含盖了很多计算,公式目前我还没吃透,直接通过 debug 的形式,来拿到对应目标值;而且网上也有很好的文章对其进行了介绍;这里不再班门弄斧,在这里只是做个总结;

  • log2Group: 组中的 2 的指数

    log2Delta: 增量中的 2 的指数

    nDelta: 数学公式中的【δ】用来表示增量值

通过这上面的三个数,可以快速计算出字节大小;公式=2^log2Group + delta * 2^log2Delta例如:log2Group = 4, log2Delta = 4, delta = 3那么sizes = 2^4 + 3* 2^4 = 64
复制代码
  • isMultiPageSize: 是否是页的倍数,一页=8192

    isSubPage: 是否是 small 内存,指的小于 28672K

log2Ndelta = log2Group: [0,1] -> 0; [2,3] -> 1; [4] -> 2log2Size = log2Delta + log2Ndelta == log2Group ? log2Group +1 : log2GroupisSubPage = (log2Size < 15?1:0)
复制代码
  • log2DeltaLookup: Same as log2Delta if a lookup table size class, 'no' otherwise.

log2Size < 12 -> log2DeltaLookup = log2Delta;log2Size > 12 -> log2DeltaLookup = 0log2Size = 12 -> log2DeltaLookup = (log2Size == log2Group ? 0: log2Delta)
复制代码
  • nSubpages: small 的数量,默认值为.39

    nSizes: size 的数量,值为 76 个

    nPSizes: 页的倍数有多少个,指为 40 个

    smallMaxSizeIdx: 小内存(小于 28672K)最大的角标值 38

    lookupMaxClass: 指 Log2DeltaLookup 中值不为 0 的最大角标

而 PageIdx2sizeTab 是提供给 chunk 使用的,也就是 chunk 能分配的内存大小;例如 chunk 要分配 172032 大小的内存,那么就会分配 196608 大小的内存,而不是只分配 172032 大小的内存;

4.2 PoolArena

是用来管理 PoolChunk 对象的;当需要分配内存时,需要一个机制来选择对应的 PoolChunk 进行分配;在该类中有 q***开头的属性变量,维护了不同使用率的 Chunk 列表;

qInit -> [0,25]q000 -> [1,25]q025 -> [25,75]q050 -> [50,100]q075 -> [75,100]q100 -> [100,100]意味着chunk内存使用率超过指定的阈值,就会将移到对应的列表中内存分配时,优先级 q050 -> q025 -> q000 -> qInit -> q075
复制代码

还有额外的 PoolSubpage 数组

该数组是维护分配small内存的PoolSubpage列表;一共有39个;当分配subpage时,会从中找到对应的PoolSubpage对象,进行内存分配;一旦PoolSubpage内存使用完,或者被释放,将会从这个链表中移除出去;
复制代码

所以说,PoolArena 是一个壳,其不提供具体的内存分配等工作,而是交给 PoolChunk 和 PoolSubpage 进行内存分配;它只负责提供工作分配,从而提高内存使用率;

4.3 PoolChunk

是用来管理 PoolSubpage 对象以及 Normal 内存块的;

在介绍 PoolChunk 之前,先介绍 handle 的使用;其长度为 64 位,用来标志内存的使用情况;

  • runOffset 用来表示 chunk 内存快中的页的偏移量

  • size 用来表示使用多少页的内存块

  • isUsed 用来表示该内存块是否被暂用

  • isSubpage 用来表示是否是 small 内存块

  • bitmap 用来表示 subpage 分配的角标位置

具体场景来说明:

当分配normal内存块时,假设时172032大小的内存块,chunk会直接分配24个页的内存块;同时之前是没有分配任何内存的情况下,runoffset = 0 , size = 24, isused = 1, 最终handle = 420906795008
复制代码

PoolChunk 对象中维护了优先队列数组,来对 chunk 内存进行管理,如下图所示


当分配24页内存块时,通过计算得到角标为13,那么就会从数组的角标13开始,往角标大的方向查找能进行内存分配的内存块;有点类似slab内存管理器;当找到对应的内存块进行分配后,剩余的内存块通过计算得到角标,从而拿到对应的优先队列,将其内存块存放进去;
复制代码

同时也维护了 subpages 数组,其与 PoolArena 的存放形式不一样,并没形成链表;

其数组大小为 2048,刚好对应 chunk 所能分配的页数量大小;

其角标是记录的 small 内存中 subpage 的内存块对 chunk 的页偏移量;例子如下:

假设分配2048大小的small内存块,会先分配normal内存块,通过计算最小公倍数,得到normal内存块大小为8192;将8192内存块交由PoolSubpage对象进行管理,从而去分配2048内存大小;在这过程当中的,normal块的在页偏移量指就是subpages数组的角标;
复制代码

4.4 PoolSubpage

其是对 small 内存块进行管理的;当分配 2048 内存块时,会优先分配 8192 大小的内存块,给到 Poolsubpage 对象中,那就意味着这个对象就可以分配 4 个 2048 内存操作;

其属性有很一些关键成员,介绍一下;

  • elemSize 每个成员所占用的内存大小,在上面的例子中,elemSize = 2048

  • bitmap 是一个 long 数组,每一个 long 的每一位来标志是否已经分配了,数组长度是根据 runsize 来计算,数组长度= runsize >> 6 >> 4; 6 代表的是 long 类型的长度 2^6=64,而 4 代表的内存最小单位为 2^4=16,下面举个例子来介绍其原理;

假设runsize = 8192, 每个内存块的大小为16; 同时计算得到数组长度 = 8(最大的长度,可能实际的用不了这么多); 可以分配512个这样的内存块;从而得到实际bitmap的数组实际长度也是为8个;每次分配一块内存块时,都需要去判断bitmap中的long值中是否存在字节位是否标注为0的,同时也是小于最大数量,那么就可以认为是可以分配的;
复制代码

4.5 线程缓存

netty 内存分配时,每次都会经过 PoolThreadCache,每个内存分配都尝试去线程缓存中去获取对应的内存块;这块逻辑相对于比较简单,不再阐述;

这里稍微说的是配置项;

  • useCacheForAllThreads 是否使用线程缓存

  • smallCacheSize small 内存块的缓存数量大小

  • normalCacheSize normal 内存块的缓存数量大小

只有设置了这些值,才可以使用线程缓存特性,提高内存分配效率;

4.6 ObjectPool

对象池,每次分配内存时都需要一个对象去承载这些内存的使用,为了减少对象的创建以及销毁,netty 增加了对象池的特性;具体的实现类为 RecyclerObjectPool 以及 Recycler 抽象类;这里不再阐述了;

等需要使用的场景时,再进一步解读里面的源码;

4.7 总结

上面的篇幅,主要是介绍了内存的分配的工作,以及其他的额外的特性;对内存管理有了一定程度的认识;

里面的内存释放,涉及到的操作细节非常多,例如内存合并操作;以及内存真正释放的时机;没有进行阐述,可以通过看代码了解;

这里列一下关键的 netty 中的几个类:

  • ServerChannelRecvByteBufAllocator 分配缓存大小的策略对象

  • PooledByteBufAllocator 字节缓存池分配器

  • PoolThreadCache 线程缓存对象

如果基于 netty 的优化方面,可以释放的往内存分配去想,

  • 例如内存颗粒度的评估,这样子可以有效的减少内存碎片化问题

  • 线程缓存大小,可以提供内存分配效率

引用

这里有比较好的文章,这里引用一下:

Netty源码解析--内存对其类SizeClasses

Netty源码解析--PoolArena实现原理

Netty源码解析--PoolChunk实现原理

Netty源码解析--PoolSubpage实现原理

武清南路篇

发布于: 刚刚阅读数: 4
用户头像

邱学喆

关注

计算机原理的深度解读,源码分析。 2018.08.26 加入

在IT领域keep Learning。要知其然,也要知其所以然。原理的爱好,源码的阅读。输出我对原理以及源码解读的理解。个人的仓库:https://gitee.com/Michael_Chan

评论

发布
暂无评论
Netty篇之内存管理器_内存管理器_邱学喆_InfoQ写作社区