资讯中心

Netty内存池核心设计:从ByteBuf分配到Arena/Chunk/Subpage源码解析

📅 2026/9/26 20:10:06
Netty内存池核心设计:从ByteBuf分配到Arena/Chunk/Subpage源码解析
做Java服务端开发的人应该都有过被NIO ByteBuffer折磨的经历要手动管理position、limit用完还要自己释放DirectBuffer稍微粗心一点就内存泄漏。后来Netty提供了ByteBuf配合引用计数和内存池才把这些麻烦收敛掉。但大多数人停留在会用PooledByteBufAllocator这个层面一旦被问到Netty内存池内部是怎么设计的就只剩几个术语在脑子里晃Arena、Chunk、Subpage、ThreadLocalCache。这篇文章我按自己啃源码的顺序把这套设计的前因后果、核心数据结构、一次完整的分配/释放旅程以及生产环境调参与踩坑经验一次性讲透。1. 为什么Netty非要自己搞一套内存池——JDK的ByteBuffer不够用吗先说一个很多人忽略的前提Netty内存池不是闲得没事造轮子而是被JDK标准API的短板逼出来的。只有先搞明白痛点在哪后面看到那些复杂的结构才不至于一脸懵。1.1 HeapByteBuffer和DirectByteBuffer的分配路径差异JDK的ByteBuffer分两类。HeapByteBuffer底层是byte[]数据在JVM堆里由GC管理分配快但问题在于如果用Channel读写需要先复制一份到堆外才能交给操作系统因为OS的DMA不能直接操作JVM堆内存GC移动对象会导致地址变化。于是又有了DirectByteBuffer也就是堆外Direct Memory它绕过了堆内复制数据直接从堆外内存到网卡性能好得多。问题来了。DirectByteBuffer的分配底层走的是Unsafe.allocateMemory这类本地分配当你调用ByteBuffer.allocateDirect()时JVM实际上要创建DirectByteBuffer对象同时注册一个Cleaner用于在GC阶段回收堆外内存。在JDK 8及以前如果堆外DirectMemory分配得又多又频繁还会触发Full GC来迫使Cleaner执行清理这正是很多人遇到OutOfMemoryError: Direct buffer memory却不理解我明明没占多少堆内存怎么Full GC了的原因之一。就算不提GC问题单看分配成本每次申请堆外内存都是一次本地内存分配在高并发场景下这个成本乘以每秒几十万次读写绝对不可忽视。这就像你开餐厅每来一桌客人都现去买菜而不是备货在后厨——池化本质上就是后厨备货。1.2 池化要解决的三个核心问题对Netty这种网络框架来说一个连接上的每个读写请求都要创建ByteBuf用完立即释放。如果不做池化流量高峰期就是高频malloc/释放。池化至少解决了三个实际问题降低分配开销。堆外内存的分配不仅仅有系统调用的开销还有DirectByteBuffer对象本身和Cleaner的创建成本。从池子里拿一段已分配好的内存只需要做几次数组下标运算和状态标记成本差一两个数量级。减少锁竞争和线程冲突。池化之后完全可以做到每个线程从自己的缓存里拿内存拿不到才去全局区域避免所有线程都在一个全局分配器上抢锁这个设计思路在后面讲jemalloc时会再次看到。控制碎片化。ByteBuf使用请求大小随机如果按原始大小一个一个分配内存会碎得像猫抓板。按规格分级分配加上伙伴系统合并释放的连续块能把碎片控制在较低水平。理解了这个出发点你再看Netty的内存池代码会发现每一块设计都有明确的目的没有一处是炫技。2. jemalloc的核心思想提炼Netty到底借鉴了哪四板斧jemalloc是Facebook、FreeBSD这些系统在用的内存分配器专门克制glibc malloc在高并发下的锁竞争问题。Netty对它的借鉴可以归结为arena、size class、slab、线程缓存这四个关键词。逐个拆开看。2.1 arena分区让不同线程各占山头jemalloc最核心的思路是把内存区域划分为多个Arena每个线程在初次分配时会绑定到其中一个Arena。不同线程跑在不同的Arena上天然规避了全局锁竞争。如果不分区所有线程都在同一个堆上打架分完区之后冲突概率被大幅稀释。Netty把这一层原封不动地搬了过来PooledByteBufAllocator内部维护了多个PoolArena数量默认是CPU核数的两倍。EventLoop线程在分配时会通过自增索引轮询选择Arena由于每个EventLoop基本是固定的几个线程这种轮询方式可以让不同线程尽量落在不同Arena上减少互相抢锁。Arena多出来了内存却仍然来自同一片物理资源。它不能解决最大内存总量的问题解决的是并发访问时的碰撞概率。所以我们在评估Netty内存池效果的时候不要指望它能让你多用内存它只能让你在相同的总内存约束下跑得更稳。2.2 size class与slab把不规则请求归一化成规格内存jemalloc把内存分配请求按大小分成一系列规格等级size class比如8字节、16字节、32字节、64字节……而不是按用户请求的任意大小去分配。这样做的核心收益有两个分配时不需要精确匹配从最近的规格桶里拿一块即可速度极快。长期来看内存块大小种类有限回收后可以重复利用不容易产生零碎空洞。Netty同样引入了这个机制。它把ByteBuf请求按大小归为三类Tiny16字节起步按16字节递增到496字节。Small512字节开始按2的幂翻倍到4096字节。Normal8192字节及更大以页为单位分配。每个等级内存块都被组织成一块大内存切分成若干等大小小块的形式这就是slab思想。你申请17字节Netty实际给你的是32字节的块申请1KB实际拿到的是1024字节的块。对上层业务来说这有一点空间浪费但换来的是分配性能和可控的回收路径整体上非常划算。2.3 线程本地缓存从源头规避锁竞争有了Arena不同线程抢同一Arena锁的概率已经低了但还不够。jemalloc更进一步为每个线程提供了一层tcache线程本地缓存。分配内存时优先从自己的tcache里拿tcache没有才去Arena层分配。释放时同样优先回收到tcache等缓存累积到阈值或线程退出再统一归还给Arena。Netty的PoolThreadCache就是这一思想的直接映射。它内部按heap和direct分别维护了三组缓存队列tiny、small、normal。每次分配ByteBuf先看这个线程的缓存里有没有对应规格的空闲块有就直接拿连Arena锁都不用碰。这才是性能提升最猛的一层一个高吞吐的EventLoop线程大量小ByteBuf的分配和释放完全走自己的线程缓存零并发冲突。你如果用过jemalloc一定会对这种感觉很熟悉。2.4 Netty对jemalloc的裁剪不是照搬C内存分配器Netty不是把jemalloc抄了一遍因为两者的底层约束根本不同。jemalloc管理的是真正的虚拟内存页需要自己调用mmap还要考虑页大小、cache line、NUMA等硬件因素而Netty管理的是JVM堆内的byte[]和堆外的DirectMemory底层内存已经有OS和JVM在管了。所以Netty做了几个重要裁剪没有实现传统malloc意义上的任意大小精确匹配而是用ByteBuf的引用计数来决定何时真正归还类似C的shared_ptr语义。没有维护真正的多级空闲链表来做buddy合并而是用一棵固定深度的完全二叉树来管理chunk内部的页分配配合PoolSubpage实现页内细分。堆内和堆外分开管理因为heap类型的内存不存在系统调用成本池化它的主要意义是减少Java对象创建和GC压力。这层裁剪特别关键。面试时如果只是背Netty借鉴了jemalloc的Arena思想并不算真正理解只有知道哪些地方被改造成了Java形态、为什么改造才算是把设计吃透了。3. 核心数据结构逐层解剖PoolArena、PoolChunk、PoolSubpage各管一段有了思想基础进入代码层面。Netty内存池里最核心的三个类PoolArena、PoolChunk、PoolSubpage它们各自的职责边界非常清晰。3.1 PoolArena多个chunkList组成的仓库PoolArena在逻辑上相当于一个内存仓库。每个Arena内部维护了一个链表数组也就是多个PoolChunkList列表中的每个元素都是一个PoolChunk。这些ChunkList按照chunk的使用率分成不同梯队qInit初始化、q000几乎为空、q025使用率0-25%、q05025%-50%、q07550%-75%、q100接近满。为什么要分这么多梯队核心原因当内存分配失败时Netty要在最快的路径上找到一个合适的chunk当内存释放时也要根据chunk使用率的变化把它挪到合适的列表里保持整个内存池的健康度。如果所有chunk都在一个大List里每次查找都要遍历大量chunk而且满载的chunk和空chunk混在一起回收效率会极其糟糕。一个PoolArena内部本身还有一把锁。因为多线程可能同时落到同一个Arena上分配所以Arena层不是无锁的。但配合线程缓存锁冲突已经减到了很小。这个设计在Netty高并发场景下是经得起考验的。3.2 PoolChunk16MB大块内存与伙伴算法PoolChunk是实际持有内存的地方。默认配置下Netty的页大小是8KB最大order是11也就是说一个PoolChunk管的是一块8192 11 16MB的连续内存。这块连续内存被设计成一棵深度为11的满二叉树。树叶是2048个页每个8KB每一层对应不同大小的连续内存块。例如深度1的节点对应8MB的连续块深度2对应4MB依此类推。当你需要分配一块几倍于page的内存时例如16KBNetty会从根节点开始寻找一个足够容纳两块页的节点把它标记为已用然后从该节点返回可用的偏移量。释放的时候如果这个节点和它的兄弟节点都空闲了就合并回父节点形成更大的连续块。这就是经典的伙伴系统buddy system。它的优势在于分配和合并逻辑可以用位运算和状态更新快速完成不需要维护复杂的大链表。Netty在这里管的东西叫大块内存normal及以上细分到页内的小块由PoolSubpage负责。3.3 PoolSubpagepage内部的字节级精细化如果一个请求只有32字节你当然不能直接从8KB页里拿走32字节然后宣称这个页已用——那也太浪费了。PoolSubpage的作用就是把一个8KB的页等分成26个256字节的块然后向内层数据结构维护一个long[]数组作为位图bitmap每一位表示对应块是否被占用。分配时找到位图中第一个空闲位更新bitmap并返回对应偏移释放时把对应位清0即可。整个过程只做位运算效率极高。默认的PoolSubpage元素大小从16字节开始所以一个8KB页最多能细分出512个16字节的小块。每个PoolSubpage有一个归属的物主链表在Arena里不同规格的subpage挂在不同的tinySubpagePools和smallSubpagePools桶上。后续分配同规格内存时能直接从对应桶里找到还有空闲位的subpage去用而不是每次从页二叉树里重新切一个新页。这个细节直接决定了小内存分配的性能。4. 一次ByteBuf分配的生命周期从线程缓存到subpage的逐级降落理解了结构之后最爽的部分来了跟着一次ByteBuf申请请求从调用入口走到真正拿到内存的那个瞬间看它怎么逐层降落。我用的是Netty 4.1的默认池化配置后面讲参数时也以此版本为基准。4.1 第一步请求规格化与线程缓存命中你调用ByteBufAllocator.DEFAULT.buffer(1024)底层会进入PooledByteBufAllocator.newDirectBuffer()再走到PoolArena.allocate()。allocate第一步不是立刻找内存而是先计算归一化容量normCapacity。比如你申请1024字节归一化结果就是1024如果你申请1023字节归一化结果也是1024因为你不可能拿一个不满规格的块。具体规则是容量在16到496之间时向上对齐到16的倍数容量在512到4096之间时向上对齐到2的幂超过4096则向上取整到页8KB的整数倍。这一步就是前面说的size class思想在发挥作用——用一个相对规则的内存块规格换取快速查找的能力。规格化之后Netty先从当前线程的PoolThreadCache里找。处理逻辑在PoolThreadCache.allocate()先判断是tiny、small还是normal各去对应的MemoryRegionCache队列里取。如果队列非空取出一个PoolChunk、一个偏移量、一个大小直接组装成ByteBuf返回。这一步的代价是O(1)不碰任何锁不碰任何并发结构。在大多数业务里70%以上的分配请求都能在这一层被满足这就是Netty内存池性能神话的核心来源。4.2 第二步没有缓存命中时Arena怎么挑Chunk线程缓存没命中才轮到PoolArena出场。走到这里就要面对锁竞争了因为多个线程可能同时在操作同一个Arena。分配逻辑的核心是allocate()方法里的一段分支如果是tiny类请求线从对应tinySubpagePools桶里找空闲subpage找到了直接从subpage里分配。如果是small类请求从smallSubpagePools桶里找。如果tiny和small都没找到或者请求大小大于等于一个页8KB则走allocateNormal()分配正常的块。allocateNormal()会在多个ChunkList之间寻找可用chunk按q050、q025、q000、qInit、q075的顺序逐个尝试。选择q050优先是因为它里面chunk的使用率相对平衡至少有50%空间可用而q075这种接近满载的chunk一般留作后备因为往里面塞新请求很容易失败白白增加遍历成本。如果所有ChunkList都找不到合适的chunkNetty就会新建一个PoolChunk从底层再分配16MB内存出来。这就解释了为什么Netty进程刚启动时内存占用看起来很低一旦流量冲起来就会突然涨几MB——它是一块16MB、16MB这样批量拿的不是一点点拿的。4.3 第三步拿到Page之后如何切分和记录当allocateNormal()选定了一个chunk后还要继续分两种情况请求大于等于8KB调用allocateRun()在二叉树中查找连续的空闲页块。比如请求16KB就在树上找一个拥有2个连续页的节点分配。拿到的内存无需再细分page因为本来就要用到这么多页。请求小于8KB调用allocateSubpage()先在Arena对应规格的subpage桶里找有没有还有空闲位的页没有则从二叉树中分配一个空闲页创建新的PoolSubpage初始化为等分块然后把该subpage挂到对应桶里再从subpage里分配一块内存。拿到chunk节点后还有个必须做的动作更新chunk的使用状态并根据使用率把chunk移动到合适的ChunkList中。如果你分配了2MBchunk的使用率就从0跳到了12.5%它需要从qInit或q000挪到q025里方便后续按使用率维度管理。最后Netty会用这块内存初始化一个ByteBuf。堆外场景下是一个PooledUnsafeDirectByteBuf堆内场景是PooledHeapByteBuf。初始化包含写入引用计数1、设置本次分配的偏移量和容量、绑定所属的PoolChunk和handle。此后所有读写都在这块内存上进行直到release()被调用。5. 释放与回收引用计数驱动下的内存归还链路内存分配了得还回去还回去的方式比你想象的更有讲究。Netty没有采用自动GC来回收ByteBuf而是用引用计数来精确控制生命周期因为ByteBuf往往涉及堆外DirectMemory延迟回收对堆外内存的压力是灾难性的。5.1 release()调用与引用计数递减每个ByteBuf都有一个refCnt字段。初始为1表示这块内存有人在用。调用retain()会加一调用release()会减一。当refCnt减到0才会真正执行内存归还。Netty的release()底层通过ReferenceCountUpdater来执行核心是一个AtomicIntegerFieldUpdater保证多线程下的原子更新。这里要特别注意即使你把ByteBuf传到另一个线程去写只要那个线程最终调用了release()refCnt减到0之后内存照样能归还不会因为跨线程而失效。当然如果A线程持有引用但忘记releaseB线程又拿不到引用这块内存就会一直被占用直到池子的chunk耗尽——这是所有Netty内存泄漏问题的总根源。归还的入口是PooledByteBuf.deallocate()。它会拿到之前记录的PoolChunk、handle、normCapacity、arena等元数据然后开始下一层动作。5.2 回收到PoolThreadCache与chunk级回收释放的第一步是把内存块放回当前线程的PoolThreadCache而不是直接还给PoolArena。这一步对应jemalloc的tcache回收入口。具体做法是把PoolChunk、offset、size这几个信息打包放进对应规格的MemoryRegionCache队列。队列容量由tinyCacheSize、smallCacheSize、normalCacheSize参数控制默认情况下tiny能缓存512块、small 256块、normal 64块。缓冲区过大反而会浪费内存因为缓存的本质是赌这个线程后续还会需要相同规格的内存。当然不是所有内存都会被无脑缓存。Netty还有一个maxCachedBufferCapacity参数默认32KB意思是如果这块内存容量超过32KB就不放进线程缓存了因为大块内存缓存命中率低而占用的内存大直接归还给Arena更合适。那什么时候真正归还给Arena两种时机当前线程缓存队列满了再次往该队列里缓存新内存时会触发从队列头部挤出一个旧的MemoryRegionCache归还给Arena。线程销毁时PoolThreadCache会被清理里面缓存的所有内存一次性归还给Arena。归还给Arena之后PoolChunk内部做释放清理。如果释放的是页内的subpage内存就把位图对应位清0如果是整页或整块就标记节点空闲并尝试与兄弟节点合并。合并成功意味着更大连续空闲块出现了这是对抗内存碎片的关键机制。如果chunk的使用率跌破阈值它还会被调整到对应的ChunkList如果整个chunk已经全部空闲它会被从ChunkList中移除整个16MB被释放回底层。5.3 内存泄漏检测和常见释放问题现实比理论残酷得多线上最常见的Netty内存问题就是申请了不释放。排查思路大概率绕不开Netty自带的泄漏检测器ResourceLeakDetector。通过系统属性配置io.netty.allocator.leakDetectionLevel可以调整检测级别DISABLED: 关闭检测性能最好但出问题最难看。SIMPLE: 检测是否存在泄漏但不打印详细采样默认级别。ADVANCED: 打印最近访问ByteBuf的采样位置能定位到大致创建/持有路径。PARANOID: 对每个ByteBuf都做完整采样成本最高但定位最准。我在生产环境排查过几次DirectMemory持续上涨的问题经验是先上PARANOID配合压测流量重现问题从日志里找leak关键字基本都能定位到是某个业务线程把ByteBuf存进了容器列表或异步任务里忘释放。定位修复后再切回SIMPLE别让检测器的成本长期拖着线上性能。还有一个常见的伪泄漏现象内存从分配线程释放到了另一个线程。比如请求进来在Reactor线程分配了ByteBuf结果你把ByteBuf交给业务线程池去处理业务线程最后release——本质上这没问题内存回到了业务线程的缓存。但如果业务线程池线程非常多且每个线程都持有少量释放的缓存内存就会分散到几十上百个线程缓存里可能造成局部内存一直卡在某些短命线程的缓存中不归还。这种场景建议把线程池线程数控制得合理一些或者用PoolThreadCache的trim机制定期把空闲缓存归还给Arena。6. 生产环境调参与观测让内存池跑稳的实用经验结构讲完最后上点能直接落地的干货。Netty内存池是自适应的绝大多数时候你不需要改任何参数。但当你真正遇到性能瓶颈、DirectMemory受限、或者内存泄漏时下面这些参数和观测手段能帮你少走弯路。6.1 关键参数速查表Netty暴露的参数大多以系统属性形式配置改完要重启进程生效。以4.1.x常见参数为例参数默认值作用io.netty.allocator.typepooled指定分配器类型改成unpooled可关闭池化回到性能更差的模式io.netty.allocator.numHeapArenas2 x CPU核数堆内PoolArena数量io.netty.allocator.numDirectArenas2 x CPU核数堆外PoolArena数量io.netty.allocator.pageSize8192页大小调整后Chunk大小也联动变化io.netty.allocator.maxOrder11二叉树深度默认11对应Chunk 16MBio.netty.allocator.tinyCacheSize512每个线程tiny规格缓存条数io.netty.allocator.smallCacheSize256每个线程small规格缓存条数io.netty.allocator.normalCacheSize64每个线程normal规格缓存条数io.netty.allocator.maxCachedBufferCapacity32768超过该容量的ByteBuf不再进入线程缓存io.netty.allocator.cacheTrimInterval8192线程缓存定期修剪的分配次数io.netty.allocator.useCacheForAllThreadsfalse是否让非EventLoop线程也使用线程缓存io.netty.allocator.leakDetectionLevelSIMPLE泄漏检测级别顺便补充一个容易踩坑的点如果你在JVM启动时配置了-XX:MaxDirectMemorySize512m这512MB是进程里所有DirectByteBuffer共享的总预算。Netty的堆外分配不会自动绕开这个限制它依然受系统参数约束。池化只是把多次分配变成少量分配但它占用的DirectMemory总量是实打实的。6.2 观测手段与线上问题排查思路你要观察Netty内存池状态第一个工具就是PooledByteBufAllocator.DEFAULT.metric()。这个东西能返回当前所有Arena的统计信息包括每个Arena的tiny/small/normal subpage数量。每个Arena的numThreadLocalCaches也就是绑定到该Arena的线程数量。每个Arena的numChunks可以看出分配出来的chunk总数。如果发现某些Arena的线程本地缓存数量特别多而其他Arena几乎没有说明线程绑定不均匀底层锁竞争会集中到少数Arena上。这通常和业务线程模型有关比如大量线程池线程都用同一个Allocator分配数据没有固定绑定。这时可以考虑调大numArenas或者让业务线程尽量复用有限线程数。遇到过一个问题某服务压测时堆外内存持续上涨到MaxDirectMemorySize上限但线程缓存明明有限额chunk数量也没有爆炸。查到最后发现是请求对象被封装在一个异步消息体里业务方持有ByteBuf超过10秒才release而压测QPS太高导致同时被持有的内存总量超过了DirectMemory预算。这种问题跟内存池本身无关是业务持有时间过长导致的并发内存占用需要从业务代码层面去释放而不是调大参数掩盖。如果说有什么实际建议我会说内存池参数不是调越大越好。你把tinyCacheSize改成4096缓存条数多了线程本地缓存占用的内存也会同比增大。一个高并发服务如果有200个业务线程每个线程多缓存4000个小块那就是百万级别的对象潜在堆积。合理做法是先用默认参数跑通过监控观察chunk数量变化、DirectMemory占用曲线和分配性能指标再针对瓶颈做微调而不是一上来就拍脑袋开大参数。写到这里Netty内存池的骨架已经完整了。它在竞态控制上用线程缓存做第一道屏障在内存组织上借jemalloc的arena、size class、slab思想在页管理上又实现了高效的伙伴系统层层设计都指向同一个目标让高频分配释放的成本最小化。我自己带团队排查Netty内存问题时最深刻的体会是与其死记那些配置项不如顺着ByteBuf从分配、使用到释放的完整生命线走一遍代码很多疑惑会自然解开。后面如果大家有兴趣我可以再写一篇关于堆外内存泄漏定位的实操复盘把详细线程转储分析和bytebuf泄漏采样方法拆开讲。

看完文章,想为自己的企业也做一次专业网站诊断?

尧图顾问免费为您评估现有网站,并给出建站/改版建议与报价方案。

免费获取方案