可做到热路径上小对象完全不进GC,前提是正确使用Recycler:对象生命周期严格绑定线程、不逃逸、get/recycle成对且同线程调用,newObject()无堆分配,reset清理状态,避免跨线程回收导致WeakOrderQueue延迟回池。

做不到“零 GC 压力释放”,但可以做到**热路径上小对象完全不进 GC**——前提是正确使用 Recycler,且对象生命周期严格绑定线程、不逃逸、不跨线程传递引用。
Recycler 的 get() 和 recycle() 必须成对调用
这是最常被忽略的前提。Netty 的对象池不是“自动管理”的缓存,而是显式回收模型:每次 get() 拿到的对象,必须在同一线程内显式调用 handle.recycle(this);漏掉一次,对象就永远卡在当前线程的 Stack 里(不会泄漏,但池容量被无效占用);多调一次,会触发 IllegalStateException: recycled multiple times。
- 回收必须发生在**同一对象实例的同一方法内**,不能靠 try-finally 包裹就万事大吉——如果对象被传给下游异步回调,回调里再回收,就属于跨线程回收,会走
WeakOrderQueue路径,延迟回池,且有概率丢弃(当目标线程栈满或弱引用被回收时) -
recycle()内部会重置对象字段(靠你覆写newObject()时传入的Handle),所以不要在recycle()后继续读写该对象 - 典型错误:在 Netty
ChannelHandler的channelRead()中get()一个对象,却在writeAndFlush()的ChannelFutureListener里回收 —— 这个 listener 可能在任意 IO 线程执行
对象构造逻辑必须无副作用且可重入
Recycler 复用对象的本质,是把对象当成“状态容器”反复 reset 使用。如果你的 newObject(Handle) 方法里做了 new HashMap()、new byte[1024] 或任何堆分配,那这个对象本身就不适合池化——它只是把 GC 压力从“对象创建”转移到了“对象内部字段创建”。
- 字段必须是基本类型或可安全 reset 的引用类型(比如用
StringBuilder.setLength(0)替代new StringBuilder()) - 禁止在构造函数或
newObject()中启动线程、注册监听器、持有外部Channel引用等长生命周期行为 - 推荐模式:所有可变状态都通过
reset()方法统一清理,且recycle()最后一步必须调用它
避免 WeakOrderQueue 成为性能瓶颈
当对象被异线程回收(比如 worker 线程处理完,想还给 boss 线程的池),Netty 会把它塞进目标线程的 WeakOrderQueue。这个队列靠弱引用 + 单向链表实现,虽避免了锁,但有隐含成本:
- 每 8 次异线程回收,才触发一次
transfer()把对象批量移回目标线程Stack,中间存在延迟 - 若目标线程长期空闲(比如 boss 线程只做 accept),
WeakOrderQueue中的对象可能一直滞留,直到下一次该线程执行get()时才被扫描转移 - 极端情况下(如大量跨线程回收 + 目标线程低频活动),池命中率骤降,
get()频繁 fallback 到新建对象,GC 压力回升
所以高吞吐场景下,应默认假设“对象只在创建它的线程内流转”。例如:所有解码后的业务对象,在 ChannelHandler 的 IO 线程内完成解析、组装、投递,并在同一线程内回收;不要试图把它扔进业务线程池再回收。
池大小和线程局部性直接影响 GC 表现
Recycler 默认每个线程 Stack 初始容量是 4KB(约 256 个对象,取决于对象大小),上限由 maxCapacityPerThread 控制(默认 4096)。但这不是越大越好:
- 过大会导致单个线程长时间持有大量对象,内存驻留时间拉长,反而增加老年代压力(尤其当对象里有缓存字段时)
- 过小会导致频繁新建,尤其在流量毛刺期,瞬时对象需求超过池容量,就会触发 new + GC
- 真实压测中建议:按 P999 单线程每秒对象峰值 × 10ms(即 10ms 内需复用的对象数)估算初始容量,再根据 GC 日志中
ParNew的GC count和pause time反复调优
真正决定是否“零 GC”的,不是池有多大,而是你能否让 99.9% 的 get() 都命中本地 Stack —— 这要求业务逻辑与 Netty 线程模型深度对齐,而不是把 Recycler 当作万能 GC 缓冲垫。

















