Netty源码剖析之内存池和对象池设计流程

1、为什么需要池化内存

Netty 作为底层网络框架,为了更高效的网络传输性能,堆外内存(Direct ByteBuffer)的使用是非常高频的。

堆外内存在 JVM 之外,在有效降低 JVM GC 压力的同时,还能提高传输性能

但它也是一把双刃剑,堆外内存是非常宝贵的资源,申请和释放都是高成本的操作,使用不当还可能造成严重的内存泄露等问题

堆外内存性能问题创建堆外内存的速度比堆内存慢了10到20倍

那么进行池化管理,多次重用是比较有效的方式。

为了解决这个问题Netty就做了内存池,Netty的内存池是不依赖于JVM本身的GC的。

申请内存大小的角度讲,申请多大的 Direct ByteBuffer 进行池化又会是一大问题,太大会浪费内存,太小又会出现频繁的扩容和内存复制!!!

所以呢,就需要有一个合适的内存管理算法解决高效分配内存的同时又解决内存碎片化的问题。

所以一个优秀的内存管理算法必不可少。

一个内存分配器至少需要看关注两个核心目标:

  • 高效的内存分配和回收,提升单线程或者多线程场景下的性能

  • 提高内存的有效利用率,减少内存碎片,包括内部碎片和外部碎片

可以带着以下问题去看Netty内存池源码:

  • 内存池管理算法是怎么做到申请效率,怎么减少内存碎片

  • 高负载下内存池不断扩展,如何做到内存回收

  • 对象池是如何实现的,这个不是关键路径,可以当成黑盒处理

  • 内存池跟对象池作为全局数据,在多线程环境下如何减少锁竞争

  • 池化后内存的申请跟释放必然是成对出现的,那么如何做内存泄漏检测,特别是跨线程之间的申请跟释放是如何处理的。

2、jemalloc内存管理算法

jemalloc参考文章

jemalloc是一种优秀的内存管理算法,本文基于netty管理pooled direct memory实现进行讲解,netty对于java heap buffer的管理和对direct memory的管理在实现上基本相同

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

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

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

3、内存碎片

Linux 世界,物理内存会被划分成若干个 4KB 大小的内存页(page),这是分配内存大小的最小粒度。

分配和回收都是基于 page 完成的。

page 内产生的碎片称为 内存碎片page 外产生的碎片称为 外部碎片

内存碎片产生的原因:

1、内存被分割成很小的块,虽然这些块是空闲且地址连续的,但却小到无法使用。

2、随着内存的分配和释放次数的增加,内存将变得越来越不连续。

3、最后,整个内存将只剩下碎片,即便有足够的空闲页框可以满足请求,但要分配一个大块的连续页框就无法满足,

外部碎片产生的原因:

1、外部碎片指的是还没有被分配出去(不属于任何进程),但由于太小了无法分配给申请内存空间的新进程的内存空闲区域。

2、外部碎片是出于任何已分配区域或页面外部的空闲存储块。

3、这些存储块的总和可以满足当前申请的长度要求,但是由于它们的地址不连续或其他原因,使得系统无法满足当前申请。

因此减少内存浪费的核心就是尽量避免产生内存碎片。

4、内存分配器算法

动态内存分配算法,伙伴算法,Slab算法

4.1、动态内存分配算法

全称 Dynamic memory allocation,又称为 堆内存分配,简单 DMA。

简单地说就是想要多少内存空间,操作系统就给你多少。在大部分场景下,只有在程序运行时才知道所需内存空间大小,

提前分配的内存大小空间不好把控,分配太多造成空间浪费,分配太少造成程序崩溃。

DMA 就是从一整块内存中 按需分配,对于已分配的内存会记录元数据,同时还会使用空闲分区维护空闲内存,便于在下次分配时快速查找可用的空闲分区。

常见的有以下三种查找策略:

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

Netty源码剖析之内存池和对象池设计流程_第1张图片

Netty源码剖析之内存池和对象池设计流程_第2张图片

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

  • 最佳适应算法(best fit)
    空间分区链始终保持从小到大的递增顺序。当内存分配时,从开头开始查找适合的空间内存并分配,当完成分配请求后,空闲分区链重新按分区大小排序。
    此算法的空间利用率更高,但同样会有难以利用的小空间分区,究其原因是空闲内存块大小不变,并没有针对内存大小做优化分类,除非内存内存大小刚好等于空闲内存块的大小,空间利用率 100%。
    每次分配完后需要重新排序,因此存在 CPU 消耗。
    Netty源码剖析之内存池和对象池设计流程_第3张图片
    Netty源码剖析之内存池和对象池设计流程_第4张图片

动态内存分配的问题:会导致严重的内存碎片

内存碎片就是内存被分割成很小很小的一些块,这些块虽然是空闲的,可是却小到没法使用。

随着申请和释放次数的增长,内存将变得愈来愈不连续。

最后,整个内存将只剩下碎片,即便有足够的空闲页框能够知足请求,但要分配一个大块的连续页框就可能没法满足,因此减小内存浪费的核心就是尽可能避免产生内存碎片。

针对这样的问题,有不少行之有效的解决方法,其中伙伴算法被证实是很是行之有效的一套内存管理方法,所以也被至关多的操做系统所采用。

4.2、伙伴算法

如何避免外部碎片的方法有两种:

(1)是利用分页单元把一组非连续的空闲页框映射到连续的线性地址区间;

(2)伙伴系统:是记录现存空闲连续页框块情况,以尽量避免为了满足对小块的请求而分割大的连续空闲块。

伙伴内存分配技术是一种内存分配算法,它将内存划分为分区,以最合适的大小满足内存请求。

Buddy memory allocation 于 1963 年 Harry Markowitz 发明。

伙伴算法的原理

伙伴(buddy)算法中,它不像DMA那样,根据需要从被管理内存的空闲分区以随意大小方式进行分配。

而是按照不同的规格,以块为单位进行分配。各个内存块可分可合,但不是任意的分与合。

每一个块都有个朋友,或叫“伙伴”,既可与之分开,又可与之结合。所以,只有伙伴关系的内存块,才能分开和合并。

系统中的空闲内存总是按照相邻关系,两两分组,每组中的两个内存块称作伙伴。

伙伴的分配可以是彼此独立的。

但如果两个小伙伴都是空闲的,内核将其合并为一个更大的内存块,作为下一层次上某个内存块的伙伴。

具体先看下一个例子:

Netty源码剖析之内存池和对象池设计流程_第5张图片

首先,伙伴算法把所有的空闲页面分为10个块组,

每组中块的大小是2的幂次方个页面,例如

  • 第0组中块的大小都为1个页面,
  • 第1组中块的大小为都为2个页面,
  • 第9组中块的大小都为512个页面。

也就是说,每一组中块的大小是相同的,且这同样大小的块形成一个链表(可以解释为hashmap中hash值相同的key的桶 bucket)。

剩下的未分配的内存,我们将其添加到第10个块组中。

并且,除了第0个块组有2个块以外,其余都只有一个块,但是第10个块组可以有多个块。

什么的伙伴块?如何获取?

伙伴块指的是连续的两个块,这两个块大小相等,并且两个块合并后的块可以一直迭代合并为1024页的大块。

这一点可能不太好理解,画了个图:

Netty源码剖析之内存池和对象池设计流程_第6张图片

假设我们寻找1号块的伙伴块,如果是2号块的话,当1,2号块合并后,是无法继续与0号块 合并的,此时0号块就变成了不可合并状态,所以1号块的伙伴块应该是0号而不是2号。

寻找到伙伴块的方法是这样的:

当块大小为n时,寻找到的伙伴块必须满足,合并后的大块的左边(低内存)区域的大小应该是合并大块的k倍,即2nk的大小(k为非负整数)。

怎么进行快分配?

当需要分配一个内存大小为n时,需要分配一个内存块,块的大小为m,且m满足: m/2 < n and m>=n.

通过这个限制条件,我们可以获得要分配的块的大小,并且到对应的块组寻找有没有空闲块,如果有的话就把这个块分配出来,如果没有的话就把继续寻找到上一级块组,如果上一级有的话,就将这个空闲块拆分为两个并且分配一个块出来,另一个块归入下一级块组中。

如果上一级也没有空闲块的话,就继续向上一级寻找,递归寻找到合适的块。

怎么进行块释放?

类似于块分配的逆向操作,回收一个块时会首先检测其伙伴块是否空闲,如果空闲的话,回收块会与伙伴块合并为更大的块,并且在上一级块组中寻找伙伴块合并,递归进行此操作,直到无法再次合并为止。

伙伴算法的一个简单例子

假设,一个最初由256KB的物理内存。假设申请21KB的内存,内核需分片过程如下:

内核将256KB的内存进行分割,变成两个128KB的内存块,AL和AR,这两个内存块称为伙伴。

随后他发现128KB也远大于21KB,于是他继续分割为两个64KB的内存块,发现64KB 也不是满足需求的最小的内存块,于是他继续分割为两个32KB的。

32KB再往下就是16KB,就不满足需求了,所以32KB是它满足需求的最下的内存块了,所以他就分割出来的CL 或者CR 分配给需求方。

当需求方用完了,需要进行归还:

然后他把32KB的内存还回来,它的另一个伙伴如果没被占用,那么他们地址连续,就合并成一个64KB的内存块,以此类推,进行合并。

注意:

这里的所有的分割都是进行二分来分割,所有内存块的大小都是2的幂次方。

伙伴算法的秘诀:

把内存块存放在比链接表更先进的数据结构中。这些结构常常是桶型、树型和堆型的组合或变种。一般来说,由于所选数据结构的不同,而各伙伴分配程序的工作方式是相差很大。

由于有各种各样的具有已知特性的数据结构可供使用,所以伙伴分配程序得到广泛应用。

伙伴分配程序编写起来常常很复杂,其性能可能各不相同。

linux内核中伙伴算法

Linux内核内存管理的一项重要工作就是如何在频繁申请释放内存的情况下,避免碎片的产生。

Linux采用伙伴系统解决外部碎片的问题,采用slab解决内部碎片的问题。

Linux2.6为每一个管理区使用不一样的伙伴系统,内核空间分为三种区,DMA,NORMAL,HIGHMEM,对于每一种区,都有对应的伙伴算法。

linux内核中,伙伴算法把所有的空闲页框分组成 11 个块链表,每一个块链表分别包含大小为1、2、4、8、16、32、64、128、256、512 和 1024 个连续的页框。

最大内存请求大小为 4MB,该内存是连续的。伙伴算法即大小相同、地址连续。

11个块链表中:

  • 第0个块链表包含大小为2^0个连续的页框,

  • 第1个块链表中,每一个链表元素包含2个页框大小的连续地址空间

  • ….

  • 第10个块链表中,每一个链表元素表明4M的连续地址空间。

每一个链表中元素的个数在系统初始化时决定,在执行过程当中,动态变化。

#ifndef CONFIG_FORCE_MAX_ZONEORDER
#define MAX_ORDER 11
#else
#define MAX_ORDER CONFIG_FORCE_MAX_ZONEORDER
#endif
#define MAX_ORDER_NR_PAGES (1 << (MAX_ORDER - 1))

  struct free_area {
    struct list_head    free_list[MIGRATE_TYPES];//空闲块双向链表
    unsigned long       nr_free;//空闲块的数目
  };
  
  struct zone{
       ....
       struct free_area    free_area[MAX_ORDER];  
       ....
  };

zone从上到下的每一个元素的类型为free_area,free_area内部都是都保存一个free_list链表,

Netty源码剖析之内存池和对象池设计流程_第7张图片

struct free_area free_area[MAX_ORDER] #MAX_ORDER 默认值为11,分别存放着11个组

free_area数组中第K个free_area元素,它标识所有大小为2^k的空闲块,所有空闲快由free_list指向的双向循环链表组织起来。

Netty源码剖析之内存池和对象池设计流程_第8张图片

Netty源码剖析之内存池和对象池设计流程_第9张图片

伙伴算法每次只能分配2的幂次个页框的空间,每页大小通常为4K

例如:一次分配1页,2页,4页,8页,…,1024页(2^10)等等,所以,伙伴算法最多一次可以分配4M(1024*4K)的内存空间

MAX_ORDER默认值为11,分别存放着11个组,free_area结构体里面又标注了该组别空闲内存块的情况

zone的成员:nr_free和zone_mem_map数组

Netty源码剖析之内存池和对象池设计流程_第10张图片
伙伴位图mem_map

伙伴关系: 两个内存块,大小相同,地址连续,同属于一个大块区域。(第0块和第1块是伙伴,第2块和第3块是伙伴,但第1块和第2块不是伙伴)

伙伴位图:用一位描述伙伴块的状态位码,称之为伙伴位码。

伙伴位码:用一位描述伙伴块的状态位码,称之为伙伴位码。
比如,bit0为第0块和第1块的伙伴位码,若是bit0为1,表示这两块至少有一块已经分配出去,若是bit0为0,说明两块都空闲或者两块都被使用。如果bit0为1,表示这两块至少有一块已经分配出去,如果bit0为0,说明两块都空闲,还没分配。

整个过程当中,位图扮演了重要的角色

Linux内核伙伴算法中每一个order 的位图都表示全部的空闲块,位图的某位对应于两个伙伴块,

为1就表示其中一块忙,为0表示两块都闲或都在使用。

系统每次分配和回收伙伴块时都要对它们的伙伴位跟1进行异或运算。

所谓异或是指刚开始时,两个伙伴块都空闲,它们的伙伴位为0:

  • 若是其中一块被使用,异或后得1;

  • 若是另外一块也被使用,异或后得0;

  • 若是前面一块回收了异或后得1;

  • 若是另外一块也回收了异或后得0。

Netty源码剖析之内存池和对象池设计流程_第11张图片

如图所示,位图的某一位对应两个互为伙伴的块,为1表示其中一块已经分配出去了,为0表示两块都空闲或都已被使用。

伙伴中不管是分配/还是释放,都只是相对的位图进行异或操做。

分配内存时对位图的是为释放过程服务,释放过程根据位图判断伙伴是否存在:

  • 若是对相应位的异或操做,得1,说明之前为0,两块都是busy,没有伙伴能够合并,

  • 若是异或操做得0,说明之前是1,说明只有一块是busy,一块是 idle(/free),busy的是自己,就进行合并。并且,继续按这种方式合并伙伴,直到不能合并为止。

位图的主要用途是在回收算法中指示是否能够和伙伴块合并,分配时只要搜索空闲链表就足够了。但是,分配的同时还要对相应位异或一下,这是为回收算法服务。

伙伴算法问题:

伙伴算法管理的是原始内存,比如最原始的物理内存(Java中的堆外),或者说一大块连续的 堆内存。

申请时,伙伴算法会给程序分配一个较大的内存空间,即保证所有大块内存都能得到满足。

很明显分配比需求还大的内存空间,会产生内部碎片。

所以伙伴算法虽然能够完全避免外部碎片的产生,但这恰恰是以产生内部碎片为代价的。

缺点:

虽然伙伴算法有效减少了外部碎片,但最小粒度还是 page(4K),因此有可能造成非常严重的内部碎片,最严重带来 50% 的内存碎片。

4.3、slab算法

伙伴 算法 在小内存场景下并不适用,因为每次都会分配一个 page,导致非常严重的内部碎片。

Slab 算法 则是在 伙伴算法 的基础上对小内存分配场景做了专门的优化:

提供调整缓存机制 存储内核对象,当内核需要再次分配内存时,基本上可以通过缓存中获取。

Linux 底层采用 Slab 算法 进行小内存分配。

Linux采用伙伴系统解决外部碎片的问题,采用slab解决内部碎片的问题。

slab 分配器的基本原理:
按照预定固定的大小,将分配的内存分割成特定长度的块,以完全解决内存碎片问题。

具体来说:

slab 分配器将分配的内存分割成各种尺寸的块,并把相同尺寸的块分成组。

另外分配到的内存用完之后,不会释放,而是返回到对应的组,重复利用。

Netty源码剖析之内存池和对象池设计流程_第12张图片

5、jemalloc算法实现

jemalloc 是基于 buddy+Slab 而来,比buddy+ Slab 更加复杂。

Slab 提升小内存分配场景下的速度和效率,jemalloc 通过 Arena 和 Thread Cache 在多线程场景下也有出色的内存分配效率。

Arena 是分而治之思想的体现,与其让一个人管理全部内存,到不如将任务派发给多个人,每个人独立管理,互不干涉(线程竞争)。

Thread Cache 是 tcmalloc 的核心思想,jemalloc 也把它借鉴过来。

通过Thread Cache机制, 每个线程有自己的内存管理器,分配在这个线程内完成,就不需要和其他线程竞争。

参考文章 https://www.cnblogs.com/xiaojiesir/p/15450732.html

Netty源码剖析之内存池和对象池设计流程_第13张图片

jemalloc 有以下几个核心概念:

  • arena 是 jemalloc 最重要的部分,内存由一定数量的 arenas 负责管理。每个用户线程都会被绑定到一个 arena 上,线程采用 round-robin 轮询的方式选择可用的 arena 进行内存分配,为了减少线程之间的锁竞争,默认每个 CPU 会分配 4 个 arena。

  • bin 用于管理不同档位的内存单元,每个 bin 管理的内存大小是按分类依次递增。因为 jemalloc 中小内存的分配是基于 Slab 算法完成的,所以会产生不同类别的内存块。

  • chunk 是负责管理用户内存块的数据结构,chunk 以 Page 为单位管理内存,默认大小是 4M,即 1024 个连续 Page。每个 chunk 可被用于多次小内存的申请,但是在大内存分配的场景下只能分配一次。

  • run 实际上是 chunk 中的一块内存区域,每个 bin 管理相同类型的 run,最终通过操作 run 完成内存分配。run 结构具体的大小由不同的 bin 决定,例如 8 字节的 bin 对应的 run 只有一个 Page,可以从中选取 8 字节的块进行分配。

  • region 是每个 run 中的对应的若干个小内存块,每个 run 会将划分为若干个等长的 region,每次内存分配也是按照 region 进行分发。
    tcache 是每个线程私有的缓存,用于 small 和 large 场景下的内存分配,每个 tcahe 会对应一个 arena,tcache 本身也会有一个 bin 数组,称为tbin。与 arena 中 bin 不同的是,它不会有 run 的概念。tcache 每次从 arena 申请一批内存,在分配内存时首先在 tcache 查找,从而避免锁竞争,如果分配失败才会通过 run 执行内存分配。

以上核心概念的关系如下:

  • 内存是由一定数量的 arenas 负责管理,线程均匀分布在 arenas 当中;

  • 每个 arena 都包含一个 bin 数组,每个 bin 管理不同档位的内存块;

  • 每个 arena 被划分为若干个 chunks,每个 chunk 又包含若干个 runs,每个 run 由连续的 Page 组成,run 才是实际分配内存的操作对象;

  • 每个 run 会被划分为一定数量的 regions,在小内存的分配场景,region 相当于用户内存;

  • 每个 tcache 对应 一个 arena,tcache 中包含多种类型的 bin。

以 Samll、Large 和 Huge 三种场景分析jemalloc的整体内存分配和释放流程

  • Small:如果请求分配内存的大小小于 arena 中的最小的 bin,那么优先从线程中对应的 tcache 中进行分配。首先确定查找对应的 tbin 中是否存在缓存的内存块,如果存在则分配成功,否则找到 tbin 对应的 arena,从 arena 中对应的 bin 中分配 region 保存在 tbin 的 avail 数组中,最终从 availl 数组中选取一个地址进行内存分配,当内存释放时也会将被回收的内存块进行缓存。

  • Large:如果请求分配内存的大小大于 arena 中的最小的 bin,但是不大于 tcache 中能够缓存的最大块,依然会通过 tcache 进行分配,但是不同的是此时会分配 chunk 以及所对应的 run,从 chunk 中找到相应的内存空间进行分配。内存释放时也跟 samll 场景类似,会把释放的内存块缓存在 tacache 的 tbin 中。此外还有一种情况,当请求分配内存的大小大于tcache 中能够缓存的最大块,但是不大于 chunk 的大小,那么将不会采用 tcache 机制,直接在 chunk 中进行内存分配。

  • Huge:如果请求分配内存的大小大于 chunk 的大小,那么直接通过 mmap 进行分配,调用 munmap 进行回收。

6、Netty内存规格

Netty源码剖析之内存池和对象池设计流程_第14张图片

Huge类型

Netty 默认向操作系统申请的内存大小为 16MB,对于大于 16MB 的内存定义为 Huge 类型,

Netty 对 Huge 类型的处理方式为:

大型内存不做缓存、不做池化,直接以 Unpool 的形式分配内存,用完后回收。

Netty 定义了一套更细粒度的内存分配单位:Chunk、Page、Subpage,方便内存的管理。

Netty源码剖析之内存池和对象池设计流程_第15张图片

6.1、Chunk

**Chunk 即上述提及的 Netty 向操作系统申请内存的单位,默认是 16MB。**后续所有的内存分配也都是基于 Chunk 完成。

Chunk 是 Page 的集合。

一个 Chunk(16MB),由 2048 个 Page (8KB)组成。

一个chunk 的大小是16MB, 实际上每个chunk, 都以双向链表的形式保存在一个chunkList 中,

Netty源码剖析之内存池和对象池设计流程_第16张图片

6.2、Page

Page 是 Chunk 用于管理内存的基本单位。

Page 的默认大小为 8KB,若欲申请 16KB,则需申请连续的两块空闲 Page。

Netty源码剖析之内存池和对象池设计流程_第17张图片
Netty源码剖析之内存池和对象池设计流程_第18张图片

6.3、SubPage

很多场景下, 为缓冲区分配8KB 的内存也是一种浪费, 比如只需要分配2KB 的缓冲区, 如果使用8KB 会造成6KB 的浪费,

这种情况, netty 又会将page 切分成多个subpage,

SubPage 是 Page 下的管理单位。

Netty源码剖析之内存池和对象池设计流程_第19张图片

SubPage 内存划分,分为tiny,small

Netty源码剖析之内存池和对象池设计流程_第20张图片

切割划分的算法原则是:

如首次申请 512 B 的内存,则先申请一块 Page 内存,然后将 8 KB 的 Page 按照 512B 均分为 16 块,每一块可以认为是一个 SubPage,然后将第一块 SubPage 内存地址返回给申请方。

同时下一次申请 512B 内存,则在 16 块中分配第二块。

其他非 512B 的内存申请,则另外申请一个 Page 进行均等切分和分配。

所以,对于 SubPage 没有固定的大小,和 Tiny、Small 中某个具体大小的内存申请有关。

6.4、ChunkList

Netty源码剖析之内存池和对象池设计流程_第21张图片

6.5、PoolArena

与jemalloc类似,Netty采用固定数量的多个 Arena 进行内存分配,Arena 的默认数量与 CPU 核数有关,通过创建多个 Arena 来缓解资源竞争问题,从而提高内存分配效率。线程在首次申请分配内存时,会通过 round-robin 的方式轮询 Arena 数组,选择一个固定的 Arena,在线程的生命周期内只与该 Arena 打交道,所以每个线程都保存了 Arena 信息,从而提高访问效率。

Netty源码剖析之内存池和对象池设计流程_第22张图片

  • 包含两个 PoolSubpage 数组和六个 PoolChunkList,两个 PoolSubpage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向循环链表。

  • PoolArena 对应实现了 Subpage 和 Chunk 中的内存分配,其 中 PoolSubpage 用于分配小于 8K 的内存,PoolChunkList 用于分配大于 8K 的内存

注意:每个netty线程 一个 PoolArena

7、Netty内存架构图

Netty 中的内存池可以看作一个 Java 版本的 jemalloc 实现,并结合 JVM 的诸多特性做了部分优化

Netty源码剖析之内存池和对象池设计流程_第23张图片

8、Netty内存池设计思路

Netty采用了jemalloc的思想,这是FreeBSD实现的一种并发malloc的算法。

jemalloc依赖多个Arena来分配内存,运行中的应用都有固定数量的多个Arena,默认的数量与处理器的个数有关。

系统中有多个Arena的原因是由于各个线程进行内存分配时竞争不可避免,这可能会极大的影响内存分配的效率,为了缓解高并发时的线程竞争,Netty允许使用者创建多个分配器(Arena)来分离锁,提高内存分配效率。

线程首次分配/回收内存时,首先会为其分配一个固定的Arena。

线程选择Arena时使用round-robin的方式,也就是顺序轮流选取。

每个线程各种保存Arena和缓存池信息,这样可以减少竞争并提高访问效率。

Arena将内存分为很多Chunk进行管理,Chunk内部保存Page,以页为单位申请。

下图展示了netty基于jemalloc实现的内存划分逻辑

8.1、netty内存池结构

  • PoolArena

  • PoolChunkList(6个Chunk)

  • PoolChunk(16M,2048个page)

  • PoolPage(8K)

  • PoolSubPage (tiny,small)

8.2、设计思路

内存块设计

Netty 将Arena作为内存管理入口,内置ChunkList,SubPage来存放不同大小的空间,Chunk(16M)进一步拆分为多个page,每个 page 默认大小为 8KB,

因此每个 chunk 包含 2048 个 page。为了对小内存进行精细化管理,减少内存碎片,提高内存使用率,

Netty 对 page 进一步拆分若干 subpage,subpage 的大小是动态变化的,最小为 16Byte。

请求内存分配过程如下

1、计算: 当请求内存分配时,将所需要内存大小进行内存规格化,获得规格化的内存请求值。根据值确认准确的树的高度。

2、搜索: 在内存映射数据中,进行空闲内存序列的搜索。

3、标记: 分组被标记为全部已使用,且通过循环更新其父节点标记信息。父节点的标记值取两个子节点标记值的最小的一个。

我的理解:计算请求数据大小20B,根据大小创建对应的Chunk,每个Chunk(默认16M),共2048个Page(默认8k),选取一个Page按大小20b划分空间(16b是规定的最小),即一个Page,被32b划划分(16B<20B<32B),标记该空间32B已使用,通过位图 bitmap 记录子内存是否已经被使用,bit 的取值为 0 或者 1。

如图:
Netty源码剖析之内存池和对象池设计流程_第24张图片

8.3、内存弹性伸缩

单个chunk比较容量有限,如何管理多个chunk,构建成能够弹性伸缩内存池?

为了解决单个PoolChunk容量有限的问题,Netty将多个PoolChunk组成链表一起管理,

然后用PoolChunkList对象持有链表的head, 将所有PoolChunk组成一个链表的话,进行遍历查找管理效率较低,

因此Netty设计了PoolArena对象(arena中文是舞台、场所),实现对多个PoolChunkList、PoolSubpage的分组管理调度,线程安全控制、提供内存分配、释放的服务

8.4、线程竞争

虽然提供了多个PoolArena减少线程间的竞争,但是难免还是会存在锁竞争,所以需要利用ThreaLocal进一步优化,把已申请的内存放入到ThreaLocal自然就没有竞争了。

大体思路是在ThreadLocal里面放一个PoolThreadCache对象,然后释放的内存都放入到PoolThreadCache里面,下次申请先从PoolThreadCache获取。但是,如果thread1申请了一块内存,然后传到thread2在线程释放,这个Netty在内存holder对象里面会引用PoolThreadCache,所以还是会释放到thread1里

9、内存释放

Netty中使用引用计数机制来管理资源,

ByteBuf实现了ReferenceCounted接口,当实例化ByteBuf对象时,引用计数加1。

当应用代码保持一个对象引用时,会调用retain方法将计数增加1,对象使用完毕进行释放,调用release将计数器减1.

当引用计数变为0时,对象将释放所有的资源,返回内存池。

10、内存泄漏监测

内存泄露检测的原理是利用虚引用,当一个对象只被虚引用指向时,

在GC的时候会被自动放到了一个ReferenceQueue里面,每次去申请内存的时候最后都会根据检测策略去ReferenceQueue里面判断释放有泄露的对象。

netty支持下面四种级别,使用-Dio.netty.leakDetectionLevel=advanced可以调节等级。

  • 禁用(DISABLED) - 完全禁止泄露检测,省点消耗。
  • 简单(SIMPLE) - 默认等级,告诉我们取样的1%的ByteBuf是否发生了泄露,但总共一次只打印一次,看不到就没有了。
  • 高级(ADVANCED) - 告诉我们取样的1%的ByteBuf发生泄露的地方。每种类型的泄漏(创建的地方与访问路径一致)只打印一次。对性能有影响。
  • 偏执(PARANOID) - 跟高级选项类似,但此选项检测所有ByteBuf,而不仅仅是取样的那1%。对性能有绝大的影响。

11、Netty中Handler内存处理机制

Netty中有handler链,消息是由本Handler传到下一个Handler。

所以Netty引入了一个规则,谁是最后使用者,谁负责释放。

根据谁最后使用谁负责释放的原则,每个Handler对消息可能有三种处理方式

  • 对原消息不做处理,调用ctx.fireChannelRead(msg)把原消息往下传,那不用做什么释放。

  • 将原消息转化为新的消息并调用 ctx.fireChannelRead(newMsg)往下传,那必须把原消息release掉

  • 如果已经不再调用ctx.fireChannelRead(msg)传递任何消息,那更要把原消息release掉。

假设每一个Handler都把消息往下传,Handler并也不知道谁是启动Netty时所设定的Handler链的最后一员,

所以Netty在Handler链的最末补了一个TailHandler,如果此时消息仍然是ReferenceCounted类型就会被release掉。

???????????????????有待测试

https://www.cnblogs.com/crazymakercircle/p/16181994.html

https://blog.csdn.net/wangwei19871103/article/details/104341612

https://people.freebsd.org/~jasone/jemalloc/bsdcan2006/jemalloc.pdf

https://juejin.cn/post/6922783552580878349

https://blog.csdn.net/wangwei19871103/article/details/104341612

https://blog.csdn.net/wangwei19871103/article/details/104341612

http://blog.ouyangsihai.cn/zhi-cheng-bai-wan-ji-bing-fa-netty-ru-he-shi-xian-gao-xing-neng-nei-cun-guan-li.html

https://www.cnblogs.com/wuzhenzhao/p/11290533.html

http://www.javashuo.com/article/p-vtghkove-ez.html

https://blog.csdn.net/gaoliang1719/article/details/115649976

https://www.jianshu.com/p/a97724153d88

你可能感兴趣的:(Netty源码,Java源码,jvm,java,算法)