谈谈Netty内存管理

点击上方蓝色字体,选择“标星公众号”
java

优质文章,第一时间送达web

  做者 |  insaneXs 数组

来源 |  urlify.cn/mAzmYf缓存

66套java从入门到精通实战课程分享安全

前言

正是Netty的易用性和高性能成就了Netty,让其可以如此流行。
而做为一款通讯框架,首当其冲的即是对IO性能的高要求。
很多读者都知道Netty底层经过使用Direct Memory,减小了内核态与用户态之间的内存拷贝,加快了IO速率。可是频繁的向系统申请Direct Memory,并在使用完成后释放自己就是一件影响性能的事情。为此,Netty内部实现了一套本身的内存管理机制,在申请时,Netty会一次性向操做系统申请较大的一块内存,而后再将大内存进行管理,按需拆分红小块分配。而释放时,Netty并不着急直接释放内存,而是将内存回收以待下次使用。
这套内存管理机制不只能够管理Directory Memory,一样能够管理Heap Memory。微信

内存的终端消费者——ByteBuf

这里,我想向读者们强调一点,ByteBuf和内存实际上是两个概念,要区分理解。
ByteBuf是一个对象,须要给他分配一块内存,它才能正常工做。
而内存能够通俗的理解成咱们操做系统的内存,虽然申请到的内存也是须要依赖载体存储的:堆内存时,经过byte[], 而Direct内存,则是Nio的ByteBuffer(所以Java使用Direct Memory的能力是JDK中Nio包提供的)。
为何要强调这两个概念,是由于Netty的内存池(或者称内存管理机制)涉及的是针对内存的分配和回收,而Netty的ByteBuf的回收则是另外一种叫作对象池的技术(经过Recycler实现)。
虽然这二者老是伴随着一块儿使用,但这两者是独立的两套机制。可能存在着某次建立ByteBuf时,ByteBuf是回收使用的,而内存倒是新向操做系统申请的。也可能存在某次建立ByteBuf时,ByteBuf是新建立的,而内存倒是回收使用的。
由于对于一次建立过程而言,能够分红三个步骤:数据结构

  1. 获取ByteBuf实例(可能新建,也多是之间缓存的)app

  2. 向Netty内存管理机制申请内存(可能新向操做系统申请,也多是以前回收的)框架

  3. 将申请到的内存分配给ByteBuf使用性能

本文只关注内存的管理机制,所以不会过多的对对象回收机制作解释。

Netty中内存管理的相关类

Netty中与内存管理相关的类有不少。框架内部提供了PoolArena,PoolChunkList,PoolChunk,PoolSubpage等用来管理一块或一组内存。
而对外,提供了ByteBufAllocator供用户进行操做。
接下来,咱们会先对这几个类作必定程度的介绍,在经过ByteBufAllocator了解内存分配和回收的流程。
为了篇幅和可读性考虑,本文不会涉及到大量很详细的代码说明,而主要是经过图辅之必要的代码进行介绍。
针对代码的注解,能够见我GitHub上的netty项目。

PoolChunck——Netty向OS申请的最小内存

上文已经介绍了,为了减小频繁的向操做系统申请内存的状况,Netty会一次性申请一块较大的内存。然后对这块内存进行管理,每次按需将其中的一部分分配给内存使用者(即ByteBuf)。这里的内存就是PoolChunk,其大小由ChunkSize决定(默认为16M,即一次向OS申请16M的内存)。

Page——PoolChunck所管理的最小内存单位

PoolChunk所能管理的最小内存叫作Page,大小由PageSize(默认为8K),即一次向PoolChunk申请的内存都要以Page为单位(一个或多个Page)。
当须要由PoolChunk分配内存时,PoolChunk会查看经过内部记录的信息找出知足这次内存分配的Page的位置,分配给使用者。

PoolChunck如何管理Page

咱们已经知道PoolChunk内部会以Page为单位组织内存,一样以Page为单位分配内存。
那么PoolChunk要如何管理才能兼顾分配效率(指尽量快的找出可分配的内存且保证这次分配的内存是连续的)和使用效率(尽量少的避免内存浪费,作到物尽其用)的?
Netty采用了Jemalloc的想法。
首先PoolChunk经过一个彻底二叉树来组织内部的内存。以默认的ChunkSize为16M, PageSize为8K为例,一个PoolChunk能够划分红2048个Page。将这2048个Page看做是叶子节点的宽度,能够获得一棵深度为11的树(2^11=2048)。
咱们让每一个叶子节点管理一个Page,那么其父节点管理的内存即为两个Page(其父节点有左右两个叶子节点),以此类推,树的根节点管理了这个PoolChunk全部的Page(由于全部的叶子结点都是其子节点),而树中某个节点所管理的内存大小便是以该节点做为根的子树所包含的叶子节点管理的所有Page。
这样作的好处就是当你须要内存时,很快能够找到从何处分配内存(你只须要从上往下找到所管理的内存为你须要的内存的节点,而后将该节点所管理的内存分配出去便可),而且所分配的内存仍是连续的(只要保证相邻叶子节点对应的Page是连续的便可)。

上图中编号为512的节点管理了4个Page,为Page0, Page1, Page2, Page3(由于其下面有四个叶子节点2048,2049,2050, 2051)。
而编号为1024的节点管理了2个Page,为Page0和Page1(其对应的叶子节点为Page0和Page1)。
当须要分配32K的内存时,只须要将编号512的节点分配出去便可(512分配出去后会默认其下全部子节点都不能分配)。而当须要分配16K的内存时,只须要将编号1024的节点分配出去便可(一旦节点1024被分配,下面的2048和2049都不容许再被分配)。

了解了PoolChunk内部的内存管理机制后,读者可能会产生几个问题:

  • PoolChunk内部如何标记某个节点已经被分配?

  • 当某个节点被分配后,其父节点所能分配的内存如何更新?即一旦节点2048被分配后,当你再须要16K的内存时,就不能从节点1024分配,由于如今节点1024可用的内存仅有8K。

为了解决以上这两点问题,PoolChunk都是内部维护了的byte[] memeoryMap和byte[] depthMap两个变量。
这两个数组的长度是相同的,长度等于树的节点数+1。由于它们把根节点放在了1的位置上。而数组中父节点与子节点的位置关系为:

假设parnet的下标为i,则子节点的下标为2i和2i+1

用数组表示一颗二叉树,大家是否是想到了堆这个数据结构。

已经知道了两个数组都是表示二叉树,且数组中的每一个元素能够当作二叉树的节点。那么再来看看元素的值分别代码什么意思。
对于depthMap而言,该值就表明该节点所处的树的层数。例如:depthMap[1] == 1,由于它是根节点,而depthMap[2] = depthMap[3] = 2,表示这两个节点均在第二层。因为树一旦肯定后,结构就不在发生改变,所以depthMap在初始化后,各元素的值也就不发生变化了。

而对于memoryMap而言,其值表示该节点下可用于完整内存分配的最小层数(或者说最靠近根节点的层数)。
这话理解起来可能有点别扭,仍是用上文的例子为例 。
首先在内存都未分配的状况下,每一个节点所能分配的内存大小就是该层最初始的状态(即memoryMap的初始状态和depthMap的一致的)。而一旦其有个子节点被分配出后去,父节点所能分配的完整内存(完整内存是指该节点所管理的连续的内存块,而非该节点剩余的内存大小)就减少了(内存的分配和回收会修改关联的mermoryMap中相关节点的值)。
譬如,节点2048被分配后,那么对于节点1024来讲,能完整分配的内存(原先为16K)就已经和编号2049节点(其右子节点)相同(减为了8K),换句话说节点1024的能力已经退化到了2049节点所在的层节点所拥有的能力。
这一退化可能会影响全部的父节点。
而此时,512节点能分配的完整内存是16K,而非24K(由于内存分配都是按2的幂进行分配,尽管一个消费者真实须要的内存多是21K,可是Netty的内存管理机制会直接分配32K的内存)。

可是这并非说节点512管理的另外一个8K内存就浪费了,8K内存还能够用来在申请内存为8K的时候分配。

用图片演示PoolChunk内存分配的过程。其中value表示该节点在memoeryMap的值,而depth表示该节点在depthMap的值。
第一次内存分配,申请者实际须要6K的内存:

此次分配形成的后果是其全部父节点的memoryMap的值都往下加了一层。
以后申请者须要申请12K的内存:

因为节点1024已经没法分配所需的内存,而节点512还可以分配,所以节点512让其右节点再尝试。

上述介绍的是内存分配的过程,而内存回收的过程就是上述过程的逆过程——回收后将对应节点的memoryMap的值修改回去。这里不过多介绍。

PoolChunkList——对PoolChunk的管理

PoolChunkList内部有一个PoolChunk组成的链表。一般一个PoolChunkList中的全部PoolChunk使用率(已分配内存/ChunkSize)都在相同的范围内。
每一个PoolChunkList有本身的最小使用率或者最大使用率的范围,PoolChunkList与PoolChunkList之间又会造成链表,而且使用率范围小的PoolChunkList会在链表中更加靠前。
而随着PoolChunk的内存分配和使用,其使用率发生变化后,PoolChunk会在PoolChunkList的链表中,先后调整,移动到合适范围的PoolChunkList内。
这样作的好处是,使用率的小的PoolChunk能够先被用于内存分配,从而维持PoolChunk的利用率都在一个较高的水平,避免内存浪费。

PoolSubpage——小内存的管理者

PoolChunk管理的最小内存是一个Page(默认8K),而当咱们须要的内存比较小时,直接分配一个Page无疑会形成内存浪费。
PoolSubPage就是用来管理这类细小内存的管理者。

小内存是指小于一个Page的内存,能够分为Tiny和Smalll,Tiny是小于512B的内存,而Small则是512到4096B的内存。若是内存块大于等于一个Page,称之为Normal,而大于一个Chunk的内存块称之为Huge。

而Tiny和Small内部又会按具体内存的大小进行细分。
对Tiny而言,会分红16,32,48...496(以16的倍数递增),共31种状况。
对Small而言,会分红512,1024,2048,4096四种状况。
PoolSubpage会先向PoolChunk申请一个Page的内存,而后将这个page按规格划分红相等的若干个内存块(一个PoolSubpage仅会管理一种规格的内存块,例如仅管理16B,就将一个Page的内存分红512个16B大小的内存块)。
每一个PoolSubpage仅会选一种规格的内存管理,所以处理相同规格的PoolSubpage每每是经过链表的方式组织在一块儿,不一样的规格则分开存放在不一样的地方。
而且老是管理一个规格的特性,让PoolSubpage在内存管理时不须要使用PoolChunk的彻底二叉树方式来管理内存(例如,管理16B的PoolSubpage只须要考虑分配16B的内存,当申请32B的内存时,必须交给管理32B的内存来处理),仅用 long[] bitmap (能够当作是位数组)来记录所管理的内存块中哪些已经被分配(第几位就表示第几个内存块)。
实现方式要简单不少。

PoolArena——内存管理的统筹者

PoolArena是内存管理的统筹者。
它内部有一个PoolChunkList组成的链表(上文已经介绍过了,链表是按PoolChunkList所管理的使用率划分)。
此外,它还有两个PoolSubpage的数组,PoolSubpage[] tinySubpagePools 和 PoolSubpage[] smallSubpagePools。
默认状况下,tinySubpagePools的长度为31,即存放16,32,48...496这31种规格的PoolSubpage(不一样规格的PoolSubpage存放在对应的数组下标中,相同规格的PoolSubpage在同一个数组下标中造成链表)。
同理,默认状况下,smallSubpagePools的长度为4,存放512,1024,2048,4096这四种规格的PoolSubpage。
PoolArena会根据所申请的内存大小决定是找PoolChunk仍是找对应规格的PoolSubpage来分配。

值得注意的是,PoolArena在分配内存时,是会存在竞争的,所以在关键的地方,PoolArena会经过sychronize来保证线程的安全。
Netty对这种竞争作了必定程度的优化,它会分配多个PoolArena,让线程尽可能使用不一样的PoolArena,减小出现竞争的状况。

PoolThreadCache——线程本地缓存,减小内存分配时的竞争

PoolArena免不了产生竞争,Netty除了建立多个PoolArena减小竞争外,还让线程在释放内存时缓存已经申请过的内存,而不当即归还给PoolArena。
缓存的内存被存放在PoolThreadCache内,它是一个线程本地变量,所以是线程安全的,对它的访问也不须要上锁。
PoolThreadCache内部是由MemeoryRegionCache的缓存池(数组),一样按等级能够分为Tiny,Small和Normal(并不缓存Huge,由于Huge效益不高)。
其中Tiny和Small这两个等级下的划分方式和PoolSubpage的划分方式相同,而Normal由于组合太多,会有一个参数控制缓存哪些规格(例如,一个Page, 两个Page和四个Page等...),不在Normal缓存规格内的内存块将不会被缓存,直接还给PoolArena。
再看MemoryRegionCache, 它内部是一个队列,同一队列内的全部节点能够当作是该线程使用过的同一规格的内存块。同时,它还有个size属性控制队列过长(队列满后,将不在缓存该规格的内存块,而是直接还给PoolArena)。
当线程须要内存时,会先从本身的PoolThreadCache中找对应等级的缓存池(对应的数组)。而后再从数组中找出对应规格的MemoryRegionCache。最后从其队列中取出内存块进行分配。

Netty内存机构总览和PooledByteBufAllocator申请内存步骤

在了解了上述这么多概念后,经过一张图给读者加深下印象。

上图仅详细画了针对Heap Memory的部分,Directory Memory也是相似的。

最后在由PooledByteBufAllocator做为入口,重头梳理一遍内存申请的过程:

  1. PooledByteBufAllocator.newHeapBuffer()开始申请内存

  2. 获取线程本地的变量PoolThreadCache以及和线程绑定的PoolArena

  3. 经过PoolArena分配内存,先获取ByteBuf对象(多是对象池回收的也多是建立的),在开始内存分配

  4. 分配前先判断这次内存的等级,尝试从PoolThreadCache的找相同规格的缓存内存块使用,没有则从PoolArena中分配内存

  5. 对于Normal等级内存而言,从PoolChunkList的链表中找合适的PoolChunk来分配内存,若是没有则先像OS申请一个PoolChunk,在由PoolChunk分配相应的Page

  6. 对于Tiny和Small等级的内存而言,从对应的PoolSubpage缓存池中找内存分配,若是没有PoolSubpage,线会到第5步,先分配PoolChunk,再由PoolChunk分配Page给PoolSubpage使用

  7. 对于Huge等级的内存而言,不会缓存,会在用的时候申请,释放的时候直接回收
    8.将获得的内存给ByteBuf使用,就完成了一次内存申请的过程

总结

Netty的内存管理机制仍是很巧妙的,可是介绍起来不免有点晦涩。本想尽可能通俗易懂的撇开源码和你们讲讲原理,可是不知不觉也写了一大段的文字。但愿上文的几幅图能帮助读者理解。
另外,本文也没有介绍内存释放的过程。释放其实就是申请的逆过程,有兴趣的读者能够本身跟一下源码,或者是从文章开头的项目中找源码注释。





     



感谢点赞支持下哈 

本文分享自微信公众号 - java1234(gh_27ed55ecb177)。
若有侵权,请联系 support@oschina.cn 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一块儿分享。

相关文章
相关标签/搜索