基于本文回答

播面 播面

刷题像听歌,多听自然懂
0
评论

Netty 内存分配算法中用到的 Jemalloc 思想及其内部核心结构

Netty 的内存管理是其实现高性能、低延迟的核心组件之一。为了解决 Java 原生 ByteBuffer 分配释放昂贵(尤其是 Direct Buffer)以及内存碎片化的问题,Netty 引入了基于 Jemalloc 算法思想的内存池(PooledByteBufAllocator)。

需要特别注意的是,Netty 的内存管理在 4.1.45 版本发生过一次重大重构:

  • 重构前:参考 Jemalloc 3,使用满二叉树(伙伴算法)管理 Chunk 中的 Page。
  • 重构后:参考 Jemalloc 4,移除了二叉树,改用 Run(连续的 Page 集合) 和基于 SizeClass 的位图管理,进一步降低了内存碎片和计算开销。

以下将围绕 Netty 借鉴的 Jemalloc 核心思想及其内部核心数据结构进行深度解析。


一、 借鉴的 Jemalloc 核心思想

Jemalloc 的设计初衷是为了在多线程环境下提供极其优异的内存分配性能,Netty 主要借鉴了其以下三个核心思想:

  1. Size Class(规格化内存)
    将请求的内存大小向上取整到特定的固定规格(Size Class),而不是按需分配任意大小。例如请求 13 字节,会分配 16 字节。这大大简化了内存管理,并降低了外部碎片,尽管会产生一定的内部碎片。
  2. Thread Local Cache(线程本地缓存)
    无锁化分配的核心。每个线程都有自己专属的内存缓存(TLAB思想)。当线程申请内存时,优先从本地缓存获取;释放时,优先放回本地缓存。这极大减少了多线程并发申请内存时产生的锁竞争。
  3. Arena(多内存阵列隔离)
    对于无法在线程缓存中满足的分配,Jemalloc 采用多个 Arena(内存管理区)。每个 Arena 独立管理一部分内存,线程被均匀绑定到不同的 Arena 上,从而将全局的锁竞争分散到多个 Arena 内部。

二、 Netty 内存池的核心内部结构

Netty 的内存池由上到下呈现严格的层级结构:PoolArena -> PoolChunk -> PoolPage (Run) -> PoolSubpage。配合线程缓存 PoolThreadCache 共同工作。

1. PoolArena (内存阵列)

  • 作用:Netty 内存管理的顶级结构。为了降低并发锁竞争,Netty 默认会创建多个 PoolArena(通常与 CPU 核心数 * 2 相关)。
  • 工作机制:当一个新的线程第一次申请内存时,Netty 会以轮询(Round-Robin)或最少使用的方式为其绑定一个 PoolArena。此后,该线程的内存分配都在这个 Arena 中进行。
  • 结构:包含用于不同利用率 Chunk 的链表(如 q000, q025, q050, q075, q100 等),用于动态管理 Chunk 的生命周期。

2. PoolThreadCache (线程本地缓存)

  • 作用:基于 FastThreadLocal 实现。每个线程拥有一个,用于缓存刚释放的内存块,下次同等大小的分配直接从这里无锁获取。
  • 结构:内部维护了多个 MemoryRegionCache 数组(分为 Small、Normal 不同规格)。每个 Cache 是一个无锁环形队列,存放被释放的内存指针。
  • 淘汰机制:为了防止内存泄漏,它有定期清理机制(基于分配频率阈值,如果某些缓存在一定分配周期内没被使用,会被释放回全局池)。

3. PoolChunk (内存块)

  • 作用:Netty 向操作系统(或 JVM)申请内存的基本单位。默认大小为 16MB
  • Jemalloc 4 结构(新版 Netty)
    • 一个 16MB 的 Chunk 被划分为 2048 个 Page(默认 Page 大小为 8KB)。
    • Run:若干个连续的 Page 组成一个 Run。新版放弃了二叉树,而是通过基于 SizeClasses 预先计算好的步长,利用 LongPriorityQueue 和各种位图(Bitmap)来记录哪些 Run 是空闲的。
    • 这种设计更容易实现 Page 的合并(Coalescing),极大减少了内存碎片。

4. PoolSubpage (小内存页)

  • 作用:当用户申请的内存小于 8KB(一个 Page)时,直接分配一个 Page 太浪费。Netty 会将一个 Page 划分为相等大小的微小块(如 16B, 32B, 64B...),这就是 Subpage。
  • 工作机制
    • 假如申请 32B 内存,Netty 会在 Chunk 中分配一个 8KB 的 Page,将其标记为 Subpage,并切割成 256 个 32B 的小格子。
    • 利用内部的 bitmap(用 long 数组表示)来记录这 256 个格子中哪些被占用了。
    • Arena 中维护了 PoolSubpage 的数组,按规格将这些 Subpage 串成双向链表,方便快速查找。

三、 内存规格分类 (Size Classes)

Netty 根据申请内存的大小,将其严格分类(新版取消了 Tiny,合并入 Small):

  1. Small(小内存)< 8KB。在 PoolSubpage 中分配。
  2. Normal(普通内存)8KB <= size <= 16MB。在 PoolChunk 中通过分配若干个连续的 Page (Run) 来满足。
  3. Huge(大内存)> 16MB。超出 Chunk 大小,不经过内存池,直接向 OS/JVM 申请,并在释放时直接销毁。

四、 核心工作流程

1. 分配流程 (Allocation)

假设线程 T 请求分配 N 字节的内存:

  1. 规格化:将 N 向上对齐到最近的 Size Class(例如,请求 13B 变为 16B)。
  2. 查线程缓存 (Fast Path)
    • 从当前线程的 PoolThreadCache 中查找对应规格的队列。
    • 如果有空闲内存,直接弹出返回。(全程无锁)
  3. Arena 分配 (Slow Path)
    • 如果缓存没有,则向线程绑定的 PoolArena 申请。(此处开始需要加锁同步)
    • 如果是 Small 内存:尝试从 Arena 的 PoolSubpage 链表中找有空闲格子的 Subpage 分配;若没有,则去 Chunk 中申请一个 Page 变成 Subpage 再分配。
    • 如果是 Normal 内存:在 Arena 现有的 PoolChunk 的链表(如 q050, q025)中寻找,通过位图找到一段连续的 Page (Run) 分配。
  4. 创建新 Chunk
    • 如果现有的所有 Chunk 都满了,Netty 会向系统申请一个新的 16MB Chunk,放入 Arena 中,然后再执行分配。

2. 释放流程 (Deallocation)

  1. 放入线程缓存:内存释放时,判断所属的规格,优先将其放入当前释放线程的 PoolThreadCache 队列中。
  2. 缓存满或清理:如果线程缓存已满,或者触发了整理机制,内存将被真正还给 PoolChunk
  3. 合并内存:在还给 Chunk 时,Netty 会检查相邻的 Run(Page集合)是否也是空闲的,如果是,则合并成更大的连续空间,防止产生外部碎片。
  4. Chunk 回收:如果一个 Chunk 内存完全释放(利用率为 0),且 Arena 的 Chunk 链表策略允许,该 Chunk 会被销毁,内存交还给操作系统。

五、 总结

Netty 的内存池是 Jemalloc 思想的 Java 完美复刻与深度优化。它的核心就在于:

  • 用空间换时间:通过预先分配大块内存(Chunk),避免频繁系统调用。
  • 层层隔离锁:利用 ThreadLocal 缓存拦截掉 80% 以上的分配请求,剩下的请求被分散到多个 Arena 中,将多线程竞争降到最低。
  • 精细化管理:通过 SizeClass、Run 和 Subpage 的位图操作,在内存对齐、降低碎片与高效率查找之间取得了极佳的平衡。
00:00
00:00