开篇讲清了 Reactor 与 EventLoop——数据在 EventLoop 上流转,但数据本身装在哪儿?答案是 ByteBuf。竞价 Server 每秒要分配、读写、释放几十万个缓冲区,内存模型没吃透,轻则 GC 抖动、重则堆外内存泄漏把整个进程打挂。
ByteBuf 用「双指针 + 池化 + 引用计数」把高频分配堆外内存做到近乎免费,代价是你得自己算清 release 的所有权。
TL;DR
- ByteBuf 取代
java.nio.ByteBuffer:后者只有一个position+flip()在读写间切换,忘一步就读到脏数据;ByteBuf 用readerIndex/writerIndex两个独立指针,读写互不干扰、无需 flip。 - 三段式布局:
[0, readerIndex)已读可丢弃、[readerIndex, writerIndex)可读内容区、[writerIndex, capacity)可写空闲区;read*只推 readerIndex,write*只推 writerIndex。 - 堆内 vs 堆外:heap buffer 受 GC 管、分配快但 socket 读写要多一次到堆外的拷贝;direct buffer 少一次拷贝、适合 IO,但分配/回收贵、不受 GC 直接管理——这正是需要池化 + 引用计数的根源。
- 池化
PooledByteBufAllocator:借鉴 jemalloc,Arena → Chunk(16MB) → Page(8KB) → SubPage分层,按 small/normal/huge 分级;多 Arena 摊开锁竞争,PoolThreadCache做线程本地缓存走无锁快路径。Netty 4.1 起默认池化。 - 引用计数
refCnt:retain()+1、release()-1,减到 0 立即归还内存(而非等 GC)。因为池化/堆外内存不受 GC 管,必须手动计数。 - 谁最后
release:核心是所有权规则——SimpleChannelInboundHandler自动帮你释放入站消息;出站write后由 Netty 释放;自己retain/slice/copy出来的,自己负责。 - 内存泄漏检测:
ResourceLeakDetector四档(DISABLED/SIMPLE/ADVANCED/PARANOID),排查泄漏时开paranoid定位到具体分配点。 - 零拷贝三板斧:
CompositeByteBuf(逻辑合并、不拷贝)、slice()/duplicate()(共享底层内存、独立指针)、FileRegion + transferTo()(内核sendfile发文件,全程不进用户态)。 - AdTech 落地:竞价 Server 用池化 direct buffer 扛高频编解码;一旦某条 handler 分支漏
release,池化堆外内存会缓慢泄漏,几小时后 Direct OOM 把进程打挂——本篇 war story 就是这个坑。
Table of contents
Open Table of contents
1. 为什么不用 java.nio.ByteBuffer
JDK 原生的 ByteBuffer 有几个让人反复踩坑的设计:
- 读写共用一个
position:写完切读之前必须flip(),读完切写之前要clear()或compact()。少调一次flip,读出来的就是脏数据或空数据——这是新手最常见的 NIO bug。 - 容量固定、扩容麻烦:
ByteBuffer不能自动扩容,写满了要自己分配更大的再拷过去。 - 没有池化:每次
allocateDirect都是一次昂贵的堆外分配 + 后续要靠Cleaner/GC 回收,高频场景下开销和不可控延迟都很痛。 - API 贫瘤:没有引用计数、没有 slice 语义上的清晰所有权、没有复合缓冲。
Netty 的 ByteBuf 把这些全重做了:双指针免 flip、可动态扩容、可池化、带引用计数、支持零拷贝复合。对一个每秒分配几十万缓冲区的竞价 Server 来说,这不是「更好用」,而是「能不能扛住」的分水岭。
2. 三段式双指针:readerIndex / writerIndex / capacity
ByteBuf 用两个独立指针把一段内存切成三块:
![ByteBuf 三段式双指针布局:[0, readerIndex) 为已读可丢弃区、[readerIndex, writerIndex) 为可读内容区、writerIndex, capacity) 为可写空闲区;read* 只推 readerIndex、write* 只推 writerIndex,读写互不干扰,无需 flip()
两个独立指针把内存切成「已读丢弃 / 可读内容 / 可写空闲」三段;读只推 readerIndex、写只推 writerIndex,彻底告别 flip()。
[0, readerIndex):discardable bytes,已经读过、可以丢弃回收。[readerIndex, writerIndex):readable bytes,真正的内容区,readByte()等从这里取。[writerIndex, capacity):writable bytes,空闲区,writeByte()等往这里写。
关键操作:
ByteBuf buf = ctx.alloc().buffer(); // 池化分配(默认 direct)
buf.writeInt(0xCAFEBABE); // writerIndex += 4
buf.writeBytes(payload); // 继续写,只动 writerIndex
int magic = buf.readInt(); // readerIndex += 4,writerIndex 不动
// 读写从不互相干扰,无需 flip()
buf.markReaderIndex(); // 记录读位置
int type = buf.readByte();
buf.resetReaderIndex(); // 回退重读(解码半包时常用)
buf.discardReadBytes(); // 回收 [0, readerIndex),腾出空间
ByteBuf 还能自动扩容:写超过 capacity 时会在 maxCapacity 范围内自动扩展,省掉手动分配大 buffer 再拷贝的麻烦。
3. 堆内 vs 堆外 ByteBuf
ByteBuf 有两种底层内存:
堆内 heap(HEAP) | 堆外 direct(DIRECT) | |
|---|---|---|
| 底层 | byte[],在 JVM 堆 | 堆外内存(Unsafe/DirectByteBuffer) |
| GC | 受 GC 管理 | 不受 GC 直接回收,靠引用计数/Cleaner |
| Socket IO | 内核读写要多一次堆→堆外拷贝 | 直达内核,少一次拷贝 |
| 分配/回收 | 快 | 贵 |
| 访问数据 | array() 直接拿数组,快 | 逐字节走 Unsafe,稍慢 |
结论:IO 密集(socket 读写)用 direct 更划算(省一次拷贝),但 direct 分配贵、不受 GC 管——这两点正是 Netty 要引入池化(摊平分配成本)和引用计数(不靠 GC 也能及时回收)的根本原因。业务处理时若要频繁按数组随机访问,heap buffer 反而更方便。
关于堆外内存与 GC 的取舍,也可参考本地缓存深挖里堆内/堆外的讨论,以及 JVM 垃圾回收——堆外内存的坑在于它绕开了你熟悉的 GC 心智模型。
4. 池化:PooledByteBufAllocator 与 jemalloc 思想
如果每次 IO 都 allocateDirect 一块新堆外内存、用完再回收,高频下开销惊人。Netty 的解法是池化:把内存块预先切好放进池子,分配即取、释放即还。
借鉴 jemalloc 的分层:Arena 削锁、Chunk/Page/SubPage 分级切分、PoolThreadCache 走无锁快路径,把「高频分配堆外内存」摊到近乎免费。
分层结构:
- PoolArena:分配的入口,默认约
2 × CPU 核数个。多个 Arena 让不同线程落到不同 Arena,把锁竞争摊开。 - PoolChunk:向系统申请的大块内存,默认 16MB,内部按伙伴算法(buddy)切分。
- Page:Chunk 里的最小整分配单位,默认 8KB。
- SubPage:小于一个 Page 的请求,把 Page 再切成等长小块。
- PoolThreadCache:线程本地缓存。刚
release的块缓存在当前线程,下次同规格分配走无锁快路径,这是池化性能的关键。
按请求大小分级:
- small:< 8KB,从 SubPage 切;
- normal:8KB ~ 16MB,占整数个 Page;
- huge:> 16MB,太大不入池,直接堆外分配。
Netty 4.1 起 PooledByteBufAllocator 是默认分配器。可通过 -Dio.netty.allocator.type=pooled|unpooled 切换,或在 ServerBootstrap 上用 .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) 显式指定。
5. 引用计数:retain / release 与所有权
因为池化 + 堆外内存不受 GC 直接回收,Netty 用引用计数决定何时把内存归还池子:
- 新分配的 ByteBuf
refCnt = 1; retain()使refCnt + 1(我还要用,别释放);release()使refCnt - 1,减到 0 立即归还内存(而不是等 GC);- 对
refCnt = 0的 buffer 再操作会抛IllegalReferenceCountException。
ByteBuf buf = ctx.alloc().buffer(); // refCnt = 1
buf.retain(); // refCnt = 2
buf.release(); // refCnt = 1
buf.release(); // refCnt = 0 → 内存归还池子
// buf.readByte(); // 抛 IllegalReferenceCountException
所有权规则(谁最后用完谁 release):
- 入站:普通
channelRead(ctx, msg)里,你负责 release 接收到的 ByteBuf;但继承SimpleChannelInboundHandler<T>时,它在channelRead0返回后自动 release,省心(但别把消息传给后续 handler 后还指望它可用)。 - 出站:
ctx.write(msg)之后,Netty 把数据写入 socket 后自动 release,你不用管。 - 自己
retain()/slice()/copy()出来的:所有权归你,用完自己 release。 - 传给下一个 handler:调用
ctx.fireChannelRead(msg)后,释放责任移交给后续 handler。
6. 零拷贝:CompositeByteBuf、slice 与 FileRegion
「零拷贝」在 Netty 里主要指避免用户态内存之间的多余拷贝:
传统合并要新分配 + 两次 memcpy;CompositeByteBuf 只多一层索引、底层字节原地不动,高频大包时省下的就是纯 CPU 与延迟。
三种手段:
CompositeByteBuf:把多段(如 header + body)逻辑合并成一个连续 ByteBuf,物理上只持有各段引用、不拷贝。读跨组件时按偏移路由到底层。ctx.alloc().compositeBuffer().addComponents(true, header, body)。slice()/duplicate():与原 ByteBuf 共享底层内存,只是各自维护独立的读写指针。切一段出来处理而不复制数据。Unpooled.wrappedBuffer(...):包裹已有的byte[]/ ByteBuf 而不拷贝。FileRegion+transferTo():文件经内核sendfile直达 socket,数据全程不进用户态,省掉「内核→用户→内核」两次拷贝——发静态资源 / 大响应体时特别值。
⚠️ 共享底层意味着引用计数也共享:slice() 出来的和原 buffer 指向同一块内存,release 责任要算清(见 §7),否则要么提前释放导致 use-after-free,要么漏释放导致泄漏。
7. 实践红线:谁最后 release
内存泄漏几乎都源于 release 责任没算清。几条红线:
- 入站优先用
SimpleChannelInboundHandler:自动释放,避免手滑漏 release。若必须用原始channelRead,finally { ReferenceCountUtil.release(msg); }兜底。 slice()/retain()后所有权归你:retainedSlice()会顺带 +1,责任更清晰。- 异常路径也要 release:解码抛异常时别让已分配的 buffer 悬空。
- 别 release 后再用:包括别把 release 后的 buffer 传给异步任务。
- 开泄漏检测跑压测:上线前用
-Dio.netty.leakDetection.level=paranoid跑一轮,ResourceLeakDetector会在 GC 到未 release 的 buffer 时打出分配栈,直接定位到漏 release 的代码。
ResourceLeakDetector 四档:DISABLED(关)、SIMPLE(默认,1% 采样)、ADVANCED(1% 采样 + 记录访问轨迹)、PARANOID(每个 buffer 都查,压测/排查用,生产别开)。
延伸阅读
- Netty 深挖 · Pipeline、编解码与粘包拆包
- Netty 深挖(开篇)· Reactor 与 EventLoop
- JVM 垃圾回收
- Netty 官方文档 · Reference counted objects
- Netty 官方文档 · Using as a generic library(ByteBuf)
- Norman Maurer, Marvin Wolfthal · Netty in Action
- jemalloc · A Scalable Concurrent malloc(3) Implementation