写点什么

java 培训:Netty 的内存管理

作者:@零度
  • 2022 年 2 月 25 日
  • 本文字数:5374 字

    阅读完需:约 18 分钟

 前言

正是 Netty 的易用性和高性能成就了 Netty,让其能够如此流行。而作为一款通信框架,首当其冲的便是对 IO 性能的高要求。

不少读者都知道 Netty 底层通过使用 Direct Memory,减少了内核态与用户态之间的内存拷贝,加快了 IO 速率。但是频繁的向系统申请 Direct Memory,并在使用完成后释放本身就是一件影响性能的事情。为此,Netty 内部实现了一套自己的内存管理机制,在申请时,Netty 会一次性向操作系统申请较大的一块内存,然后再将大内存进行管理,按需拆分成小块分配。而释放时,Netty 并不着急直接释放内存,而是将内存回收以待下次使用。

这套内存管理机制不仅可以管理 Directory Memory,同样可以管理 Heap Memory。

内存的终端消费者——ByteBuf

这里,我想向读者们强调一点,ByteBuf 和内存其实是两个概念,要区分理解。

ByteBuf 是一个对象,需要给他分配一块内存,它才能正常工作。

而内存可以通俗的理解成我们操作系统的内存,虽然申请到的内存也是需要依赖载体存储的:堆内存时,通过 byte[], 而 Direct 内存,则是 Nio 的 ByteBuffer(因此 Java 使用 Direct Memory 的能力是 JDK 中 Nio 包提供的)。

为什么要强调这两个概念,是因为 Netty 的内存池(或者称内存管理机制)涉及的是针对内存的分配和回收,而 Netty 的 ByteBuf 的回收则是另一种叫做对象池的技术(通过 Recycler 实现)。

虽然这两者总是伴随着一起使用,但这二者是独立的两套机制。可能存在着某次创建 ByteBuf 时,ByteBuf 是回收使用的,而内存却是新向操作系统申请的。也可能存在某次创建 ByteBuf 时,ByteBuf 是新创建的,而内存却是回收使用的。

因为对于一次创建过程而言,可以分成三个步骤:

  1. 获取 ByteBuf 实例(可能新建,也可能是之间缓存的)

  2. 向 Netty 内存管理机制申请内存(可能新向操作系统申请,也可能是之前回收的)

  3. 将申请到的内存分配给 ByteBuf 使用

本文只关注内存的管理机制,因此不会过多的对对象回收机制做解释。

Netty 中内存管理的相关类

Netty 中与内存管理相关的类有很多。框架内部提供了 PoolArena,PoolChunkList,PoolChunk,PoolSubpage 等用来管理一块或一组内存。

而对外,提供了 ByteBufAllocator 供用户进行操作java培训

接下来,我们会先对这几个类做一定程度的介绍,在通过 ByteBufAllocator 了解内存分配和回收的流程。

为了篇幅和可读性考虑,本文不会涉及到大量很详细的代码说明,而主要是通过图辅之必要的代码进行介绍。

针对代码的注解,感兴趣的网友可加我微信:xttblog2,免费获取!

PoolChunck——Netty 向 OS 申请的最小内存

上文已经介绍了,为了减少频繁的向操作系统申请内存的情况,Netty 会一次性申请一块较大的内存。而后对这块内存进行管理,每次按需将其中的一部分分配给内存使用者(即 ByteBuf)。这里的内存就是 PoolChunk,其大小由 ChunkSize 决定(默认为 16M,即一次向 OS 申请 16M 的内存)。

Page——PoolChunck 所管理的最小内存单位

PoolChunk 所能管理的最小内存叫做 Page,大小由 PageSize(默认为 8K),即一次向 PoolChunk 申请的内存都要以 Page 为单位(一个或多个 Page)。

当需要由 PoolChunk 分配内存时,PoolChunk 会查看通过内部记录的信息找出满足此次内存分配的 Page 的位置,分配给使用者。

PoolChunck 如何管理 Page

我们已经知道 PoolChunk 内部会以 Page 为单位组织内存,同样以 Page 为单位分配内存。那么 PoolChunk 要如何管理才能兼顾分配效率(指尽可能快的找出可分配的内存且保证此次分配的内存是连续的)和使用效率(尽可能少的避免内存浪费,做到物尽其用)的?Netty 采用了 Jemalloc 的想法。

首先 PoolChunk 通过一个完全二叉树来组织内部的内存。以默认的 ChunkSize 为 16M, PageSize 为 8K 为例,一个 PoolChunk 可以划分成 2048 个 Page。将这 2048 个 Page 看作是叶子节点的宽度,可以得到一棵深度为 11 的树(2^11=2048)。

我们让每个叶子节点管理一个 Page,那么其父节点管理的内存即为两个 Page(其父节点有左右两个叶子节点),以此类推,树的根节点管理了这个 PoolChunk 所有的 Page(因为所有的叶子结点都是其子节点),而树中某个节点所管理的内存大小即是以该节点作为根的子树所包含的叶子节点管理的全部 Page。

这样做的好处就是当你需要内存时,很快可以找到从何处分配内存(你只需要从上往下找到所管理的内存为你需要的内存的节点,然后将该节点所管理的内存分配出去即可),并且所分配的内存还是连续的(只要保证相邻叶子节点对应的 Page 是连续的即可)。



上图中编号为 512 的节点管理了 4 个 Page,为 Page0, Page1, Page2, Page3(因为其下面有四个叶子节点 2048,2049,2050, 2051)。

而编号为 1024 的节点管理了 2 个 Page,为 Page0 和 Page1(其对应的叶子节点为 Page0 和 Page1)。当需要分配 32K 的内存时,只需要将编号 512 的节点分配出去即可(512 分配出去后会默认其下所有子节点都不能分配)。而当需要分配 16K 的内存时,只需要将编号 1024 的节点分配出去即可(一旦节点 1024 被分配,下面的 2048 和 2049 都不允许再被分配)。

了解了 PoolChunk 内部的内存管理机制后,读者可能会产生几个问题:

  • PoolChunk 内部如何标记某个节点已经被分配?

  • 当某个节点被分配后,其父节点所能分配的内存如何更新?即一旦节点 2048 被分配后,当你再需要 16K 的内存时,就不能从节点 1024 分配,因为现在节点 1024 可用的内存仅有 8K。

为了解决以上这两点问题,PoolChunk 都是内部维护了的 byte[] memeoryMap 和 byte[] depthMap 两个变量。

这两个数组的长度是相同的,长度等于树的节点数+1。因为它们把根节点放在了 1 的位置上。而数组中父节点与子节点的位置关系为:

假设 parnet 的下标为 i,则子节点的下标为 2i 和 2i+1

用数组表示一颗二叉树,你们是不是想到了堆这个数据结构。

已经知道了两个数组都是表示二叉树,且数组中的每个元素可以看成二叉树的节点。那么再来看看元素的值分别代码什么意思。

对于 depthMap 而言,该值就代表该节点所处的树的层数。例如:depthMap[1] == 1,因为它是根节点,而 depthMap[2] = depthMap[3] = 2,表示这两个节点均在第二层。由于树一旦确定后,结构就不在发生改变,因此 depthMap 在初始化后,各元素的值也就不发生变化了。

而对于 memoryMap 而言,其值表示该节点下可用于完整内存分配的最小层数(或者说最靠近根节点的层数)。

这话理解起来可能有点别扭,还是用上文的例子为例。

首先在内存都未分配的情况下,每个节点所能分配的内存大小就是该层最初始的状态(即 memoryMap 的初始状态和 depthMap 的一致的)。而一旦其有个子节点被分配出后去,父节点所能分配的完整内存(完整内存是指该节点所管理的连续的内存块,而非该节点剩余的内存大小)就减小了(内存的分配和回收会修改关联的 mermoryMap 中相关节点的值)。

譬如,节点 2048 被分配后,那么对于节点 1024 来说,能完整分配的内存(原先为 16K)就已经和编号 2049 节点(其右子节点)相同(减为了 8K),换句话说节点 1024 的能力已经退化到了 2049 节点所在的层节点所拥有的能力。

这一退化可能会影响所有的父节点。

而此时,512 节点能分配的完整内存是 16K,而非 24K(因为内存分配都是按 2 的幂进行分配,尽管一个消费者真实需要的内存可能是 21K,但是 Netty 的内存管理机制会直接分配 32K 的内存)。

但是这并不是说节点 512 管理的另一个 8K 内存就浪费了,8K 内存还可以用来在申请内存为 8K 的时候分配。

用图片演示 PoolChunk 内存分配的过程。其中 value 表示该节点在 memoeryMap 的值,而 depth 表示该节点在 depthMap 的值。

第一次内存分配,申请者实际需要 6K 的内存:


这次分配造成的后果是其所有父节点的 memoryMap 的值都往下加了一层。之后申请者需要申请 12K 的内存:



由于节点 1024 已经无法分配所需的内存,而节点 512 还能够分配,因此节点 512 让其右节点再尝试。

上述介绍的是内存分配的过程,而内存回收的过程就是上述过程的逆过程——回收后将对应节点的 memoryMap 的值修改回去。这里不过多介绍。

PoolChunkList——对 PoolChunk 的管理

PoolChunkList 内部有一个 PoolChunk 组成的链表。通常一个 PoolChunkList 中的所有 PoolChunk 使用率(已分配内存/ChunkSize)都在相同的范围内。

每个 PoolChunkList 有自己的最小使用率或者最大使用率的范围,PoolChunkList 与 PoolChunkList 之间又会形成链表,java培训班并且使用率范围小的 PoolChunkList 会在链表中更加靠前。

而随着 PoolChunk 的内存分配和使用,其使用率发生变化后,PoolChunk 会在 PoolChunkList 的链表中,前后调整,移动到合适范围的 PoolChunkList 内。

这样做的好处是,使用率的小的 PoolChunk 可以先被用于内存分配,从而维持 PoolChunk 的利用率都在一个较高的水平,避免内存浪费。

PoolSubpage——小内存的管理者

PoolChunk 管理的最小内存是一个 Page(默认 8K),而当我们需要的内存比较小时,直接分配一个 Page 无疑会造成内存浪费。PoolSubPage 就是用来管理这类细小内存的管理者。

小内存是指小于一个 Page 的内存,可以分为 Tiny 和 Smalll,Tiny 是小于 512B 的内存,而 Small 则是 512 到 4096B 的内存。如果内存块大于等于一个 Page,称之为 Normal,而大于一个 Chunk 的内存块称之为 Huge。

而 Tiny 和 Small 内部又会按具体内存的大小进行细分。

对 Tiny 而言,会分成 16,32,48...496(以 16 的倍数递增),共 31 种情况。

对 Small 而言,会分成 512,1024,2048,4096 四种情况。

PoolSubpage 会先向 PoolChunk 申请一个 Page 的内存,然后将这个 page 按规格划分成相等的若干个内存块(一个 PoolSubpage 仅会管理一种规格的内存块,例如仅管理 16B,就将一个 Page 的内存分成 512 个 16B 大小的内存块)。

每个 PoolSubpage 仅会选一种规格的内存管理,因此处理相同规格的 PoolSubpage 往往是通过链表的方式组织在一起,不同的规格则分开存放在不同的地方。

并且总是管理一个规格的特性,让 PoolSubpage 在内存管理时不需要使用 PoolChunk 的完全二叉树方式来管理内存(例如,管理 16B 的 PoolSubpage 只需要考虑分配 16B 的内存,当申请 32B 的内存时,必须交给管理 32B 的内存来处理),仅用 long[] bitmap (可以看成是位数组)来记录所管理的内存块中哪些已经被分配(第几位就表示第几个内存块)。

实现方式要简单很多。

PoolArena——内存管理的统筹者

PoolArena 是内存管理的统筹者。

它内部有一个 PoolChunkList 组成的链表(上文已经介绍过了,链表是按 PoolChunkList 所管理的使用率划分)。

此外,它还有两个 PoolSubpage 的数组,PoolSubpage[] tinySubpagePools 和 PoolSubpage[] smallSubpagePools。

默认情况下,tinySubpagePools 的长度为 31,即存放 16,32,48...496 这 31 种规格的 PoolSubpage(不同规格的 PoolSubpage 存放在对应的数组下标中,相同规格的 PoolSubpage 在同一个数组下标中形成链表)。

同理,默认情况下,smallSubpagePools 的长度为 4,存放 512,1024,2048,4096 这四种规格的 PoolSubpage。

PoolArena 会根据所申请的内存大小决定是找 PoolChunk 还是找对应规格的 PoolSubpage 来分配。

值得注意的是,PoolArena 在分配内存时,是会存在竞争的,因此在关键的地方,PoolArena 会通过 sychronize 来保证线程的安全。

Netty 对这种竞争做了一定程度的优化,它会分配多个 PoolArena,让线程尽量使用不同的 PoolArena,减少出现竞争的情况。

PoolThreadCache——线程本地缓存,减少内存分配时的竞争

PoolArena 免不了产生竞争,Netty 除了创建多个 PoolArena 减少竞争外,还让线程在释放内存时缓存已经申请过的内存,而不立即归还给 PoolArena。

缓存的内存被存放在 PoolThreadCache 内,它是一个线程本地变量,因此是线程安全的,对它的访问也不需要上锁。

PoolThreadCache 内部是由 MemeoryRegionCache 的缓存池(数组),同样按等级可以分为 Tiny,Small 和 Normal(并不缓存 Huge,因为 Huge 效益不高)。

其中 Tiny 和 Small 这两个等级下的划分方式和 PoolSubpage 的划分方式相同,而 Normal 因为组合太多,会有一个参数控制缓存哪些规格(例如,一个 Page, 两个 Page 和四个 Page 等...),不在 Normal 缓存规格内的内存块将不会被缓存,直接还给 PoolArena。

再看 MemoryRegionCache, 它内部是一个队列,同一队列内的所有节点可以看成是该线程使用过的同一规格的内存块。同时,它还有个 size 属性控制队列过长(队列满后,将不在缓存该规格的内存块,而是直接还给 PoolArena)。

当线程需要内存时,会先从自己的 PoolThreadCache 中找对应等级的缓存池(对应的数组)。然后再从数组中找出对应规格的 MemoryRegionCache。最后从其队列中取出内存块进行分配。

Netty 内存机构总览和 PooledByteBufAllocator 申请内存步骤

在了解了上述这么多概念后,通过一张图给读者加深下印象。



上图仅详细画了针对 Heap Memory 的部分,Directory Memory 也是类似的。

最后在由 PooledByteBufAllocator 作为入口,重头梳理一遍内存申请的过程:

  1. PooledByteBufAllocator.newHeapBuffer()开始申请内存

  2. 获取线程本地的变量 PoolThreadCache 以及和线程绑定的 PoolArena

  3. 通过 PoolArena 分配内存,先获取 ByteBuf 对象(可能是对象池回收的也可能是创建的),在开始内存分配

  4. 分配前先判断此次内存的等级,尝试从 PoolThreadCache 的找相同规格的缓存内存块使用,没有则从 PoolArena 中分配内存

  5. 对于 Normal 等级内存而言,从 PoolChunkList 的链表中找合适的 PoolChunk 来分配内存,如果没有则先像 OS 申请一个 PoolChunk,在由 PoolChunk 分配相应的 Page

  6. 对于 Tiny 和 Small 等级的内存而言,从对应的 PoolSubpage 缓存池中找内存分配,如果没有 PoolSubpage,线会到第 5 步,先分配 PoolChunk,再由 PoolChunk 分配 Page 给 PoolSubpage 使用

  7. 对于 Huge 等级的内存而言,不会缓存,会在用的时候申请,释放的时候直接回收

  8. 将得到的内存给 ByteBuf 使用,就完成了一次内存申请的过程

文章来源于业余草

用户头像

@零度

关注

关注尚硅谷,轻松学IT 2021.11.23 加入

还未添加个人简介

评论

发布
暂无评论
java培训:Netty的内存管理