netty4的内存池化技术

叁叁肆2018-10-25 16:44

此文已由作者徐赟授权网易云社区发布。

欢迎访问网易云社区,了解更多网易技术产品运营经验。



一、 前言

java语言在运行于JVM的前提下,内存分配和回收通常无需用户干预。用户创建对象时,系统会在堆中分配空间存放对象,如果堆空间不足就触发JVM的gc做内存回收,在删除无效对象的同时对堆空间做整理,清除内存碎片使空闲内存连续,用于支持大对象的分配。脏活累活都让JVM干了,用户只要专注自己的逻辑,java应用就能长时间的以最优状态持续运行。

这是java语言和JVM的核心优势,但在一些场景下,完全剥离用户对内存和对象生命周期的管理也会造成一些困扰:
1. 对象生命周期的开始由用户控制,但用户却完全不知道生命周期何时结束。类似于C++中的析构函数,java中提供finalize方法,但该方法并不会在对象失效后而是在gc触发时被调用。gc的触发时间对用户来说是玄之又玄的东西,即使显式调用System.gc()方法也不一定会触发gc,因为该方法只是建议JVM做gc,至于是否真的要做,还要依赖JVM自身的判断。因此想要在finalize方法里释放资源的同学要失望了,因为资源可能会在任意一个用户无法控制的时间被释放(ps. 使用reference能够探测对象回收的时机,但会对回收造成压力,极端情况下会导致内存溢出)。且不说finalize方法执行机制存在安全隐患,这又是另一个问题,总之不建议依赖finalize方法实现对象回收行为。目前对象生命周期管理的一种思路,是将对象交于容器管理,通过容器控制对象生命周期。

2. java中没有指针,也不方便用指针,这表示用户对内存的控制能力非常弱,换来的是代码的整洁和健壮。在绝大多数java面临的业务场景下,用户无需关心内存的具体使用方式。但对于有海量数据流转的java后台应用,锯齿状的内存使用曲线和周期性的大量gc,频繁的对象创建和回收,依然会或多或少的带来一些性能开销,这里有优化的余地,就是使用内存池化技术。


二、 池化技术概述

池化的简单实现思路,是基于JVM堆内存之上,构建更高一层内存池,通过调用内存池allocate方法获取内存空间,调用release方法将内存区域归还内存池。内存池面临的首要问题是碎片回收,内存池在频繁申请和释放空间后,还能有尽可能连续的内存空间用于大块内存空间的分配。基于这个需求,有两种算法用于优化这一块的内存分配:伙伴系统和slab系统。

伙伴系统:

伙伴系统是以类完全二叉树的结构组织内存区域,左右节点互为伙伴。内存分配过程中,大块内存不断二分,直到找到满足所需的最小内存分片。内存释放是,判断释放内存分片的伙伴是否空闲,如果空闲则将左右伙伴合成更大一级内存块。linux就是使用该方式解决外部分配碎片的问题,为避免分片太细碎,通常情况下有最小分片,例如4k。

slab系统:

slab系统主要解决内部碎片问题,slab系统的思路是将预先申请的一块内存区域包装成一个内存集,该内存集将申请到的大块内存分割成相等大小的内存片。用户申请小块内存时,通过具体申请的内存大小找到slab系统中对应的一个内存集,从内存集中拿到内存分片,内存释放时也是将内存分片归还给内存集。


三、 netty4池化技术实现

netty4相对于netty3的一大改进就是引入了内存池化技术,用以解决高速网络通信过程中,netty造成的应用内存锯齿状消费和大量gc的问题。这一块代码内容很多,逻辑略显复杂,但究其根本就是伙伴系统和slab系统的实现和扩展。内存池管理单元以Allocate对象的形式出现,一个Allocate对象由多个Arena组成,每个Arena能完全执行内存块的分配和回收。Arena内有三类内存块管理单元:TinySubPage,SmallSubPage,ChunkList。其中tiny和small符合slab系统的管理策略,ChunkList符合伙伴系统的管理策略。当用户申请内存介于tinySize和smallSize之间时,从tinySubPage中获取内存块;申请内存介于smallSize和pageSize之间时,从smallSubPage中获取内存块;介于pageSize和chunkSize之间时,从ChunkList中获取内存;大于ChunkSize的内存块不通过池化分配(其中tinySubPage和smallSubPage的内存也来自ChnkList,是对一个page内存块的细分)。

除此之外,netty4中还有线程缓存的内存块和实现Recycler的对象重用,这些内容与池化无关,暂不赘述。

java代码中可以使用netty4中的PooledByteBufAllocator对象实现内存池化效果。


四、 可能存在的内存泄露问题

用户自己管理内存带来的弊端就是可能存在内存泄露的问题,持续的内存泄露会造成应用性能下降,严重的还会导致oom,另外内存泄露问题往往难以发现。

合理使用netty4中的内存池,需要账务netty4中的以下特性:

1. 线程缓存问题

netty4中的池化buf,内存使用结束后首先将内存块归还给本地缓存,便于本地再次申请时直接从本地缓存获取,降低多线程对集中式内存分配器的并发压力,这时如果缓存分配跨线程就会出现问题。例如线程A申请一块内存空间存放数据,这一块内存随后流转到线程B,线程B释放内存块,此时内存块被缓存在B线程中,A再申请一块内存空间时,线程缓存中任然没有,而由于线程B从未申请内存,因此B中缓存的内存块永远不会被用到(线程缓存中空闲内存块清理工作有allocate方法触发,因此B线程如果从不申请内存块则线程缓存永远不会被清理)

创建Allocator时有一个useCacheForAllThreads变量,用于控制缓存块是否被线程缓存,默认时开启的。如果内存块要在多个线程中流转,可以考虑关闭该变量,防止内存泄露。

2. 死亡线程的内存块回收

netty为了应对内存泄露的问题,在创建PoolThreadCache对象时,通过

ThreadDeathWatcher.watch(deathWatchThread, freeTask);

启用守护线程检测线程存活状态,随后每个PoolThreadCache对象创建时都会把当前线程注册进来。守护线程会每秒检测一次,判断线程死亡了则回收线程缓存中的内存块。

3. 对象缓存

netty4中的缓存池不仅缓存内存块,还缓存ByteBuf对象。

例如allocate的一个PooledUnsafeHeapByteBuf对象,在release后会被暂存在RECYCLER中,再次allocate会将该对象重新分配使用,这样会造成之前已经release的对象重新变得可用。

ByteBuf byteBuf = bufAllocator.buffer(100);

byteBuf.release();

ByteBuf byteBuf1 = bufAllocator.buffer(100);

此时byteBuf会重新变得可用。

4. 内存泄露检测

当前有4个泄露检测级别:

  ● 禁用(DISABLED)   - 完全禁止泄露检测。不推荐。

  ● 简单(SIMPLE)       - 告诉我们取样的1%的缓冲是否发生了泄露。默认。

  ● 高级(ADVANCED) - 告诉我们取样的1%的缓冲发生泄露的地方

  ● 偏执(PARANOID)  - 跟高级选项类似,但此选项检测所有缓冲,而不仅仅是取样的那1%。此选项在自动测试阶段很有用。如果构建(build)输出包含了LEAK,可认为构建失败。

使用 -Dio.netty.leakDetectionLevel=advanced 配置


网易云免费体验馆,0成本体验20+款云产品! 

更多网易技术、产品、运营经验分享请点击


相关文章:
【推荐】 大数据应用除了在体育项目中,还有这些切身感受得到的应用案例
【推荐】 “代码变更覆盖率”在后端测试中的实践
【推荐】 让机器读懂用户--大数据中的用户画像