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 实现,侧重于减少内存碎片和提升高并发场景下内存的分配效率,其目标是能够替代 malloc
。jemalloc
应用十分广泛,在 Firefox、Redis、Rust、Netty 等出名的产品或者编程语言中都有大量使用。具体细节可以参考 Jason Evans 发表的论文 [《A Scalable Concurrent malloc Implementation for FreeBSD》]。
除了 jemalloc 之外,业界还有一些著名的高性能内存分配器实现,比如 ptmalloc 和 tcmalloc。简单对比如下:
- 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 通过 Arena
和 Thread Cache
在多线程场景下也有出色的内存分配效率。Arena
是分而治之思想的体现,与其让一个人管理全部内存,到不如将任务派发给多个人,每个人独立管理,互不干涉(线程竞争)。
Thread Cache
是 tcmalloc
的核心思想,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。
// io.netty.buffer.PoolArena.SizeClass
enum SizeClass {
Tiny,
Small,
Normal
}
内存规格化
Netty 需要对用户申请的内存大小进行 规格化 处理,目的是方便后续计算和内存分配。比如用户申请的内存大小为 31B
,如果不进行内存规格化,直接返回 31B
内存大小,那不就成 DMA 内存分配了么?
通过内存规格化,将 31B
规格化为 32B
,将 15MB
规格化 16MB
。当然,对于不同类型的内存策略不同。
从上图可以看出一些端倪:
- 对于
Huge
级别的内存大小,用户申请多少内存就返回多少内存(如有必要,需要内存对齐)。 - 对于
tiny
、small
、normal
级别的内存,以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
的值。此时,原值的第j
、j-1
都确定为1
,那么接下来就可以无符号右移两倍,让j-2
、j-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 内存池分配整体思路
- 首先,
Netty
会向操作系统
申请一整块 **连续内存,**称为 chunk(数据块),除非申请Huge
级别大小的内存,否则一般大小为16MB
,使用io.netty.buffer.PoolChunk
对象包装。具体长这样子:
- Netty将chunk进一步拆分为多个page,每个 page 默认大小为
8KB
,因此每个 chunk 包含2048
个 page。为了对小内存进行精细化管理,减少内存碎片,提高内存使用率,Netty 对 **page **进一步拆分若干 subpage,subpage 的大小是动态变化的,最小为16Byte
。 - 计算: 当请求内存分配时,将所需要内存大小进行内存规格化,获得合适的内存值。根据值确认准确的树的高度。
- 搜索: 在该分组大小的相应高度中从左至右搜寻空闲分组并进行分配。
- 标记: 分组被标记为全部已使用,且通过循环更新其父节点标记信息。父节点的标记值取两个子节点标记值的最小的一个。
当然,上面说的只是整体思路,一时看还云里雾里的。相信经过下面的讲述能帮助你拔云见日。
Huge 分配逻辑概述
大内存分配比其他类型的内存分配稍微简单一点,操作的内存单元是 PoolChunk
,它的容量大小是用户申请的容量(可满足内存对齐要求)。Netty 对 Huge
对象的内存块采用非池化管理策略,在每次请求分配内存时单独创建特殊的非池化 PoolChunk
对象,当对象内存释放时整个 PoolChunk
内存也会被释放。
大内存的分配逻辑是在 io.netty.buffer.PoolArena#allocateHuge
完成。
Normal 分配逻辑
Normal 级别分配的大小范围是 [4097B, 16M)
。核心思想是将 PoolChunk
拆分成 2048
个 page
,这是 Normal
分配的最小单位。每个 page 等大(pageSize=8KB),并在逻辑上通过一棵满二叉树管理这些 page 对象。我们申请的内存本质是组合若干个 page
。
Normal
的分配核心逻辑是在 PoolChunk#allocateRun(int)
完成。
Small 分配逻辑
Small 级别分配的大小范围是 (496B, 4096B]
。核心是把一个 page
拆分若干个 Subpage
,PoolSubpage
就是这些若干个 Subpage
的化身,有效解决小内存场景造成内存碎片的问题。
一个 page 大小为 8192B
,有且只有四种大小: 512B
、1024B
、2048B
和 4096B
,以 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
提供 DirectArena
和 HeapArena
子类,这是因为底层容器类型不同所以需要子类区分。但核心逻辑是在 PoolArena
完成的。PoolArena
的数据结构大致(除去监测指标数据)可分为两大类: 存储 PoolChunk
的 6
个 PoolChunkList
和 存储 PoolSubpage
的 2
个数组。PoolArena
构造器初始化也做了很多重要的工作,包含串联 PoolChunkList
以及初始化 PoolSubpage[]
。
初始化 PoolChunkList
q000
、q025
、q050
、q075
、q100
表示最低内存使用率。如下图所示
任意 PoolChunkList
都有内存使用率的上下限: minUsag
、maxUsage
。如果使用率超过 maxUsage
,那么 PoolChunk
会从当前 PoolChunkList
移除,并移动到下一个PoolChunkList
。同理,如果使用率小于 minUsage
,那么 PoolChunk
会从当前 PoolChunkList
移除,并移动到前一个PoolChunkList
。
每个 PoolChunkList
的上下限都有交叉重叠的部分,因为 PoolChunk
需要在 PoolChunkList
不断移动,如果临界值恰好衔接的,则会导致 PoolChunk
在两个 PoolChunkList
不断移动,造成性能损耗。
PoolChunkList
适用于 Chunk
场景下的内存分配,PoolArena
初始化 6
个 PoolChunkList
并按上图首尾相连,形成双向链表,唯独 q000
这个 PoolChunkList
是没有前向节点,是因为当其余 PoolChunkList
没有合适的 PoolChunk
可以分配内存时,会创建一个新的 PoolChunk
放入 pInit
中,然后根据用户申请内存大小分配内存。而在 p000
中的 PoolChunk
,如果因为内存归还的原因,使用率下降到 0%
,则不需要放入 pInit
,直接执行销毁方法,将整个内存块的内存释放掉。这样,内存池中的内存就有生成/销毁等完成生命周期流程,避免了在没有使用情况下还占用内存。
初始化 PoolSubpage[]
PoolSubpage
是对某一个 page
的化身,由于 Page
还可以按 elemSize
拆分成若干个 subpage
,在 PoolArena 使用 PoolSubpage[]
数组来存储 PoolSubpage
对象,经过 PoolArena
后如下图所示:
还记得这幅图么:
对于 Small
它拥有四种不同大小的规格,因此 smallSupbagePools
的数组长度为 4
,smallSubpagePools[0]
表示 elemSize=512B
的 PoolSubpage
对象的链表,smallSubpagePols[1]
表示 elemSize=1024B
的 PoolSubpages
对象的链表。以此类推,tinySubpagePools
原理一样,只不过划分的粒度(步长)比较少,以 16
的倍数递增。因此,由于 Tiny
大小限制,总共可分为 32
类,因此 tinySubpagePools
数组长度为 32
。数组下标所对应的 size
容量不一样,且每个数组都对应一组双向链表。这两个数组用来存储 PoolSubpage
对象且按 PoolSubpage#elemSize
确定索引的位置 index
,最后将它们构造双向链表。
源码
子类实现
继承体系如下图所示:
PoolArenaMetric
: 定义与PoolArena
相关监控接口。PoolArena
: 抽象类。定义了主要的核心变量和部分内存分配逻辑。由于存储数据容器不同,创建和销毁逻辑也有所不一样。因此它有两个子类,分别是 DirectArena、HeapArena。
抽象类 PoolArena
有几个子类必须实现的接口:
这些抽象方法就是 DirectArena
和 HeapArena
实现类的区别,具体细节就不再描述了。
PoolChunkList
PoolChunkList
是一个双向链表,用来存储 PoolChunk
对象,它指向 PoolChunk
链表的头结点。
而对于 PoolChunkList
节点本身来说,它与其他 PoolChunkList
也构成一个双向链表。如上图所示。PoolChunkList
内部定义比较简单:
PoolChunk
PoolChunk
是 Netty 对 jemalloc3.x
算法思想的描述,它是 Netty 内存分配的最核心的类。
文档翻译
概述描述
page
是 Chunk
可分配的最小内存单元,Chunk
是 page
的集合,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
都会添加到拥有这个 PoolChunk
的 PoolArena
的子页内存池中。
- 使用
allocateNode(maxOrder)
找到任意空闲的页子节点,返回一个handle
变量。 - 使用
handle
构建PoolSubpage
对象并添加到PoolArena
的subpagePool
内存池中。
源码
PoolChunk
源码相对比较复杂,首先需要把定义的变量理解清楚,为后续内存分配源码分析打下基础。
相关方法一览:
这是只是为了让大家留有印象,等到源码分析时可以来这里看看对应的变量和方法到底做了些什么事情。
PoolSubpage
PoolSubpage
是 Small
、Tiny
级别分配内存时所使用到的对象。一个 PoolSubpage
对象对应一个 page。因此,一个 PoolSubpage
管理的内存大小为 8KB
。
相关变量解释如下:
PoolSubpage
管理小内存也是十分有技巧,待后面做详细解读。
再讲池化内存分配
在 ByteBuf 这一章节中我们讲过 ByteBufAllocator
分配器体系。但那里是从整个分配器体系讲解,与池化分配器相关的 PooledByteBufAllocator
只是简单的描述了初始化流程。现在我们继续从这里当做切入点,理清各个类之间如何分配和管理的。
首先我们要知道 PooledByteBufAllocator
是线程安全的类,我们可以通过 PooledByteBufAllocator.DEFAULT
获得一个 io.netty.buffer.PooledByteBufAllocator
池化分配器,这也是 Netty 推荐的做法之一。我们也了解到,PooledByteBufAllocator
会初始两个重要的数组,分别是 heapArenas
和 directArenas
,所有的与内存分配相关的操作都会委托给 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
缓存部分 ByteBuffer
或 byte[]
信息,这里我们只需要知道是从 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<T>, int)
,PoolArena
依据申请内存大小采用不同的内存分配策略,并把内存信息写入 ByteBuf
对象。前面我们对 PoolSubpage<T>[] tinySubpagePools
和 PoolSubpage[] smallSubpagePools 这两个变量有所了解,会在分配 tiny&small
级别内存时使用到。待下次请求分配同等大小的内存时就可以通过现成的 PoolSubpage<T>[]
进行分配。从源码好好休会一下:
现在总结一下堆外内存分配逻辑:
- 首先,对申请容量进行规格化处理。获取最接近且大于原值的2的幂次方的值,称为规范值。
- 根据规范值选择合适的分配策略。从大方向讲,有
3
种分配策略,分别是tiny&small
、normal
以及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[]
,而DirectArena
是java.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,希望通过对特定的类、结构的分析让大家对整个内存流程有大致的了解。等熟悉这些过程后,我们再深究细节。