Netty源码之内存管理(一)(4.1.44 )

Netty源码之内存管理(一)(4.1.44 )

Netty 作为一款高性能的网络应用程序框架,拥有自己的内存分配。其思想源于 jemalloc github ,可以说是 jemalloc 的 Java 版本。
本章源码基于 Netty 4.1.44 版本,该版本是采用 jemalloc3.x 的算法思想,而 4.1.45 以后的版本则基于 jemalloc4.x 算法进行重构,两者差别还是挺大的。

高性能内存分配

jemalloc 是由 Jason Evans 在 FreeBSD 项目中引入的新一代内存分配器。它是一个通用的 malloc 实现,侧重于减少内存碎片和提升高并发场景下内存的分配效率,其目标是能够替代 mallocjemalloc 应用十分广泛,在 Firefox、Redis、Rust、Netty 等出名的产品或者编程语言中都有大量使用。具体细节可以参考 Jason Evans 发表的论文 [《A Scalable Concurrent malloc Implementation for FreeBSD》]。
除了 jemalloc 之外,业界还有一些著名的高性能内存分配器实现,比如 ptmalloctcmalloc。简单对比如下:

  • ptmalloc(per-thread malloc) 基于 glibc 实现的内存分配器,由于是标准实现,兼容性较好。缺点是多线程之间内存无法实现共享,内存开销很大。
  • tcmalloc(thread-caching malloc) 是由 Google 开源,最大特点是带有线程缓存,目前在 Chrome、Safari 等产品中有所应用。tcmalloc 为每个线程分配一个局部缓存,可以从线程局部缓冲分配小内存对象,而对于大内存分配则使用自旋锁减少内存竞争,提高内存效率。
  • jemalloc 借鉴 tcmalloc 优秀的设计思路,所以在架构设计方面两者有很多相似之处,同样都包含线程缓存特性。但是 jemalloc 在设计上比 tcmalloc 要复杂。它将内存分配粒度划分为** Small、Large、Huge**,并记录了很多元数据,所以元数据占用空间高于 tcmalloc。

从上面了解到,他们的核心目标无外乎有两点:

  • 高效的内存分配和回收,提升单线程或多线程场景下的性能。
  • 减少内存碎片,包括内存碎片和外部碎片。提高内存的有效利用率。

内存碎片

在 Linux 世界,物理内存会被划分成若干个 4KB 大小的内存页(page),这是分配内存大小的最小粒度。分配和回收都是基于 page 完成的。page 内产生的碎片称为 内存碎片,page 外产生的碎片称为 外部碎片
内存碎片产生的原因是内存被分割成很小的块,虽然这些块是空闲且地址连续的,但却小到无法使用。随着内存的分配和释放次数的增加,内存将变得越来越不连续。最后,整个内存将只剩下碎片,即便有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就无法满足,所以减少内存浪费的核心就是尽量避免产生内存碎片。

常见的内存分配器算法wiki

常见的内存分配器算法有:

  • 动态内存分配
  • 伙伴算法 Wiki
  • Slab算法

动态内存分配

全称 Dynamic memory allocation,又称为 堆内存分配,简单 DMA。简单地说就是想要多少内存空间,操作系统就给你多少。在大部分场景下,只有在程序运行时才知道所需内存空间大小,提前分配的内存大小空间不好把控,分配太多造成空间浪费,分配太少造成程序崩溃。
DMA 就是从一整块内存中 按需分配,对于已分配的内存会记录元数据,同时还会使用空闲分区维护空闲内存,便于在下次分配时快速查找可用的空闲分区。常见的有以下三种查找策略:

首次适应算法(first fit)

  • 空闲分区按内存地址从低到高的顺序以双向链表形式连接在一起。
  • 内存分配每次从低地址开始查找并分配。因此造成低地址使用率较高而高地址使用率很低。同时会产生较多的小内存。

循环首次适应算法(next fit)

  • 该算法是 首次适应算法 的变种,主要变化是第二次的分配是从下一个空闲分区开始查找。
  • 对于 首次适应算法 ,该算法将内存分配得更加均匀,查找效率有所提升,但是这会导致严重的内存碎片。

最佳适应算法(best fit)

  • 空间分区链始终保持从小到大的递增顺序。当内存分配时,从开头开始查找适合的空间内存并分配,当完成分配请求后,空闲分区链重新按分区大小排序。
  • 此算法的空间利用率更高,但同样会有难以利用的小空间分区,究其原因是空闲内存块大小不变,并没有针对内存大小做优化分类,除非内存内存大小刚好等于空闲内存块的大小,空间利用率 100%。
  • 每次分配完后需要重新排序,因此存在 CPU 消耗。

伙伴算法(Buddy memory allocation)wiki

伙伴内存分配技术是一种内存分配算法,它将内存划分为分区,以最合适的大小满足内存请求。于 1963 年 Harry Markowitz 发明。
伙伴算法把所有的空闲页框分组成 11 个块链表,每一个块链表分别包含大小为1、2、4、8、16、32、64、128、256、512 和 1024 个连续的页框。最大内存请求大小为 4MB,该内存是连续的。

  • 伙伴算法即大小相同、地址连续。
  • 缺点: 虽然伙伴算法有效减少了外部碎片,但最小粒度还是 page,因此有可能造成非常严重的内部碎片,最严重带来 50% 的内存碎片。

Slab 算法

  • 伙伴 算法 在小内存场景下并不适用,因为每次都会分配一个 page,导致内存学杂费。而 Slab 算法 则是在 伙伴算法 的基础上对小内存分配场景做了专门的优化:
    • 提供调整缓存机制存储内核对象,当内核需要再次分配内存时,基本上可以通过缓存中获取。
  • Linux 底层采用 Slab 算法 进行内存分配。

jemalloc 算法

jemalloc 是基于 Slab 而来,比 Slab 更加复杂。Slab 提升小内存分配场景下的速度和效率,jemalloc 通过 ArenaThread Cache 在多线程场景下也有出色的内存分配效率。Arena分而治之思想的体现,与其让一个人管理全部内存,到不如将任务派发给多个人,每个人独立管理,互不干涉(线程竞争)。
Thread Cachetcmalloc 的核心思想,jemalloc 也把它借鉴过来。每个线程有自己的内存管理器,分配在这个线程内完成,就不需要和其他线程竞争。相关文档

  • Facebook Engineering post: This article was written in 2011 and corresponds to jemalloc 2.1.0.
  • jemalloc(3) manual page: The manual page for the latest release fully describes the API and options supported by jemalloc, and includes a brief summary of its internals.

Netty 底层的内存分配是采用 jemalloc 算法思想。

内存规格

Netty 保留了对不同大小的内存采用不同的分配策略,具体规格如上图所示。在 Netty 中定义了 io.netty.buffer.PoolArena.SizeClass 枚举类,用于描述上图的内存规格类型,分别是 Tiny、Small 和 Normal。>16MB 时,归为Huge类型。
Netty 在每个区域内又定义了更细粒度的内存分配单位,分别是 Chunk、Page 和 Subpage。
Netty源码之内存管理(一)(4.1.44 )_第1张图片

// io.netty.buffer.PoolArena.SizeClass
enum SizeClass {
    Tiny,
    Small,
    Normal
}

内存规格化

Netty 需要对用户申请的内存大小进行 规格化 处理,目的是方便后续计算和内存分配。比如用户申请的内存大小为 31B,如果不进行内存规格化,直接返回 31B 内存大小,那不就成 DMA 内存分配了么?
通过内存规格化,将 31B 规格化为 32B,将 15MB 规格化 16MB。当然,对于不同类型的内存策略不同。
从上图可以看出一些端倪:

  • 对于 Huge 级别的内存大小,用户申请多少内存就返回多少内存(如有必要,需要内存对齐)。
  • 对于 tinysmallnormal 级别的内存,以 512B 为分界线有:
    • >=512B时,返回最接近 2且大于用户申请内存的大小的值。比如申请内存大小为 513B,则返回 1024B
    • <512B 时,返回最接近 16 的倍数且大于用户申请内存的大小的值。比如申请内存大小为 17B,则返回 32B; 申请内存大小为 46B,返回 48B

内存规格化核心源码在 io.netty.buffer.PoolArena 对象中,PoolArena 是 Netty 管理内存最重要的一个类:

获取最接近 2^n 的数

我们需要对申请的内存进行规格化便于计算和管理。下面是将 1025 进行规格化的过程:
![获取2^n的数.png](https://img-blog.csdnimg.cn/img_convert/8a942d577a9e825fb0ff869eb4357e48.png#align=left&display=inline&height=1104&margin=[object Object]&name=获取2^n的数.png&originHeight=1104&originWidth=2324&size=314131&status=done&style=none&width=2324)
上面一连串的
位移
计算,看得眼花缭乱。其实最主要的目的是找到最接近 2且大于用户申请内存的大小的值。思路是把二进制 0100 0000 0001(1025) 变成 0111 1111 1111(2048)。记初始值为 i,原始值的二进制最高位为 1 的序号记为 j,具体执行过程描述如下:

  • 先执行 i-1 操作,目的是解决当值为 2时也能得到本身,而非 2。
  • 再执行 i |= i>>>1 运算,目的是赋值第 j-1 位的值为 1。已经第 j 位位置确定为 1,那么无符号右移一位后第 j-1 也为 1。再与原值进行 | 运算后更新第 j-1 的值。此时,原值的第 jj-1 都确定为 1,那么接下来就可以无符号右移两倍,让 j-2j-3 赋值为 1。由于 int 类型有 32 位,所以只需要进行 5 次运算,每次分别无符号右移1、2、4、8、16 就可让小于 i 的所有位都赋值为 1

获取最近的下一个16的倍数值

其实思路很简单,先把低四位的值抹去(变成0),再加上 16 就得到了目标值。

(reqCapacity & ~15) + 16;
// 0000 0000 0000 0000 0000 0000 0010 1100 (44)(原始值)
// 0000 0000 0000 0000 0000 0000 0000 1111 (15)(15)
// 1111 1111 1111 1111 1111 1111 1111 0000 (-16)(~15) 				  // ~15
// 0000 0000 0000 0000 0000 0000 0010 0000 (32)(reqCapacity& ~15) 	  // 抹去低4位
// 0000 0000 0000 0000 0000 0000 0011 0000 (48)		  			      // +16,补值 

小结

  • Netty 通过大量的位运算来提升性能,但代码的可读性不太好。因此,大家可以通过一边网上搜索一边通过模拟位运算体会各个位之间的变化过程。
  • 位运算的使用技巧,可以看看 位运算简介及实用技巧,里面讲得十分详细。
  • Netty 和内存规格化的位运算技巧展示了三个:
    • 一是找到离分配内存最近且大于分配内存的 2 值。
    • 二是找到离分配内存最近且大于分配内存的16 倍的值。
    • 三是通过掩码判断是否大于某个数。
  • 内存规格化的单位是字节(byte),而非字(bit)。

Netty 内存池分配整体思路

  1. 首先,Netty 会向 操作系统 申请一整块 **连续内存,**称为 chunk(数据块),除非申请 Huge 级别大小的内存,否则一般大小为 16MB,使用 io.netty.buffer.PoolChunk 对象包装。具体长这样子:

Netty源码之内存管理(一)(4.1.44 )_第2张图片

  1. Nettychunk进一步拆分为多个page,每个 page 默认大小为 8KB,因此每个 chunk 包含 2048page。为了对小内存进行精细化管理,减少内存碎片,提高内存使用率,Netty 对 **page **进一步拆分若干 subpage,subpage 的大小是动态变化的,最小为 16Byte
  2. 计算: 当请求内存分配时,将所需要内存大小进行内存规格化,获得合适的内存值。根据值确认准确的树的高度。
  3. 搜索: 在该分组大小的相应高度从左至右搜寻空闲分组并进行分配。
  4. 标记: 分组被标记为全部已使用,且通过循环更新其父节点标记信息。父节点的标记值取两个子节点标记值的最小的一个。

当然,上面说的只是整体思路,一时看还云里雾里的。相信经过下面的讲述能帮助你拔云见日。

Huge 分配逻辑概述

大内存分配比其他类型的内存分配稍微简单一点,操作的内存单元是 PoolChunk,它的容量大小是用户申请的容量(可满足内存对齐要求)。Netty 对 Huge 对象的内存块采用非池化管理策略,在每次请求分配内存时单独创建特殊的非池化 PoolChunk 对象,当对象内存释放时整个 PoolChunk 内存也会被释放。
大内存的分配逻辑是在 io.netty.buffer.PoolArena#allocateHuge 完成。

Normal 分配逻辑

Normal 级别分配的大小范围是 [4097B, 16M) 。核心思想是将 PoolChunk 拆分成 2048page ,这是 Normal 分配的最小单位。每个 page 等大(pageSize=8KB),并在逻辑上通过一棵满二叉树管理这些 page 对象。我们申请的内存本质是组合若干个 page
Normal 的分配核心逻辑是在 PoolChunk#allocateRun(int) 完成。

Small 分配逻辑

Small 级别分配的大小范围是 (496B, 4096B] 。核心是把一个 page 拆分若干个 SubpagePoolSubpage 就是这些若干个 Subpage 的化身,有效解决小内存场景造成内存碎片的问题。
一个 page 大小为 8192B,有且只有四种大小: 512B1024B2048B4096B,以 2 倍递增。当申请的内存大小在 496B~4096B 范围内时,就能确定这四种中的一种。
当进行内存分配时,先在树的最底层找到一个空闲的 page,拆分成若干个 subpage,并构造一个 PoolSubpage 进行管理。选择第一个 subpage 用于此次申请,标记为已使用,并将 PoolSubpage 放置在 PoolSubpage[] smallSubpagePools 数组所对应的链表中。等下次申请等大容量内存时就可从 PoolSubpage[] 直接分配从链表中分配内存。

Tiny 分配逻辑

Tiny 级别分配的大小范围是 (0B, 496B] 。分配逻辑与 Small 类似,先找到空闲的 Page 然后将其拆分若干个 Subpage 并构造一个 PoolSubpage 对它们进行管理。随后选择第一个 subpage 用于此次申请,并将对象 PoolSubpage 放置在 PoolSubpage[] tinySubpagePools 数组所对应的链表中。等待下次分配时使用。区别在于如何定义若干个? Tiny 给出的定义逻辑是获取最接近 16*N 的且大于规格值的大小。比如申请内存大小为 31B,找到最接近的下一个 16*1 的倍数且大于 31 的值是 32,因此,就把 Page 拆分成 8192/32=256 个 subpage,这里的若干个就是根据规格值确定的,它是可变的值。

PoolArena

上面讲述了针对不同级别 Netty 是如何完成内存分配的。接下来,我们先对一些类进行认识,为后续源码解读打下基础。
PoolArena 是进行池化内存分配的核心类,采用固定数量的多个 Arena 进行内存分配,默认与 CPU 核心数量有关,它是线程共享的对象,每个线程只会绑定一个 PoolArena,线程和 PoolArena多对一的关系。当某个线程首次申请内存分配时,会通过轮询(Round-Robin) 方式得到一个 Arena,在该线程的整个生命周期内只和这个 Arena 打交道,前面也说过,PoolArena 是分治思想的体现,在多线程场景下有出色的表现。PoolArena 提供 DirectArenaHeapArena 子类,这是因为底层容器类型不同所以需要子类区分。但核心逻辑是在 PoolArena 完成的。PoolArena 的数据结构大致(除去监测指标数据)可分为两大类: 存储 PoolChunk6PoolChunkList 和 存储 PoolSubpage2 个数组。PoolArena 构造器初始化也做了很多重要的工作,包含串联 PoolChunkList 以及初始化 PoolSubpage[]

初始化 PoolChunkList

Netty源码之内存管理(一)(4.1.44 )_第3张图片

q000q025q050q075q100 表示最低内存使用率。如下图所示
Netty源码之内存管理(一)(4.1.44 )_第4张图片

任意 PoolChunkList 都有内存使用率的上下限: minUsagmaxUsage。如果使用率超过 maxUsage,那么 PoolChunk 会从当前 PoolChunkList 移除,并移动到下一个PoolChunkList 。同理,如果使用率小于 minUsage,那么 PoolChunk 会从当前 PoolChunkList 移除,并移动到前一个PoolChunkList
每个 PoolChunkList 的上下限都有交叉重叠的部分,因为 PoolChunk 需要在 PoolChunkList 不断移动,如果临界值恰好衔接的,则会导致 PoolChunk 在两个 PoolChunkList 不断移动,造成性能损耗。
PoolChunkList 适用于 Chunk 场景下的内存分配,PoolArena 初始化 6PoolChunkList 并按上图首尾相连,形成双向链表,唯独 q000 这个 PoolChunkList 是没有前向节点,是因为当其余 PoolChunkList 没有合适的 PoolChunk 可以分配内存时,会创建一个新的 PoolChunk 放入 pInit 中,然后根据用户申请内存大小分配内存。而在 p000 中的 PoolChunk ,如果因为内存归还的原因,使用率下降到 0%,则不需要放入 pInit,直接执行销毁方法,将整个内存块的内存释放掉。这样,内存池中的内存就有生成/销毁等完成生命周期流程,避免了在没有使用情况下还占用内存。

初始化 PoolSubpage[]

PoolSubpage 是对某一个 page 的化身,由于 Page 还可以按 elemSize 拆分成若干个 subpage,在 PoolArena 使用 PoolSubpage[] 数组来存储 PoolSubpage 对象,经过 PoolArena 后如下图所示:
Netty源码之内存管理(一)(4.1.44 )_第5张图片

还记得这幅图么:
Netty源码之内存管理(一)(4.1.44 )_第6张图片

对于 Small 它拥有四种不同大小的规格,因此 smallSupbagePools 的数组长度为 4smallSubpagePools[0] 表示 elemSize=512BPoolSubpage 对象的链表,smallSubpagePols[1] 表示 elemSize=1024BPoolSubpages 对象的链表。以此类推,tinySubpagePools 原理一样,只不过划分的粒度(步长)比较少,以 16 的倍数递增。因此,由于 Tiny 大小限制,总共可分为 32 类,因此 tinySubpagePools 数组长度为 32。数组下标所对应的 size 容量不一样,且每个数组都对应一组双向链表。这两个数组用来存储 PoolSubpage 对象且按 PoolSubpage#elemSize 确定索引的位置 index,最后将它们构造双向链表。

源码

子类实现

继承体系如下图所示:
Netty源码之内存管理(一)(4.1.44 )_第7张图片

  • PoolArenaMetric: 定义与 PoolArena 相关监控接口。
  • PoolArena: 抽象类。定义了主要的核心变量和部分内存分配逻辑。由于存储数据容器不同,创建和销毁逻辑也有所不一样。因此它有两个子类,分别是 DirectArena、HeapArena。

抽象类 PoolArena 有几个子类必须实现的接口:
Netty源码之内存管理(一)(4.1.44 )_第8张图片
这些抽象方法就是 DirectArenaHeapArena 实现类的区别,具体细节就不再描述了。

PoolChunkList

PoolChunkList 是一个双向链表,用来存储 PoolChunk 对象,它指向 PoolChunk 链表的头结点。
而对于 PoolChunkList 节点本身来说,它与其他 PoolChunkList 也构成一个双向链表。如上图所示。PoolChunkList 内部定义比较简单:
Netty源码之内存管理(一)(4.1.44 )_第9张图片

PoolChunk

PoolChunk 是 Netty 对 jemalloc3.x 算法思想的描述,它是 Netty 内存分配的最核心的类。

文档翻译

概述描述

pageChunk 可分配的最小内存单元,Chunkpage 的集合,Chunk 大小的计算公式为 chunkSize = 2^{maxOrder} * pageSize
首先,我们分配一个 size = chunkSize 的字节数组,当需要创建一个给定大小的 ByteBuf 时,我们搜索字节数组中的第一个位置,该位置有足够的空闲空间来容纳请求的大小,并返回一个 long 类型的句柄值来编码这个偏移量信息(这个内存段然后被标记为保留,所以它总是由一个 ByteBuf 使用,而不是多个)。
为了简单起见,所有用户申请内存的大小都按 PoolArena#normalizecapacity 方法法进行规格化处理。这确保了当我们请求大小 >= pageSize 的内存段时,规格化容量等于下一个最近的2的次幂。
为了获取请求大小可用的第一个偏移量,我们构造了一棵 满二叉树(Compelte balanced binary) 从而加快搜索速度。使用数组 memoryMap 存储这棵树的信息。这棵树看起来看是这样的(括号中的表示每个节点的大小)

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

depth=maxOrder 时,叶子节点是由 page 组成。

搜索算法

用符号在 memoryMap 中编码满二叉树。

  • memoryMap 类型是 byte[],用来记录树的分配情况。初始值为对应节点所在的树的深度。
  • memoryMap[id] = depth_of_id => 空闲/完全未分配。
  • memoryMap[id] > depth_of_id => 至少有一个子节点已经被分配了,但其他子节点仍然可分配。
  • memoryMap[id] = maxOrder + 1 => 当前节点已经完成分配了,即当前节点处于不可用状态。

allocateNode(d)

目标是在对应深度从左到右找到第一个空闲的可分配的节点。参数 d 表示 depth

  • 从头结点开始。(depth=0 或 id=1)
  • 如果 memoryMap[1] > d 表示这个 Chunk 无可用分配内存。
  • 如果左节点的值 <=h,我们可以从左子树进行分配,重复直到找到空闲节点。
  • 否则深度右子树并重复直到找到空闲节点。

allocateRun(size)

分配一组 page。参数 size 表示规格化后的内存大小。

  • 计算 size 所对应的深度。公式 d = log_2(chunkSize/size)
  • 返回 allocateNode(d)

allocateSubpage(size)

创建/初始化一个 normcacity 大小的新 PoolSubpage。创建/初始化任意 PoolSubpage 都会添加到拥有这个 PoolChunkPoolArena 的子页内存池中。

  • 使用 allocateNode(maxOrder) 找到任意空闲的页子节点,返回一个 handle 变量。
  • 使用 handle 构建 PoolSubpage 对象并添加到 PoolArenasubpagePool 内存池中。

源码

PoolChunk 源码相对比较复杂,首先需要把定义的变量理解清楚,为后续内存分配源码分析打下基础。

相关方法一览:

Netty源码之内存管理(一)(4.1.44 )_第10张图片

这是只是为了让大家留有印象,等到源码分析时可以来这里看看对应的变量和方法到底做了些什么事情。

PoolSubpage

PoolSubpageSmallTiny 级别分配内存时所使用到的对象。一个 PoolSubpage 对象对应一个 page。因此,一个 PoolSubpage 管理的内存大小为 8KB
相关变量解释如下:

PoolSubpage 管理小内存也是十分有技巧,待后面做详细解读。

再讲池化内存分配

在 ByteBuf 这一章节中我们讲过 ByteBufAllocator 分配器体系。但那里是从整个分配器体系讲解,与池化分配器相关的 PooledByteBufAllocator 只是简单的描述了初始化流程。现在我们继续从这里当做切入点,理清各个类之间如何分配和管理的。
首先我们要知道 PooledByteBufAllocator 是线程安全的类,我们可以通过 PooledByteBufAllocator.DEFAULT 获得一个 io.netty.buffer.PooledByteBufAllocator 池化分配器,这也是 Netty 推荐的做法之一。我们也了解到,PooledByteBufAllocator 会初始两个重要的数组,分别是 heapArenasdirectArenas,所有的与内存分配相关的操作都会委托给 heapArenas 或 directArenas 处理,数组长度一般是通过 2*CPU_CORE 计算得到。这里体现 Netty(准确地说应该是 jemalloc 算法思想) 内存分配设计理念,通过增加多个 Arenas 减少内存竞争,提高在多线程环境下分配内存的速度以及效率。数组 arenas 是由上面我们讲过的 PoolArena 对象构成,它是内存分配的中心枢纽,一位大管家。包括管理 PoolChunk 对象、管理 PoolSubpage 对象、分配内存对象的核心逻辑、管理本地对象缓存池、内存池销毁等等,它的侧重点在于管理已分配的内存对象。而 PoolChunk 是 jemalloc 算法思想的化身,它知道如何有效分配内存,你只需要调用对应方法就能获取想要大小的内存块,它只专注管理物理内存这件事情,至于分配后的事情,它一概不知,也一概不管,反正 PoolArena 这个大管家会操心的。
接下来,我们会通过 PooledByteBufAllocator 相关方法为入口,通过源码带你走进 Netty 分配内存的世界。

堆外内存分配源码实现

堆外内存底层数据存储容器是 java.nio.ByteBuffer 对象。一般通过 io.netty.buffer.AbstractByteBufAllocator#directBuffer(int) 得到一个池化的堆外内存 ByteBuf 对象。跟踪方法,它会通过抽象类 io.netty.buffer.AbstractByteBufAllocator#newDirectBuffer 交给子类实现,这里是使用池化的分配器 PooledByteBufAllocator 实现。相关源码如下:

// io.netty.buffer.PooledByteBufAllocator#newDirectBuffer
/**
 * 获取一个堆外内存的「ByteBuf」对象
 */
@Override
protected ByteBuf newDirectBuffer(int initialCapacity, int maxCapacity) {
    // #1 从本地线程缓存中获取「PoolThreadCache」对象
    PoolThreadCache cache = threadCache.get();
    
    // #2 从缓存对象中获取「directArena」,根据存储类型不同选取对应的「Arena」
    PoolArena<ByteBuffer> directArena = cache.directArena;

    final ByteBuf buf;
    if (directArena != null) {
        // #3-1 委托「directArena」完成内存分配
        buf = directArena.allocate(cache, initialCapacity, maxCapacity);
    } else {
        // #3-2 兜底方案
        buf = PlatformDependent.hasUnsafe() ?
            UnsafeByteBufUtil.newUnsafeDirectByteBuf(this, initialCapacity, maxCapacity) :
            new UnpooledDirectByteBuf(this, initialCapacity, maxCapacity);
    }
    
    // #4 包装生成好的「ByteBuf」对象,用于内存泄漏检查
    return toLeakAwareBuffer(buf);
}

上面就是分配器分配一个池化 ByteBuf 对象的核心源码。是不是感觉很简单,因此内存分配委托 directArena 完成的。之前说过,每个线程只能绑定一个 PoolArena 对象,在整个线程的生命周期内只和这个 PoolArena 打交道,而这个引用是存放在 PoolThreadCache 本地线程缓存里面,某个线程想要分配内存,调用 threadCache.get() 会初始化相关变量,一般 Netty 默认开始本地线程缓存,因此,从 cache 获得 directArena 对象不为空。这个 PoolThreadCache 可有用了! 它持有 PoolArena 对象,通过 MemoryRegionCache 缓存部分 ByteBufferbyte[] 信息,这里我们只需要知道是从 PoolThreadCache 本地缓存中获取其中一个 dicrectArena 对象,通过比较 PoolByteBufAllocator 中每一个 PoolArena#numThreadCaches 大小,返回最小值的 PoolArena 对象。每个线程都拥有 PoolThreadCache。关于 PoolThreadCache 会在新的章节详细介绍。
继续跟着主线,现在执行到 PoolArena#allocate(PoolThreadCache, int, int)。那我们看看 PoolArena 作了些什么:

阶段一: 初始化一个 ByteBuf 实例对象

通过对象池加速 ByteBuf 对象的内存和释放,但不好的一面是有如果对 Netty 底层不了解的开发人员的程序可能导致内存泄漏。如果对象池没有,则直接根据相应规则创建。

// io.netty.buffer.PoolArena#allocate(io.netty.buffer.PoolThreadCache, int, int)
/**
 * 获取池化的「ByteBuf」实例
 */
PooledByteBuf<T> allocate(PoolThreadCache cache, int reqCapacity, int maxCapacity) {
    
    // #1 获取一个「ByteBuf」实例对象。可能直接生成,也有可能从对象池中获取。
    // 它是「PoolArena」抽象类,需要子类实现,这里是「PoolArena」实现类
    PooledByteBuf<T> buf = newByteBuf(maxCapacity);
    
    // #2 为「buf」填充物理内存信息
    allocate(cache, buf, reqCapacity);
    
    // #3 返回
    return buf;
}

// io.netty.buffer.PoolArena.DirectArena#newByteBuf
/**
 * 获取一个「ByteBuf」实例对象。
 */
@Override
protected PooledByteBuf<ByteBuffer> newByteBuf(int maxCapacity) {
    if (HAS_UNSAFE) {
        
        // #1 带有「Unsafe」的「ByteBuf」,一般在服务器中都支持 Unsafe
        // 所以我们仔细看看这个方法是如何实现的
        return PooledUnsafeDirectByteBuf.newInstance(maxCapacity);
    } else {
        
        // #2 「非Unsafe」的「ByteBuf」
        return PooledDirectByteBuf.newInstance(maxCapacity);
    }
}
// io.netty.buffer.PooledUnsafeDirectByteBuf
/**
 * 「PooledUnsafeDirectByteBuf」没有被「public」修饰,它是包可见对象,因此,我们不能通过分配器获得此类型实例。
 * 这个「ByteBuf」拥有「ObjectPool」对象池,可加速对象的分配效率。
 * 还有一个和它类型的,叫「io.netty.buffer.PooledDirectByteBuf」,内部也使用「ObjectPool」对象池。
 * 具体区别是「PooledUnsafeDirectByteBuf」内部维护「memoryAddress」变量,这是「Unsafe」操作的必要变量。
 */
final class PooledUnsafeDirectByteBuf extends PooledByteBuf<ByteBuffer> {
    // 对象池
    private static final ObjectPool<PooledUnsafeDirectByteBuf> RECYCLER = ObjectPool.newPool(
            new ObjectCreator<PooledUnsafeDirectByteBuf>() {
        @Override
        public PooledUnsafeDirectByteBuf newObject(Handle<PooledUnsafeDirectByteBuf> handle) {
            return new PooledUnsafeDirectByteBuf(handle, 0);
        }
    });

    static PooledUnsafeDirectByteBuf newInstance(int maxCapacity) {
        // #1 从对象池中获取「ByteBuf」实例
        PooledUnsafeDirectByteBuf buf = RECYCLER.get();
        
        // #2 重置
        buf.reuse(maxCapacity);
        
        // 返回
        return buf;
    }

    private long memoryAddress;
    
    // 重置所有指针变量
    final void reuse(int maxCapacity) {
        maxCapacity(maxCapacity);
        resetRefCnt();
        setIndex0(0, 0);
        discardMarks();
    }

    // ...
}

阶段二: 为 ByteBuf 填充内存信息

这个阶段的核心方法属于 io.netty.buffer.PoolArena#allocate(io.netty.buffer.PoolThreadCache, io.netty.buffer.PooledByteBuf, int)PoolArena 依据申请内存大小采用不同的内存分配策略,并把内存信息写入 ByteBuf 对象。前面我们对 PoolSubpage[] tinySubpagePools 和 PoolSubpage[] smallSubpagePools 这两个变量有所了解,会在分配 tiny&small 级别内存时使用到。待下次请求分配同等大小的内存时就可以通过现成的 PoolSubpage[] 进行分配。从源码好好休会一下:

现在总结一下堆外内存分配逻辑:

  • 首先,对申请容量进行规格化处理。获取最接近且大于原值的2的幂次方的值,称为规范值。
  • 根据规范值选择合适的分配策略。从大方向讲,有 3 种分配策略,分别是 tiny&smallnormal 以及 Huge
  • Huge 进行内存分配并不会尝试从本地线程缓存分配,也不会对它进行池化管理,直接创建 PoolChunk 对象并返回。
  • Normal 进行内存分配,会按 q050->q025->q000->qInit->q075 顺序进行分配,从 q050 开始分配是因为这是一个折中的分配方案,如果从 q000 分配的话,会有大部分的 PoolChunk 面临频繁的创建和销毁,造成内存分配的性能降低。如果从 q050 开始,会使 PoolChunk 的使用率范围保持在中间水平,既降低了 PoolChunkList 被回收的概率,也兼顾了性能。如果分配成功,则计算该 PoolChunk 的使用率,使用率超过了 PoolChunkList 的上限时,移动到下一个 PoolChunkList 链表中。如果分配失败,则会创建一个新的内存块进行内存,如果分配成功添加到 qInit 链表。
  • 对于 Tiny&Small 级别,会尝试通过 PoolSubpage 分配,如果分配成功则返回。如果分配失败,则还是按 Normal 那套分配逻辑进行分配。

总的来说,PoolArena#allocate 方法是 PoolArena 对象分配内存的核心逻辑,会根据规范值选择合适的分配策略。而且通过本地线程缓存加速内存分配,通过对象池加速 ByteBuf 对象分配,并减少 GC。

堆内内存分配概述

堆内内存和堆外内存分配逻辑大致相同,不同点在于:

  • 使用 PoolArena 的子类 HeapArena 完成分配工作。
  • 底层数据容器为 byte[],而 DirectArenajava.nio.ByteBuffer 对象。

内存回收

内存回收需要分清楚主语是谁?我们知道,Netty 通过 Thead Cache 缓存部分已分配的内存,那么它是如何进行内存回收呢?这里的主语是 Thread Cache。而对于大管家 PoolArena,它是如何管理内存的回收?
众所周期,通过 BytBuf#release() 释放 ByteBuf 对象,这个 API 只会让引用计数值 -1,并非直接回收物理内存。只有当引用计数值为 0 再进行物理内存回收动作。
ByteBuf#release() 调用过程概述如下:
我们通过 Update 对象更新引用计数,如果引用计数为0,则需要释放内存。如果所属的「PoolChunk」不支持池化,则直接释放。对于可池化的「PoolChunk」,首先看能不能通过本地线程缓存待回收的内存信息,如果本地线程缓存成功,则返回。否则交给「PoolArena」处理内存回收。
「PoolArena」会交给所在的「PoolChunkList」链表进行处理。处理逻辑相对简单: 找到「PoolChunk」回收内存,判断「PoolChunk」是否满足 minUsage,不满足则移动前向节点。至此,这就是内存回收大致情况。

// io.netty.buffer.AbstractReferenceCountedByteBuf#release()
@Override
public boolean release() {
    // #1 首先通过 updater 更新「refCnt」的值,refCnt=refCnt-2
    // 如果旧值「refCnt」==2,则update.release(this)会返回true,表示当前「ByteBuf」引用计数为0了,
    // 是时候需要释放了
    // #2 释放内存
    return handleRelease(updater.release(this));
}

// io.netty.buffer.AbstractReferenceCountedByteBuf#handleRelease
private boolean handleRelease(boolean result) {
    if (result) {
        // 释放内存
        deallocate();
    }
    return result;
}

// io.netty.buffer.PooledByteBuf#deallocate
@Override
protected final void deallocate() {
    // 判断句柄变量是否>=0
    if (handle >= 0) {
        final long handle = this.handle;
        this.handle = -1;
        memory = null;
        
        // △ 使用「PoolArena#free」释放
        chunk.arena.free(chunk, tmpNioBuf, handle, maxLength, cache);
        tmpNioBuf = null;
        chunk = null;
        // 回收「ByteBuf」对象
        recycle();
    }
}

// io.netty.buffer.PoolArena#free
/**
 * 由「PoolArena」定义「释放」二字
 * @param chunk			「ByteBuf」所以的「PoolChunk」
 * @param nioBuffer		「ByteBuf」内部的临时「ByteBuffer」对象
 * @param handle		 句柄变量值
 * @param normCapacity	 申请内存值
 * @param cache		     线程缓存
 */
void free(PoolChunk<T> chunk, 
          ByteBuffer nioBuffer, 
          long handle, int normCapacity, PoolThreadCache cache) {
    
    if (chunk.unpooled) {
        
        // #1 待回收「ByteBuf」所属的「Chunk」为非池化,直接销毁
        // 根据底层实现方式不同采取不同销毁策略。
        // 如果是「ByteBuf」对象,根据有无「Cleaner」分类,采取不同的销毁方法
        // 如果是「byte[]」,不做任何处理,JVM GC 会回收这部分内存
        int size = chunk.chunkSize();
        destroyChunk(chunk);
        activeBytesHuge.add(-size);
        deallocationsHuge.increment();
    } else {
        
        // #2 对于池化的「Chunk」
        SizeClass sizeClass = sizeClass(normCapacity);
        if (cache != null &&
            // 尝试添加到本地缓存,至于如何添加,会在另一章节详细说明
            // 内部会使用「MermoryRegionCache」缓存内存信息,比如句柄值,容量大小、属于哪个「chunk」等
            // 待后面这个线程申请等容量大小时就可以从本地线程中分配
			// 那有人会说,有借不还么?那是不可能的,PoolThreadCache会维持添加计数,达到某个阈值则会触发
            // 回收动作,并不会造成内存泄漏
            cache.add(this, chunk, nioBuffer, handle, normCapacity, sizeClass)) {
            return;
        }
		
        // 本地缓存添加失败,那就交给由「PoolArena」完成释放
        freeChunk(chunk, handle, sizeClass, nioBuffer, false);
    }
}

// io.netty.buffer.PoolArena#freeChunk
/**
 * 释放「ByteBuf」对象
 * @param chunk
 * @param handle
 * @param sizeClass
 * @param nioBuffer
 * @param finalizer
 */
void freeChunk(PoolChunk<T> chunk, 
               long handle, 
               SizeClass sizeClass, 
               ByteBuffer nioBuffer, boolean finalizer) {
    final boolean destroyChunk;
    synchronized (this) {
        // We only call this if freeChunk is not called because of the PoolThreadCache finalizer as otherwise this
        // may fail due lazy class-loading in for example tomcat.
        // 这里应对懒加载所做出的判断。比如「Tomcat」卸载某个应用时,会把对应的「ClassLoader」卸载掉,
        // 但对于线程回收finalizer而言可能需要这个类加载器的类信息,因此这里判断一下
        if (!finalizer) {
            switch (sizeClass) {
                case Normal:
                    ++deallocationsNormal;
                    break;
                case Small:
                    ++deallocationsSmall;
                    break;
                case Tiny:
                    ++deallocationsTiny;
                    break;
                default:
                    throw new Error();
            }
        }
        // 调用PoolChunkList#free方法归还内存
        destroyChunk = !chunk.parent.free(chunk, handle, nioBuffer);
    }
    if (destroyChunk) {
        // destroyChunk not need to be called while holding the synchronized lock.
        destroyChunk(chunk);
    }
}

// io.netty.buffer.PoolChunkList#free
boolean free(PoolChunk<T> chunk, long handle, ByteBuffer nioBuffer) {
    
    // #1 先通过「PoolChunk#free」回收内存块
    // 「handle」记录树的位置信息
    // 「PoolChunk」会缓存nioBuffer对象,用于下次体时使用
    chunk.free(handle, nioBuffer);
    
    // #2 判断当前「PoolChunk」的使用率,是否需要移到前一个节点链表中
    if (chunk.usage() < minUsage) {
        remove(chunk);
        // Move the PoolChunk down the PoolChunkList linked-list.
        return move0(chunk);
    }
    return true;
}

总结

以上是我们迈向 Netty 内存的一小步,也是熟悉 Netty 内存的一大步。2333,希望通过对特定的类、结构的分析让大家对整个内存流程有大致的了解。等熟悉这些过程后,我们再深究细节。

我的公众号

Netty源码之内存管理(一)(4.1.44 )_第11张图片

你可能感兴趣的:(Netty,java,netty,内存管理)