做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请求按大小归为三类:
- Tiny:16字节起步,按16字节递增到496字节。
- Small:512字节开始,按2的幂翻倍到4096字节。
- Normal:8192字节及更大,以页为单位分配。
每个等级内存块都被组织成"一块大内存切分成若干等大小小块"的形式,这就是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%)、q050(25%-50%)、q075(50%-75%)、q100(接近满)。
为什么要分这么多梯队?核心原因:当内存分配失败时,Netty要在最快的路径上找到一个合适的chunk;当内存释放时,也要根据chunk使用率的变化把它挪到合适的列表里,保持整个内存池的健康度。如果所有chunk都在一个大List里,每次查找都要遍历大量chunk,而且满载的chunk和空chunk混在一起,回收效率会极其糟糕。
一个PoolArena内部本身还有一把锁。因为多线程可能同时落到同一个Arena上分配,所以Arena层不是无锁的。但配合线程缓存,锁冲突已经减到了很小。这个设计在Netty高并发场景下是经得起考验的。
3.2 PoolChunk:16MB大块内存与伙伴算法
PoolChunk是实际持有内存的地方。默认配置下,Netty的页大小是8KB,最大order是11,也就是说一个PoolChunk管的是一块8192 << 11 = 16MB的连续内存。
这块连续内存被设计成一棵深度为11的满二叉树。树叶是2048个页(每个8KB),每一层对应不同大小的连续内存块。例如深度1的节点对应8MB的连续块,深度2对应4MB,依此类推。
当你需要分配一块几倍于page的内存时,例如16KB,Netty会从根节点开始寻找一个足够容纳两块页的节点,把它标记为已用,然后从该节点返回可用的偏移量。释放的时候,如果这个节点和它的兄弟节点都空闲了,就合并回父节点,形成更大的连续块。
这就是经典的伙伴系统(buddy system)。它的优势在于分配和合并逻辑可以用位运算和状态更新快速完成,不需要维护复杂的大链表。Netty在这里管的东西叫"大块内存"(normal及以上),细分到页内的小块由PoolSubpage负责。
3.3 PoolSubpage:page内部的字节级精细化
如果一个请求只有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都找不到合适的chunk,Netty就会新建一个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中。如果你分配了2MB,chunk的使用率就从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线程持有引用但忘记release,B线程又拿不到引用,这块内存就会一直被占用,直到池子的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.type | pooled | 指定分配器类型,改成unpooled可关闭池化,回到性能更差的模式 |
| io.netty.allocator.numHeapArenas | 2 x CPU核数 | 堆内PoolArena数量 |
| io.netty.allocator.numDirectArenas | 2 x CPU核数 | 堆外PoolArena数量 |
| io.netty.allocator.pageSize | 8192 | 页大小,调整后Chunk大小也联动变化 |
| io.netty.allocator.maxOrder | 11 | 二叉树深度,默认11对应Chunk 16MB |
| io.netty.allocator.tinyCacheSize | 512 | 每个线程tiny规格缓存条数 |
| io.netty.allocator.smallCacheSize | 256 | 每个线程small规格缓存条数 |
| io.netty.allocator.normalCacheSize | 64 | 每个线程normal规格缓存条数 |
| io.netty.allocator.maxCachedBufferCapacity | 32768 | 超过该容量的ByteBuf不再进入线程缓存 |
| io.netty.allocator.cacheTrimInterval | 8192 | 线程缓存定期修剪的分配次数 |
| io.netty.allocator.useCacheForAllThreads | false | 是否让非EventLoop线程也使用线程缓存 |
| io.netty.allocator.leakDetectionLevel | SIMPLE | 泄漏检测级别 |
顺便补充一个容易踩坑的点:如果你在JVM启动时配置了-XX:MaxDirectMemorySize=512m,这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泄漏采样方法拆开讲。