一.合理管理堆外内存
堆外内存利弊:
弊:堆外内存不收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 的零拷贝:
从数据读取到发送一共经历了四次数据拷贝,具体流程如下:

当用户进程发起 read() 调用后,上下文从用户态切换至内核态。DMA 引擎从文件中读取数据,并存储到内核态缓冲区,这里是第一次数据拷贝。
请求的数据从内核态缓冲区拷贝到用户态缓冲区,然后返回给用户进程。第二次数据拷贝的过程同时,会导致上下文从内核态再次切换到用户态。
用户进程调用 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,避免内核缓冲区和用户态缓冲区之间的数据拷贝,这属于操作系统级别的零拷贝。