本节摘要:PooledByteBufAllocator 借鉴 jemalloc 的分级仓储思想:线程绑定的 Arena 切成 16MB 的 Chunk,Chunk 再切成 8KB 的 Page 与更细的 SubPage,用"规格化尺寸 + 位图"把分配从系统调用变成查表。本节拆解这条分配路径与关键参数。
没有池化时,每条消息都要向 JVM 或操作系统要内存:堆内靠 GC 事后回收,堆外靠 malloc 系列调用。消息量到每秒几十万时,两条路都很痛——GC 停顿随分配速率上涨,malloc/free 的系统调用与锁竞争吃掉吞吐。工程界的通用解法是内存池:一次批发,长期零售。Netty 4 的默认分配器 PooledByteBufAllocator 就是这个思路,设计蓝本是久经考验的 jemalloc。

四层结构各自的职责:
规格化是池化的灵魂:申请 33B 实际给 48B 档,浪费一点空间,换来"归还后能立刻被下一个同规格请求复用"。这是典型的空间换时间。
// 手动体验池化分配(服务端默认已是池化,这里为了看清路径) ByteBuf small = PooledByteBufAllocator.DEFAULT.directBuffer(64); // → subpage 小格 ByteBuf normal = PooledByteBufAllocator.DEFAULT.directBuffer(16 * 1024); // → 整页取整 ByteBuf huge = PooledByteBufAllocator.DEFAULT.directBuffer(32 * 1024 * 1024); // → 不进池,直接要 System.out.println(small.maxFastWritableBytes()); // 观察实际拿到的规格 ReferenceCountUtil.release(small); // 归还小格,立刻可复用
释放则原路返回:release 归零后,小格回抽屉、页段回二叉树、相邻空闲页自动合并。归还是池化生效的前提——这也是 4.1 那套账本纪律存在的根本原因。
常用开关集中在启动参数与分配器配置上:
-Dio.netty.allocator.numDirectArenas=8 堆外仓库区数量,默认核数 -Dio.netty.allocator.pageSize=8192 页大小,默认 8KB -Dio.netty.allocator.maxOrder=11 决定 chunk 大小(页 × 2 的 maxOrder 次方 = 16MB) -Dio.netty.allocator.smallCacheSize=256 线程本地小件缓存 -Dio.netty.allocator.useCacheForAllThreads=false 是否所有线程都享受线程本地缓存
验证池化是否生效,最直接的办法是看指标(第七章的仪表盘会讲全局统计),此处先给个轻量版:
PooledByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT; ByteBuf buf = alloc.directBuffer(256); System.out.println(alloc.metric().numDirectArenas()); // 堆外仓库区数量 buf.release();
⚠️ 常见坑:单元测试里频繁 new 服务器又不停机,池子越建越多,直观数据结构上看着像泄漏。测试环境可以
-Dio.netty.allocator.type=unpooled临时切非池化,排查时少一个变量;生产保持默认池化。
💡 关键直觉:池化把"每次申请内存"变成"从自家货架取货":线程认领仓库区(arena)免争抢,大货架(chunk)批量进货免系统调用,规格化小格(subpage)免碎片。代价只有一条——必须记账归还。
四级结构之外还有一层容易被漏看的加速器:PoolThreadCache。每个线程在仓库区之外自带一组小抽屉,release 归还的内存先入抽屉,下次同规格请求直接从抽屉里取,连仓库区的定位开销都省了;抽屉装满才真正归还货架,线程退出时抽屉整体上缴。前面参数清单里的 smallCacheSize、normalCacheSize 调的就是抽屉大小——读写频繁的推送类服务适当调大有可测收益。
理解抽屉之后,一个经典谜题也顺带解开:为什么流量平稳时 Netty 的堆外曲线几乎是平的?因为请求与归还在用户态的自家货架间循环,极少真正向操作系统批发内存。分配器指标里的 arena 命中率,说的就是这件事。