前言正是Netty的易用性和高性能让Netty如此受欢迎。作为通信框架,首当其冲的就是对IO性能的高要求。很多读者都知道Netty底层使用DirectMemory来减少内核态和用户态之间的内存拷贝,加快IO速率。但是频繁向系统申请DirectMemory,用完就释放,这本身就是一个影响性能的事情。为此,Netty内部实现了一套自己的内存管理机制。申请时,Netty会一次性向操作系统申请一块较大的内存,然后管理这块大内存,根据需要拆分成小块进行分配。释放的时候,Netty并不急于直接释放内存,而是回收内存,以备下次使用。这种内存管理机制不仅可以管理DirectoryMemory,还可以管理HeapMemory。内存的最终消费者——ByteBuf在这里,我想跟读者强调一下,ByteBuf和内存其实是两个概念,需要分开来理解。ByteBuf是一个对象,它需要分配一块内存才能正常工作。内存可以通俗的理解为我们操作系统的内存,虽然申请的内存也需要依赖载体存储:使用堆内存时使用的是byte[],而Direct内存是Nio的ByteBuffer(所以Java使用DirectMemory的功能由JDK中的Nio包提供)。之所以强调这两个概念,是因为Netty的内存池(或者说内存管理机制)涉及到内存的分配和回收,而Netty的ByteBuf的回收是另一种叫做对象池的技术(通过Recycler完成)。尽管两者总是一起使用,但它们是两个独立的机制。可能有一段时间创建了ByteBuf,回收了ByteBuf,但是内存是新申请给操作系统的。也有可能在创建一个ByteBuf的时候,新创建了一个ByteBuf,但是回收了内存。因为对于一个创建过程,可以分为三个步骤:获取ByteBuf实例(可能是新创建的,也可能是中间缓存的);从Netty内存管理机制申请内存(可能是操作系统新申请的,也可能是之前回收的))将申请的内存分配给ByteBuf。本文只关注内存管理机制,对对象回收机制不作过多解释。Netty中内存管理相关的类Netty中内存管理相关的类有很多。框架提供了PoolArena、PoolChunkList、PoolChunk、PoolSubpage等来管理一块或一组内存。对外提供了ByteBufAllocator,供用户操作java训练。下面我们先对这些类进行一定程度的介绍,然后了解通过ByteBufAllocator进行内存分配和回收的过程。为了篇幅和可读性,本文不会涉及大量非常详细的代码说明,主要是用图来介绍必要的代码。PoolChunck——Netty申请给OS的最小内存,上面已经介绍过了。为了减少操作系统频繁申请内存,Netty会一次性申请更大的内存。然后管理这块内存,每次按需分配一部分给内存使用者(即ByteBuf)。这里的内存是PoolChunk,其大小由ChunkSize决定(默认为16M,即一次向OS申请16M内存)。Page——PoolChunk管理的最小内存单元PoolChunk能够管理的最小内存称为Page,大小由PageSize决定(默认为8K),即一次申请到PoolChunk的内存必须在该单元内页(一页或多页)。当PoolChunk需要分配内存时,PoolChunk会检查内部记录的信息,找出满足内存分配的Page所在的位置,分配给用户。PoolChunck是如何管理Page的我们已经知道PoolChunk内部是以Pages为单位来组织内存的,同时也是以Pages为单位分配内存的。那么如何管理PoolChunk才能兼顾分配效率(指尽快找到可以分配的内存并保证分配的内存是连续的)和使用效率(尽可能少的避免内存浪费),从而物尽其用))的?Netty借鉴了Jemalloc的思想。首先,PoolChunk通过一棵完全二叉树来组织内存。以默认的ChunkSize为16M,PageSize为8K为例,一个PoolChunk可以分为2048个Pages。以这2048个Pages作为叶子节点的宽度,可以得到一棵深度为11的树(2^11=2048)。我们让每个叶子节点管理一个Page,那么它的父节点管理的内存就是两个Page(父节点有左右两个叶子节点),以此类推,树的根节点管理这个PoolChunk的所有Page(因为所有叶子节点都是它的子节点),而树中一个节点管理的内存大小就是以该节点为根的子树中包含的叶子节点所管理的所有Pages。这样做的好处是,当你需要内存的时候,可以很快的找到分配内存的位置(只需要从上到下找到管理内存是你需要的内存的节点,然后分配该节点管理的内存即可出去就好),分配的内存还是连续的(只要保证相邻叶子节点对应的Pages是连续的即可)。上图中编号为512的节点管理着4个Page,分别是Page0、Page1、Page2、Page3(因为它下面还有4个叶子节点2048、2049、2050、2051)。编号为1024的节点管理着两个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的初始状态是一致的)。一旦分配了子节点,父节点可以分配的完整内存(fullmemory是指节点管理的连续内存块,不是节点剩余的内存大小)减少(内存Allocation和deallocation会修改值关联memoryMap中的相关节点)。比如2048号节点分配完之后,那么对于1024号节点来说,可以分配满的内存(原本是16K)已经和2049号节点(它的右子节点)一样了(减为8K),换句话说,节点1024的记忆能力已经退化为2049节点所在层节点的能力。这种降级可能会影响所有父节点。此时512节点可以分配的完整内存是16K,而不是24K(因为内存分配是按照2的次方分配的,虽然一个consumer实际需要的内存可能是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就是用来管理这么小内存的管理器。Smallmemory是指小于一个Page的内存,分为Tiny和Small。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只选择一种规范进行内存管理,所以处理相同规范的PoolSubpages往往通过链表组织在一起,不同的规范存放在不同的地方。并且始终管理一个规范的特性,使得PoolSubpage在内存管理时不需要使用PoolChunk的完全二叉树的方式来管理内存(比如管理16B的PoolSubpage只需要考虑分配16B内存,申请32B内存时,你必须支付管理32B的内存),只用long[]位图(可以看做是一个位数组)记录被管理的内存块中有哪些被分配了(位数表示内存块的个数)。实现要简单得多。PoolArena——内存管理的协调者PoolArena是内存管理的协调者。它有一个由PoolChunkList组成的链表(上面说了链表是根据PoolChunkList管理的使用率来划分的)。此外,它还有两个PoolSubpage数组,PoolSubpage[]tinySubpagePools和PoolSubpage[]smallSubpagePools。默认情况下,tinySubpagePools的长度为31,即存储了16、32、48...496的31个规格的PoolSubpages(不同规格的PoolSubpages存储在对应的数组下标中,相同规格的PoolSubpages存储在同一个数组中标出链表)。同样,默认情况下smallSubpagePools的长度为4,存储了四种规格的PoolSubpages:512、1024、2048、4096。PoolArena会根据申请的内存大小,决定是否寻找相应规格的PoolChunk或PoolSubpage进行分配.值得注意的是,PoolArena在分配内存的时候,会存在竞争,所以在关键的地方,PoolArena会通过sychronize来保证线程安全。Netty在一定程度上优化了这种竞争。它会分配多个PoolArenas,让线程尽可能使用不同的PoolArenas,减少竞争。PoolThreadCache——线程局部缓存,减少内存分配的竞争PoolArena不可避免地产生竞争。除了创建多个PoolArena来减少竞争,Netty还允许线程在释放内存时缓存已经申请的内存,而不是立即返回给PoolArena。缓存的内存保存在PoolThreadCache中,它是一个线程局部变量,因此是线程安全的,访问它不需要加锁。PoolThreadCache内部是MemeoryRegionCache的缓存池(数组),按照级别也可以分为Tiny、Small和Normal(Huge不缓存,因为Huge效率不高)。其中Tiny和Small的划分方式与PoolSubpage相同。因为Normal中的组合太多,所以会有一个参数来控制缓存哪些规格(比如一页,两页,四页等等。。。),不在Normal缓存规格内的内存块会不被缓存并直接返回到PoolArena。再看MemoryRegionCache,它里面是一个队列,同一个队列中的所有节点都可以看成线程使用的同规格的内存块。同时它还有一个size属性来控制队列过长(当队列满的时候,这个规格的内存块不会被缓存,而是直接返回给PoolArena)。当一个线程需要内存时,它会先从自己的PoolThreadCache中找到对应级别的缓冲池(对应数组)。然后从数组中找到规范对应的MemoryRegionCache。最后从队列中取出内存块进行分配。Netty的内存组织概览和PooledByteBufAllocator的内存申请步骤了解了上面这么多概念之后,就用一张图来给读者留下深刻印象。上图只详细画出了HeapMemory部分,DirectoryMemory类似。最后以PooledByteBufAllocator为入口,再次回顾内存申请流程:PooledByteBufAllocator.newHeapBuffer()开始申请内存获取线程局部变量PoolThreadCache和线程绑定的PoolArena通过PoolArena分配内存,首先获取ByteBufobject(可能对象池Recycled也可能创建),在开始内存分配前先判断内存的级别,尝试从PoolThreadCache中寻找相同规格的缓存内存块,如果没有,则从PoolArena中分配内存为Normal级别的内存,查找从PoolChunkList的链表中选择一个合适的PoolChunk来分配内存。如果没有,先像OS一样申请一个PoolChunk,然后由PoolChunk分配相应的Page。对于Tiny和Small内存,从对应的PoolSubpage缓存池中寻找内存分配,如果没有PoolSubpage,该行会转到第5步,先分配PoolChunk,然后PoolChunk再分配Pages给PoolSubpage使用。对于Huge级别的内存,不会缓存,用到的时候申请,释放的时候直接回收。内存被ByteBuf使用,内存申请过程完成。文章来自业余草
