主题
ByteBuf 与零拷贝
一句话:ByteBuf 用 readerIndex/writerIndex 双指针把「读」和「写」解耦,用池化和引用计数把分配/释放成本压下来;代价是释放责任转移给了使用方。
适用版本:Netty 4.1.x。池化参数是 4.1 的默认值,版本间可能调整,以官方源码为准。
为什么需要
JDK ByteBuffer 只有一个 position,读写切换要 flip()/clear()。写了一半想读、读了一半想写,边界很容易搞乱;容量固定,扩容要自己复制;没有池化,频繁分配会推高 GC。
ByteBuf 解决的就是这三件事:读写指针分离、自动扩容、池化 + 引用计数。
核心机制
双指针结构
ByteBuf 是一块连续内存,两个指针把读和写切成三段互不干扰的区域:
readerIndex:下一个要读的位置;readXxx()后前移。writerIndex:下一个要写的位置;writeXxx()后前移。- 可读 =
writerIndex - readerIndex;可写 =capacity - writerIndex。 readerIndex ≤ writerIndex ≤ capacity。
读写互不影响,所以不需要 flip。discardReadBytes() 可以把已读区域回收,但会把未读数据搬到开头,是一次内存拷贝;只在写空间不足时用。
堆内与堆外
| 类型 | 内存位置 | 优点 | 代价 |
|---|---|---|---|
| HeapByteBuf | JVM 堆 | 分配快、GC 可见、调试方便 | 写 Socket 时通常要复制到堆外 |
| DirectByteBuf | 堆外(native) | 网络 IO 少一次复制 | 分配/释放更贵;计入 MaxDirectMemorySize 和进程 RSS |
Netty 默认对 IO 使用 DirectByteBuf(-Dio.netty.noPreferDirect=true 可改为堆内)。堆外内存不受 -Xmx 限制,但受 -XX:MaxDirectMemorySize 限制,其默认值等于最大堆;长连接服务通常要显式设置。
平台差异:
- Linux NIO 写 Socket 时,堆内 Buffer 需要先复制到堆外;Direct 可直接写。
FileRegion依赖FileChannel.transferTo(),只在 NIO transport 下有效,且不能与 SSL 一起用(SSL 需要读到明文)。- Windows 的
transferTo实现与 Linux 不同,文件传输性能不能直接套用 Linux 结论。 - 使用 native epoll 时,Direct Buffer 是常态;跨平台行为以 JDK/Netty 对应版本为准。
池化分配器
Netty 4.1 默认 PooledByteBufAllocator,结构借鉴 jemalloc:
Arena → Chunk → Page → SubPage4.1 的默认值:
pageSize=8192(8KiB)maxOrder=11→chunkSize = 8KiB << 11 = 16MiB- Arena 数默认
2 × CPU 核数 - 每线程缓存:tiny 512、small 256、normal 64(按规格缓存)
分配时先走线程本地缓存(PoolThreadCache),没有再向 Arena 申请,减少多线程竞争。池化的收益是减少 malloc/free 和 GC;代价是 RSS 常驻更高、碎片和泄漏更难发现。是否更快取决于分配大小和线程模型,不要引用没有测试条件的百分比。
零拷贝的几种形态
这里的「零拷贝」是用户态减少复制,不等于所有路径都没有 CPU 拷贝:
| 方式 | 做法 | 注意 |
|---|---|---|
CompositeByteBuf | 多个 ByteBuf 逻辑组合,不合并内存 | 底层 gather write 依赖 writev;组件释放要 release |
slice()/duplicate() | 共享同一块内存的视图 | 不增加引用计数;retainedSlice() 才会 retain |
Unpooled.wrappedBuffer() | 包装已有 byte[]/ByteBuffer | 不复制,但原数组生命周期要自己保证 |
FileRegion | transferTo 文件直发网卡 | 仅 NIO、不能配 SSL |
| Direct Buffer | 避免堆内→堆外复制 | 受直接内存上限约束 |
CompositeByteBuf 适合「header + body 分开编码」:逻辑上是一个 Buffer,写时用 gather write 一次发出,不需要先拼成一个大数组。
引用计数与释放责任
java
ByteBuf buf = allocator.buffer(); // refCnt=1
buf.retain(); // refCnt=2
buf.release(); // refCnt=1
buf.release(); // refCnt=0 → 归还池释放规则:
- 入站消息走到 Tail 仍未释放,Tail 兜底释放。
- 出站消息写到 Head 后由 Head 释放。
- 在 Handler 里把 ByteBuf 存到异步队列/缓存,必须
retain();消费完release()。 SimpleChannelInboundHandler自动释放;ChannelInboundHandlerAdapter需要自己处理。slice()不增加引用计数;父 Buffer 释放后,slice 不能再用。
实战场景
一、堆 RSS 只涨不降,Pod 反复 OOMKilled
现象:长连接推送服务运行 6 小时后被容器 OOM kill;JVM 堆监控全程健康(-Xmx 2G,实际用 1.2G),GC 正常。kubectl describe pod 显示 Reason: OOMKilled,Limits: memory=3Gi。
排查:堆外泄漏不会先体现在堆 Dump 上,判据是 RSS 与堆的差额。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 差额有多大 | pmap -x <pid> 或 RSS 曲线 vs -Xmx | RSS − 堆 − 元空间 − 线程栈 ≈ 堆外,持续涨即可疑 |
| Netty 自己知道多少 | PooledByteBufAllocator.DEFAULT.metric().usedDirectMemory() 定时打点 | 指标与 RSS 同步涨 → Netty 分配未归还 |
| 指标不涨但 RSS 涨 | 同上对比 | 多为 Unpooled 分配、JNI 库或 native epoll 缓存 |
| 泄漏点 | 预发开 -Dio.netty.leakDetection.level=PARANOID 复现,看 LEAK: 栈 | SIMPLE 只采样,抓不全;PARANOID 开销随负载变化 |
| 常见写法 | 搜把 ByteBuf 放进队列/缓存的代码,检查是否 retain() | 入队未 retain、消费后 release,等于提前归还 |
本例根因是转发队列:channelRead 里把入站 ByteBuf 直接放进 LinkedBlockingQueue,业务线程消费后 release。入站消息在 Handler 返回时已被 Tail 兜底释放,业务线程拿到的是已归还池化的 Buffer——表现是内存没省、数据还错。
对症做法:
java
// 1. 跨线程传递必须显式增加引用计数,并由消费者释放
queue.put(msg.retain()); // 生产者 retain
...
try { handle(buf); } finally { buf.release(); } // 消费者唯一释放点
// 2. 或者在进入队列时就转成不依赖计数的形式(小消息更简单可靠)
byte[] copy = ByteBufUtil.getBytes(msg); // 复制一次,换掉整条生命周期约束
// 3. 内存预算按层写清,容器 limit 必须覆盖堆外
-Xmx2g -XX:MaxDirectMemorySize=768m -XX:MaxMetaspaceSize=256m
# 容器 limit 3Gi:堆 2G + 堆外 768M + 元空间/栈/预留,留出余量yaml
# 4. 用指标挡住回归,而不是等 OOM
- record: netty_direct_ratio
expr: netty_used_direct_memory / process_resident_memory_bytes
# 告警:RSS 与 usedDirectMemory 同向持续增长 30 分钟判定标准要可执行:生产者 retain、消费者 release,二者数量必须相等;把“入队 retain / 出队 release”写成一对单元测试,用 buf.refCnt() 断言归零。
复盘要点:池化会掩盖泄漏——Buffer 归还池后 RSS 不下降,所以“内存没降”不能作为证据,引用计数和 PARANOID 检测才是。跨线程传 ByteBuf 是小成本高风险的写法,团队约定应是:协议对象进队列(已拷贝成 POJO),原始 Buffer 只在 IO 线程内活完。
二、slice() 之后读到脏数据
现象:协议解析偶发解出错误的长度字段,重传后正常;没有任何异常,refCnt 检查也过。故障只在 body 被拆成多个 ByteBuf 且经过异步队列时出现。
排查:视图类操作不复制内存,也不增加引用计数。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 用的是哪种视图 | 代码里是 slice() / asReadOnly() / retainedSlice() | 前三者不 retain,父 Buffer 释放即失效 |
| 父 Buffer 何时释放 | 在视图使用点打印 parent.refCnt() | 为 0 → 视图指向的已是池内可复用内存 |
| 是否跨线程 | 视图与父 Buffer 是否在不同线程使用 | 池化内存被别的线程分配到同一 chunk,就会写脏 |
| Composite 释放 | CompositeByteBuf.release() 会释放所有组件 | 组件再单独 release 就是重复释放 |
| 包装而非复制 | Unpooled.wrappedBuffer(byte[]) | 不复制,原数组被业务改写就影响 Buffer |
slice() 的失效不会抛异常,它读到的是归还池后被别人写过的内存——这是最难发现的一类 bug,因为表现为随机数据错乱。
对症做法:
java
// 1. 视图需要独立生命周期时,用 retained* 系列
ByteBuf frame = total.retainedSlice(total.readerIndex(), len); // 引用计数 +1,自己负责 release
// 2. 只读消费且不想管计数时,转成拷贝
ByteBuf detached = total.copy(total.readerIndex(), len);
// 3. 边界规则:视图只在当前方法内有效,出了 Handler 一律 retain 或 copy
// 断言测试直接看引用计数
@Test void retainedSliceKeepsParentAlive() {
ByteBuf b = Unpooled.buffer().writeInt(7).writeInt(8);
ByteBuf v = b.retainedSlice(0, 4);
b.release();
assertThat(b.refCnt()).isEqualTo(1); // 视图还持有一份
assertThat(v.getInt(0)).isEqualTo(7);
v.release();
assertThat(b.refCnt()).isZero();
}discardReadBytes() 是另一类“看起来省内存、实际搬数据”的写法:它把未读区复制到开头,是一次内存拷贝,只在写空间确实不足时使用,不要放进每次读回调。
复盘要点:把 ByteBuf 的生命周期规则压到三条——谁最后访问谁释放;跨线程/跨方法要 retain 或 copy;视图不延长生命周期。refCnt() 断言可以机械化检查这三条,值得进每个协议解析模块的测试。
三、上了 HTTPS,文件下载 CPU 翻三倍
现象:对象存储网关把文件下载从 HTTP 换成 HTTPS 后,同样带宽下单机 CPU 从 20% 涨到 75%,吞吐下降一半;FileRegion 的代码一行没改。
排查:FileRegion 依赖 FileChannel.transferTo(),在 SSL 下不可用。
| 定位层 | 取证据 | 判定 |
|---|---|---|
链上是否有 SslHandler | 打印 pipeline.names() | 有 SSL 时 FileRegion 必须读到明文,sendfile 路径失效 |
| 是否退化成普通读 | CPU profile 里出现 SSLEngine.wrap、FileChannel.read | 数据被读进用户态加密再写出,两次复制 |
| transport | native epoll / NIO;Windows 的 transferTo 实现与 Linux 不同 | 平台结论不能直接套用 |
| 加解密在哪 | SslHandler 的握手与记录保护都跑在 EventLoop 上 | 大流量下 IO 线程被加解密占满 |
对症做法:
java
// 1. SSL 路径改成分块读 + Direct Buffer,控制单次驻留内存
final int CHUNK = 64 * 1024;
ByteBuf buf = ctx.alloc().directBuffer(CHUNK); // Direct:省掉堆内到堆外的复制
try (var fc = FileChannel.open(path, READ)) {
long pos = 0, size = Files.size(path);
while (pos < size) {
int n = (int) Math.min(CHUNK, size - pos);
buf.clear();
fc.read(buf.nioBuffer(0, n), pos);
buf.writerIndex(n);
ctx.writeAndFlush(buf.retainedSlice(0, n), ctx.voidPromise()); // 写完由 Head 释放
pos += n;
}
}
// 2. 大文件与静态内容优先在接入层卸载 TLS,后端走明文 HTTP/2 回源
// 3. CPU 仍是瓶颈时评估硬件加速或降低并发下载数,把带宽让给小文件ctx.voidPromise() 在这里有用:不需要回调的写不分配 Promise 对象;但错误必须靠 exceptionCaught 兜住,否则失败静默。
复盘要点:FileRegion 的收益只在“无 SSL、NIO、Linux”这组前提下成立,任何前提变化都要重新压测,不能沿用旧结论。这类退化不会被功能测试发现,只有 CPU/带宽比(每 GB 流量的 CPU 秒)这类效率指标能提前暴露;把它加进容量评估,见 Netty 生产踩坑。
最小上手
java
ByteBuf buf = Unpooled.buffer(8);
buf.writeInt(42);
System.out.println(buf.readerIndex() + "/" + buf.writerIndex()); // 0/4
int v = buf.readInt(); // readerIndex=4
System.out.println(v); // 42
ByteBuf view = buf.slice(0, 4); // 共享内存,不复制
System.out.println(view.getInt(0)); // 42;view 不增加引用计数验证池化与直接内存:
java
PooledByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
System.out.println(alloc.metric().usedDirectMemory());
System.out.println(alloc.metric().usedHeapMemory());运行参数:
bash
-Dio.netty.leakDetection.level=SIMPLE # 默认;PARANOID 仅预发
-XX:MaxDirectMemorySize=512m # 默认等于 -Xmx,通常不够生产动作
- 显式设置直接内存上限:
-XX:MaxDirectMemorySize;容器里还要看 cgroup 限制,RSS 超限会被 OOM kill。 - 泄漏检测:线上
SIMPLE,预发PARANOID;PARANOID有明显性能开销,具体比例随负载变化,不要当成固定 5%。 - 池化开关:默认池化;内存极敏感、连接短命的场景可评估
UnpooledByteBufAllocator,但要先压测。 - 不要频繁
discardReadBytes:它会搬移未读数据;只在写空间不足时用。 - Composite 的释放:
CompositeByteBuf.release()会释放所有组件;不要重复释放组件。 - 监控:
usedDirectMemory、usedHeapMemory、进程 RSS、Full GC 频率;RSS 只涨不降通常先怀疑泄漏。 - 排障:
OutOfDirectMemoryError→ 看 RSS 与usedDirectMemory→ 预发开PARANOID复现 → 按LEAK:栈定位忘记release或异步队列漏retain。
边界
- 零拷贝不是万能的:
CompositeByteBuf组件太多会增加遍历和释放成本;小消息直接合并可能更快。 FileRegion限制多:仅 NIO、不能 SSL、不能经过需要读数据的 Handler;文件传输要评估。- 堆外内存不归 GC 管:泄漏表现为 RSS 上涨,不会先体现在堆 Dump 上。
- 池化会掩盖泄漏:Buffer 归还池后内存不下降,泄漏更难通过「内存是否释放」判断,要靠引用计数和泄漏检测。
- 跨平台差异:sendfile、epoll、直接内存释放的实现不同,性能结论要标注平台。
讲清楚
ByteBuf 的核心是双指针:readerIndex 和 writerIndex 把读写解耦,不需要 flip;discardReadBytes 能回收已读空间,但会复制数据。堆内 Buffer 分配快,写 Socket 时常要复制到堆外;Direct Buffer 少一次复制,但受 MaxDirectMemorySize 和 RSS 限制。默认的池化分配器按 Arena/Chunk/Page 管理,4.1 默认 8KiB page、16MiB chunk、2×核数 Arena,收益是减少分配和 GC,代价是常驻内存更高、泄漏更难发现。零拷贝包括 CompositeByteBuf、slice/duplicate、FileRegion 和 Direct Buffer,各有平台和 SSL 限制。最后,ByteBuf 用引用计数管理生命周期,谁最后访问谁释放;把 Buffer 存到异步队列必须 retain。