暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Netty内存分配机制和内存碎片导致的内存溢出情况分析

本文根据Netty内存分配机制的研究及具体应用过程中发现的问题分析整理而成,可为采用该技术的项目提供借鉴参考。


Netty作为Java高性能NIO通信框架,它的健壮性、可靠性、高性能和可扩展性在同类框架中都是首屈一指的,已经得到成百上千的商用项目验证。随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是个非常轻量级的工作,但是对于Socket网络通信的缓冲区情况却稍有不同,特别是对于堆外直接内存的分配和回收,是一件耗时的操作。为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区重用机制,这是Netty框架能实现高性能NIO通信的关键设计之一。



内存分配思想

Netty采用了Jemalloc的思想,这是FreeBSD实现的一种并发内存分配的算法。由于多个线程同时进行内存分配时竞争不可避免,会严重影响内存分配的效率,为了缓解高并发时的线程竞争,Jemalloc依赖多个Arena(内存分区)来分配内存,使用这种设计思想可以有效来减少并发申请内存冲突,提高内存分配效率。



内存池结构

Netty池化内存分配器由PoolThreadCache(线程内存缓存池)和多个PoolArena(内存分区)组成。PoolArena中主要包含三部分:PoolChunkList(内存块链表),TinySubpagePool(Tiny子页池)和SmallSubpagePool(Small子页池)。PoolChunk 是Netty向操作系统进行内存申请的单位,默认大小16MB。PoolArena维护了一组PoolChunkList链表,每个PoolChunkList对象管理一组特定内存使用率的PoolChunk,这组PoolChunk也以双向链表的形式保存。当一个PoolChunk由于内存分配或回收导致内存使用率发生变化并达到阈值,该PoolChunk将迁移至其他PoolChunkList,当PoolChunk使用率为0%时,该PoolChunk将会被释放。PoolChunk 内部以Page为单位分配内存,一个Page大小为8K。Page可以进一步划分为不同大小规格的Subpage,用来分配小规格内存,从而提高内存利用率。TinySubpagePool管理了32个由Subpage组成的链表,这32个链表中的Subpage分别能够分配16B至496B的内存,SmallSubpagePool管理了4个Subpage组成的链表,这4个链表中的Subpage分别能够分配512B至4096B的内存。


Netty根据申请的内存大小不同,将分配的规格分为几类:Tiny,Small,Normal 和 Huge,其中Tiny 代表了大小在0-512B的内存块,Small代表了大小在 512B-8K 的内存块,Normal 代表了大小在8K-16M的内存块,Huge代表了大于16M的内存块。多个Tiny和Small内存可以借由Subpage在同一个Page中分配;Normal内存由PoolChunk中多个连续Page组成;Huge内存是单独申请的大于16MB的大块连续内存,这类内存不在内存池中保存。


注:Netty4.1.55版本之后,删除了Tiny类型。



内存分配、回收机制

线程第一次申请分配内存时,会为其分配一个固定的PoolArena。每个线程只向自己绑定的PoolArena申请、释放内存,这样可以减少竞争并提高访问效率。申请内存时,首先尝试从PoolThreadCache中分配内存,优先使用缓存中的内存;若PoolThreadCache中并没有可用内存,则向线程关联的PoolArena申请内存。根据申请内存的大小不同,申请具体动作也存在区别。当申请分配Tiny或Small内存时,在TinySubpagePool或SmallSubpagePool中分配,若没有足够的Subpage,则向从PoolChunkList中申请一个新的Page作为Subpage进一步划分为不同大小的element再来满足分配需求。当申请分配Normal内存时,则直接向PoolChunkList申请,按照q050->q025->q000->qInit->q075顺序尝试从多个PoolChunkList申请,申请成功则返回,若如果申请不到,那么直接向操作系统申请一个新的PoolChunk,然后在该PoolChunk分配该内存,最后将PoolChunk加入到qinit中。如果申请的是Huge内存,则直接向操作系统申请分配该内存。


内存回收则是上述分配过程的逆过程,线程释放内存首先放回PoolThreadCache,如果PoolThreadCache已满,则放回PoolArena。如果释放的是Tiny和Small对象时,则放回对应大小的SubpagePools的链表中,若由于本次导致某个Subpage使用率为0且该链表中的Subpage不止一个,则释放这个Page回PoolChunkList(若此时该链表中只有一个Subpage,即使该Subpage使用率为0,也不会释放)。如果释放的是Normal对象,直接放回PoolChunkList,若由于本次放回导致PoolArena某个PoolChunk利用率为0%,则将该PoolChunk释放回操作系统。如果释放的是Huge对象,则直接释放回操作系统。



PoolChunk内存管理和碎片导致的内存溢出分析

PoolChunk是Java的一个对象,它引用了操作系统分配的一段连续内存(ByteBuffer或byte数组),Page是将这段内存划分成了多个小段,每段8K。PoolChunk分配一个Page,本质上是Netty内存分配器将这个Page地址区间包装成对象后返回,并将该区间标识为已占用。PoolChunk回收一个Page,则实际上是将该Page地址区间标识为空闲,并回收包装Page地址的对象。Netty使用线段树来标识PoolChunk中某个区间的内存是否为已占用,如下图所示,一个PoolChunk先申请分配2M,再申请4M,那么则会标记0-2M、4-8M节点占用100%,0-8M节点占用75%,0-16M节点占用37.5%。

在这种管理方式下,该内存块还有10M内存可用,但是不存在6-16M这个节点,不能将6-16M这个区间分配出去,需要申请新的块才能满足分配需求,此时如果申请的内存已经到达限制,就会发生内存溢出。另外Netty的TinySubpagePool和SmallSubpagePool中管理的Subpage回收时,至少会保留一个Subpage,可能出现内存碎片导致的无法分配空闲内存的情况。如下图所示,假设保留了2个Subpage,这两个Subpage分别在127-128K和8192-8200K,此时即使该内存块内存利用率只有2/2048 = 0.097%,还有15.98M空闲,但在该块中已不能满足8M的内存分配需求。

Netty框架每个PoolArena的SmallSubpagePool包含4个链表,TinySubpagePools包含32个链表,那么极端情况下,这36个链表中都保留一个Subpage,而且这个Subpage来自不同的Poolchunk,这样一个PoolArena就会占用36 * 16 = 576M。若部署在一个16C的机器上,默认会申请32个PoolArena,则极端情况下会占用576 * 32 = 18432 M(18G)内存,同时由于所有PoolArena中都有一个Page被使用,所以即使18G的内存中,也不存在一个连续的16M内存,若内存上限小于等于18GB一旦申请大于16M的连续内存必然内存溢出。除此之外,Netty的线程本地内存缓存机制PoolThreadCache可能导致更多的小内存不被释放,会产生更多碎片,出现这种情况时申请6-8M连续内存都可能出现内存溢出(此时空余空间可能几个G,但全被碎片占用,不存在足够大的连续内存,所以还是溢出)。



PoolChunk内存管理和碎片导致的内存溢出解决方案

避免上述内存溢出情况可以从两方面着手:

(1)通过JVM启动参数设置Netty的内部参数

设置Arena个数为固定值,避免Netty按照默认CPU核数*2生成过多PoolArena。同时设置Tiny和Small类型不在PoolThreadCache中缓存,释放时直接放回PoolArena,减少小内存由于缓存占用大块内存的情况。


(2)升级Netty版本,1.55版本之后删除了Tiny类型,由于SubpagePool链表个数减少,保留的Subpage个数减少,能够减少内存溢出概率。


作者 | 郭一鸣

视觉 | 王朋玉

统筹 | 郑    洁

文章转载自中国光大银行科技创新实验室,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论