Netty内存相关

一.合理管理堆外内存

堆外内存利弊:
弊:堆外内存不收JVM管理,需要手动释放,稍有不慎,会引起内存泄漏

利:
1.进行网络I/O或文件读写时,堆内内存需要转换为堆外内存,如直接使用堆外内存,减少内存拷贝
2.可实现进程间,多JVM实例间数据共享

分配堆外内存的方式:
1.JAVA NIO的api:ByteBuffer buf = ByteBuffer.allocate(10*1024*1024);  //10M

通过 ByteBuffer 分配的堆外内存不需要手动回收,它可以被 JVM 自动回收。当堆内的 DirectByteBuffer 对象被 GC 回收时,Cleaner 就会用于回收对应的堆外内存

Cleaner 是虚引用 PhantomReference 的子类,PhantomReference 不能被单独使用,需要与引用队列 ReferenceQueue 联合使用。

当初始化堆外内存时,内存中的对象引用情况如下图所示,first 是 Cleaner 类中的静态变量,Cleaner 对象在初始化时会加入 Cleaner 链表中。DirectByteBuffer 对象包含堆外内存的地址、大小以及 Cleaner 对象的引用,ReferenceQueue 用于保存需要回收的 Cleaner 对象

当发生 GC 时,DirectByteBuffer 对象被回收,内存中的对象引用情况发生了如下变化:

此时 Cleaner 对象不再有任何引用关系,在下一次 GC 时,该 Cleaner 对象将被添加到 ReferenceQueue 中,并执行 clean() 方法。clean() 方法主要做两件事情:

将 Cleaner 对象从 Cleaner 链表中移除;

调用 unsafe.freeMemory 方法清理堆外内存

 2.unsafe.allocateMemory(10 * 1024 * 1024);
必须自己手动释放,否则会造成内存泄漏

二.ByteBuffer的替代品--ByteBuf

Netty 大量使用了自己实现的 ByteBuf 工具类,ByteBuf 是 Netty 的数据容器,所有网络通信中字节流的传输都是通过 ByteBuf 完成的。

NIO ByteBuffer 包含以下四个基本属性:
mark:为某个读取过的关键位置做标记,方便回退到该位置;
position:当前读取的位置;
limit:buffer 中有效的数据长度大小;
capacity:初始化时的空间容量。
mark <= position <= limit <= capacity

NIO ByteBuffer 缺陷:
1.ByteBuffer 分配的长度是固定的,无法动态扩缩容,很难控制需要分配多大的容量。如果分配太大容量,容易造成内存浪费;如果分配太小,存放太大的数据会抛出 BufferOverflowException 异常。在使用 ByteBuffer 时,为了避免容量不足问题,你必须每次在存放数据的时候对容量大小做校验,如果超出 ByteBuffer 最大容量,那么需要重新开辟一个更大容量的 ByteBuffer,将已有的数据迁移过去

2.ByteBuffer 只能通过 position 获取当前可操作的位置,因为读写共用的 position 指针,所以需要频繁调用 flip、rewind 方法切换读写状态,开发者必须很小心处理 ByteBuffer 的数据读写,稍不留意就会出错

Netty ByteBuf 相比于 ByteBuffer 优势:
1.容量可以按需动态扩展,类似于 StringBuffer;
2.读写采用了不同的指针,读写模式可以随意切换,不需要调用 flip 方法
3.通过内置的复合缓冲类型可以实现零拷贝
4.支持引用计数,支持缓存池

ByteBuf 有效地区分了可读、可写以及可扩容数据,解决了 ByteBuffer 无法扩容以及读写模式切换烦琐的缺陷

ByteBuf 是基于引用计数设计的,它实现了 ReferenceCounted 接口,ByteBuf 的生命周期是由引用计数所管理。只要引用计数大于 0,表示 ByteBuf 还在被使用;当 ByteBuf 不再被其他对象所引用时,引用计数为 0,那么代表该对象可以被释放 。创建一个 ByteBuf 对象时,它的初始引用计数为 1,当 ByteBuf 调用 release() 后,引用计数减 1,直至减为0, ByteBuf 对象才会被回收。Netty 会对分配的 ByteBuf 进行抽样分析,检测 ByteBuf 是否已经不可达且引用计数大于 0,判定内存泄漏的位置并输出到日志中。

ByteBuf 分类:
Heap/Direct、Pooled/Unpooled和Unsafe/非 Unsafe

readerIndex() & writeIndex()
markReaderIndex() & resetReaderIndex()
isReadable():如果 writerIndex 大于 readerIndex,那么 ByteBuf 是可读的,否则是不可读状态
readableBytes():ByteBuf 当前可读取的字节数,可以通过 writerIndex - readerIndex 计算得到
readByte() & writeByte(int value)----getByte(int index) & setByte(int index, int value):read/write 方法在读写时会改变readerIndex 和 writerIndex 指针,而 get/set 方法则不会改变指针位置

release() & retain():每调用一次 release() 引用计数减 1,每调用一次 retain() 引用计数加 1

当 writerIndex 等于 capacity 的时候,Buffer 置为不可写状态;
向不可写 Buffer 写入数据时,Buffer 会尝试扩容,但是扩容后 capacity 最大不能超过 maxCapacity,如果写入的数据超过 maxCapacity,程序会直接抛出异常

三.Netty的内存管理

Linux 中物理内存会被划分成若干个 4K 大小的内存页 Page,物理内存的分配和回收都是基于 Page 完成的,Page 内产生的内存碎片称为内部碎片,Page 之间产生的内存碎片称为外部碎片。

常用的内存分配器算法:动态内存分配、伙伴算法和Slab 算法

  • 动态内存分配:

DMA 是从一整块内存中按需分配,对于分配出的内存会记录元数据,同时还会使用空闲分区链维护空闲内存。可用的空闲分区,常用的有三种查找策略:
1.⾸次适应算法(first fit)
2.循环首次适应算法(next fit)
3.最佳适应算法(best fit)

  • 伙伴算法:|

    将物理内存按照 2 的次幂进行划分,内存分配时也是按照 2 的次幂大小进行按需分配,例如 4KB、 8KB、16KB 等。假设我们请求分配的内存大小为 10KB,那么会按照 16KB 分配

伙伴算法把内存划分为 11 组不同的 2 次幂大小的内存块集合,每组内存块集合都用双向链表连接。链表中每个节点的内存块大小分别为 1、2、4、8、16、32、64、128、256、512 和 1024 个连续的 Page,例如第一组链表的节点为 2^0 个连续 Page,第二组链表的节点为 2^1 个连续 Page,以此类推。

伙伴算法的具体分配过程:
首先需要找到存储 2^4 连续 Page 所对应的链表,即数组下标为 4;
查找 2^4 链表中是否有空闲的内存块,如果有则分配成功;
如果 2^4 链表不存在空闲的内存块,则继续沿数组向上查找,即定位到数组下标为 5 的链表,链表中每个节点存储 2^5 的连续 Page;
如果 2^5 链表中存在空闲的内存块,则取出该内存块并将它分割为 2 个 2^4 大小的内存块,其中一块分配给进程使用,剩余的一块链接到 2^4 链表中。

当进程使用完内存归还时,需要检查其伙伴块的内存是否释放,所谓伙伴块是不仅大小相同,而且两个块的地址是连续的,其中低地址的内存块起始地址必须为 2 的整数次幂。如果伙伴块是空闲的,那么就会将两个内存块合并成更大的块,然后重复执行上述伙伴块的检查机制。直至伙伴块是非空闲状态,那么就会将该内存块按照实际大小归还到对应的链表中。

伙伴算法有效地减少了外部碎片,但是有可能会造成非常严重的内部碎片,最严重的情况会带来 50% 的内存碎片

  • Slab 算法​​​​​​​:Linux 内核使用的就是 Slab 算法

因为伙伴算法都是以 Page 为最小管理单位,在小内存的分配场景,伙伴算法并不适用,如果每次都分配一个 Page 岂不是非常浪费内存,因此 Slab 算法应运而生了,采用了内存池的方案,解决内部碎片问题。

Slab 算法中维护着大小不同的 Slab 集合,在最顶层是 cache_chain,cache_chain 中维护着一组 kmem_cache 引用,kmem_cache 负责管理一块固定大小的对象池。通常会提前分配一块内存,然后将这块内存划分为大小相同的 slot,不会对内存块再进行合并,同时使用位图 bitmap 记录每个 slot 的使用情况。mem_cache 中包含三个 Slab 链表:完全分配使用 slab_full、部分分配使用 slab_partial和完全空闲 slabs_empty,这三个链表负责内存的分配和释放。每个链表中维护的 Slab 都是一个或多个连续 Page,每个 Slab 被分配多个对象进行存储,Slab 算法是基于对象进行内存管理的,它把相同类型的对象分为一类。当分配内存时,从 Slab 链表中划分相应的内存单元;当释放内存时,Slab 算法并不会丢弃已经分配的对象,而是将它保存在缓存中,当下次再为对象分配内存时,直接会使用最近释放的内存块

jemalloc内存分配器:jemalloc 的整体内存分配和释放流程,主要分为 Samll、Large 和 Huge 三种场景

 jemalloc 的几个核心概念,例如 arena、bin、chunk、run、region、tcache 等

内存是由一定数量的 arenas 负责管理,线程均匀分布在 arenas 当中;
每个 arena 都包含一个 bin 数组,每个 bin 管理不同档位的内存块;
每个 arena 被划分为若干个 chunks,每个 chunk 又包含若干个 runs,每个 run 由连续的 Page 组成,run 才是实际分配内存的操作对象;
每个 run 会被划分为一定数量的 regions,在小内存的分配场景,region 相当于用户内存;
每个 tcache 对应 一个 arena,tcache 中包含多种类型的 bin。

Netty划分的内存规格:

 Tiny 代表 0 ~ 512B 之间的内存块,
Samll 代表 512B ~ 8K 之间的内存块,
Normal 代表 8K ~ 16M 的内存块,
Huge 代表大于 16M 的内存块。在 Netty 中定义了一个 SizeClass 类型的枚举,用于描述内存规格类型,分别为 Tiny、Small 和 Normal

Netty 在每个区域内又定义了更细粒度的内存分配单位,分别为 Chunk、Page、Subpage

Chunk:Netty 向操作系统申请内存的单位,可以理解为 Page 的集合,每个 Chunk 默认大小为 16M
Page 是 Chunk 用于管理内存的单位,Netty 中的 Page 的大小为 8K
Subpage 负责 Page 内的内存分配,假如我们分配的内存大小远小于 Page,直接分配一个 Page 会造成严重的内存浪费,所以需要将 Page 划分为多个相同的子块进行分配,这里的子块就相当于 Subpage

Netty 内存池架构设计:

Netty 内存池抽象出一些核心组件,如 PoolArena、PoolChunk、PoolChunkList、PoolSubpage、PoolThreadCache、MemoryRegionCache

Arena:每个线程绑定一个Arena,提高访问效率

  • PoolArena 的数据结构包含两个 PoolSubpage 数组和六个 PoolChunkList(qInit: 中的 PoolChunk 即使内存被完全释放也不会被回收,避免 PoolChunk 的重复初始化工作、q000、q025、q050、q075、q100),两个 PoolSubpage 数组分别存放 Tiny 和 Small 类型的内存块,六个 PoolChunkList 分别存储不同利用率的 Chunk,构成一个双向循环链表
  • PoolChunkList 负责管理多个 PoolChunk 的生命周期,同一个 PoolChunkList 中存放内存使用率相近的 PoolChunk
  • PoolChunk
    Netty 内存的分配和回收都是基于 PoolChunk 完成的,PoolChunk 是真正存储内存数据的地方,每个 PoolChunk 的默认大小为 16M,netty 会使用伙伴算法将 PoolChunk 分配成 2048 个 Page,最终形成一颗满二叉树,二叉树中所有子节点的内存都属于其父节点管理
  • PoolSubpage
    目前大家对 PoolSubpage 应该有了一些认识,在小内存分配的场景下,即分配的内存大小小于一个 Page 8K,会使用 PoolSubpage 进行管理。PoolSubpage 是如何记录内存块的使用状态的呢?PoolSubpage 通过位图 bitmap 记录子内存是否已经被使用,bit 的取值为 0 或者 1
  • PoolThreadCache & MemoryRegionCache
    当内存释放时,与 jemalloc 一样,Netty 并没有将缓存归还给 PoolChunk,而是使用 PoolThreadCache 缓存起来,当下次有同样规格的内存分配时,直接从 PoolThreadCache 取出使用即可。PoolThreadCache 缓存 Tiny、Small、Normal 三种类型的数据。PoolThreadCache 中有一个重要的数据结构:MemoryRegionCache。MemoryRegionCache 有三个重要的属性,分别为 queue,sizeClass 和 size

    Netty 记录了 allocate() 的执行次数,默认每执行 8192 次,就会触发 PoolThreadCache 调用一次 trim() 进行内存整理,会对 PoolThreadCache 中维护的六个 MemoryRegionCache 数组分别进行整理


     

    Netty 中不同的内存规格采用的分配策略是不同的:
    分配内存大于 8K 时,PoolChunk 中采用的 Page 级别的内存分配策略。
    分配内存小于 8K 时,由 PoolSubpage 负责管理的内存分配策略。
    分配内存小于 8K 时,为了提高内存分配效率,由 PoolThreadCache 本地线程缓存提供的内存分配。​​​​​​​

分四种内存规格管理内存,分别为 Tiny、Samll、Normal、Huge,PoolChunk 负责管理 8K 以上的内存分配,PoolSubpage 用于管理 8K 以下的内存分配。当申请内存大于 16M 时,不会经过内存池,直接分配。

设计了本地线程缓存机制 PoolThreadCache,用于提升内存分配时的并发性能。用于申请 Tiny、Samll、Normal 三种类型的内存时,会优先尝试从 PoolThreadCache 中分配。

PoolChunk 使用伙伴算法管理 Page,以二叉树的数据结构实现,是整个内存池分配的核心所在。

每调用 PoolThreadCache 的 allocate() 方法到一定次数,会触发检查 PoolThreadCache 中缓存的使用频率,使用频率较低的内存块会被释放。

线程退出时,Netty 会回收该线程对应的所有内存

Netty 对象池架构设计:

对象池Recycler,其中实现了 newObject() 方法,如果对象池没有可用的对象,会调用该方法新建对象。此外需要创建 Recycler.Handle 对象与 实例对象进行绑定,这样我们就可以通过 userRecycler.get() 从对象池中获取 实例 对象,如果对象不再使用,通过调用 实例 类实现的 recycle() 方法即可完成回收对象到对象池

Recycler一共包含四个核心组件:

Stack:用于存储当前本线程回收的对象。在多线程的场景下,Netty 为了避免锁竞争问题,每个线程都会持有各自的对象池,内部通过 FastThreadLocal 来实现每个线程的私有化

static final class Stack<T> {

    final Recycler<T> parent; // 所属的 Recycler

    final WeakReference<Thread> threadRef; // 所属线程的弱引用



    final AtomicInteger availableSharedCapacity; // 异线程回收对象时,其他线程能保存的被回收对象的最大个数

    final int maxDelayedQueues; // WeakOrderQueue最大个数

    private final int maxCapacity; // 对象池的最大大小,默认最大为 4k

    private final int ratioMask; // 控制对象的回收比率,默认只回收 1/8 的对象

    private DefaultHandle<?>[] elements; // 存储缓存数据的数组

    private int size; // 缓存的 DefaultHandle 对象个数

    private int handleRecycleCount = -1; 



    // WeakOrderQueue 链表的三个重要节点

    private WeakOrderQueue cursor, prev;

    private volatile WeakOrderQueue head;



}

WeakOrderQueue:用于存储其他线程回收到当前线程所分配的对象,并且在合适的时机,Stack 会从异线程的 WeakOrderQueue 中收割对象

Link:每个 WeakOrderQueue 中都包含一个 Link 链表,回收对象都会被存在 Link 链表中的节点上,每个 Link 节点默认存储 16 个对象,当每个 Link 节点存储满了会创建新的 Link 节点放入链表尾部

DefaultHandle:DefaultHandle 实例中保存了实际回收的对象,Stack 和 WeakOrderQueue 都使用 DefaultHandle 存储回收的对象。在 Stack 中包含一个 elements 数组,该数组保存的是 DefaultHandle 实例。DefaultHandle 中每个 Link 节点所存储的 16 个对象也是使用 DefaultHandle 表示的。

  • 对象池有两个重要的组成部分:Stack 和 WeakOrderQueue。

  • 从 Recycler 获取对象时,优先从 Stack 中查找,如果 Stack 没有可用对象,会尝试从 WeakOrderQueue 迁移部分对象到 Stack 中。

  • Recycler 回收对象时,分为同线程对象回收和异线程对象回收两种情况,同线程回收直接向 Stack 中添加对象,异线程回收向 WeakOrderQueue 中的 Link 添加对象。

  • 对象回收都会控制回收速率,每 8 个对象会回收一个,其他的全部丢弃。

 Netty 零拷贝技术:

所谓零拷贝,就是在数据操作时,不需要将数据从一个内存位置拷贝到另外一个内存位置,这样可以减少一次内存拷贝的损耗,从而节省了 CPU 时钟周期和内存带宽

传统 Linux 的零拷贝:

从数据读取到发送一共经历了四次数据拷贝,具体流程如下:

 

  1. 当用户进程发起 read() 调用后,上下文从用户态切换至内核态。DMA 引擎从文件中读取数据,并存储到内核态缓冲区,这里是第一次数据拷贝

  2. 请求的数据从内核态缓冲区拷贝到用户态缓冲区,然后返回给用户进程。第二次数据拷贝的过程同时,会导致上下文从内核态再次切换到用户态。

  3. 用户进程调用 send() 方法期望将数据发送到网络中,此时会触发第三次线程切换,用户态会再次切换到内核态,请求的数据从用户态缓冲区被拷贝到 Socket 缓冲区。

     4.最终 send() 系统调用结束返回给用户进程,发生了第四次上下文切换。第四次拷贝会异步执行,从 Socket 缓冲区拷贝到协议引擎中。

Linux 中系统调用 sendfile() 可以实现将数据从一个文件描述符传输到另一个文件描述符,从而实现了零拷贝技术。在 Java 中也使用了零拷贝技术,它就是 NIO FileChannel 类中的 transferTo() 方法,transferTo() 底层就依赖了操作系统零拷贝的机制,它可以将数据从 FileChannel 直接传输到另外一个 Channel

进一步减少内存拷贝:
Socket Buffer 追加一些 Descriptor 信息来进一步减少内核数据的复制。DMA 引擎读取文件内容并拷贝到内核缓冲区,然后并没有再拷贝到 Socket 缓冲区,只是将数据的长度以及位置信息被追加到 Socket 缓冲区,然后 DMA 引擎根据这些描述信息,直接从内核缓冲区读取数据并传输到协议引擎中 。

Netty 的零拷贝技术:

  • 堆外内存,避免 JVM 堆内存到堆外内存的数据拷贝。

  • CompositeByteBuf 类,可以组合多个 Buffer 对象合并成一个逻辑上的对象,避免通过传统内存拷贝的方式将几个 Buffer 合并成一个大的 Buffer。

  • 通过 Unpooled.wrappedBuffer 可以将 byte 数组包装成 ByteBuf 对象,包装过程中不会产生内存拷贝。

  • ByteBuf.slice 操作与 Unpooled.wrappedBuffer 相反,slice 操作可以将一个 ByteBuf 对象切分成多个 ByteBuf 对象,切分过程中不会产生内存拷贝,底层共享一个 byte 数组的存储空间。

  • Netty 使用 FileRegion 实现文件传输,FileRegion 的默认实现类是 DefaultFileRegion,通过 DefaultFileRegion 将文件内容写入到 NioSocketChannel,FileRegion 底层封装了 FileChannel#transferTo() 方法,可以将文件缓冲区的数据直接传输到目标 Channel,避免内核缓冲区和用户态缓冲区之间的数据拷贝,这属于操作系统级别的零拷贝。



​​​​​​​


版权声明:本文为zhuyong7原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。