2360 字
约 7 分钟
14
S02-X12 · Netty ByteBuf 与零拷贝:索引、引用计数与视图组合

一个 8KB 的响应体从 socket 读进来,要经过解码、拼装、写回三个环节。如果每一环都用 new byte[] 搬一次,这条链路凭空多出三次分配和三次整块拷贝,GC 压力与尾延迟一起被放大。ByteBuf 的做法是把"搬字节"换成"改指针加记账",把"整块复制"换成"多个视图指向同一段内存"。抓住三件事就够用:索引、引用计数、组合。


1. 三个索引:读写分离与 capacity 推导

ByteBuf 不是 byte[] 的包装,它自己维护 readerIndex、writerIndex、capacity 三个位置,读和写互不干扰,不需要像 InputStream 那样维护外部游标。

ByteBuf buf = Unpooled.buffer(16);
System.out.println(buf.readerIndex() + "/" + buf.writerIndex() + "/" + buf.capacity()); // 0/0/16
buf.writeBytes("hello".getBytes(StandardCharsets.UTF_8));
System.out.println(buf.readableBytes());      // 5
byte[] out = new byte[buf.readableBytes()];
buf.readBytes(out);                            // readerIndex 前移到 5
System.out.println(buf.isReadable());          // false
字段/方法 含义 越界时的行为
readerIndex 下一个待读位置 读到 writerIndex 之后抛 IndexOutOfBoundsException
writerIndex 下一个待写位置 写超 capacity 时先尝试扩容,超 maxCapacity 才抛异常
capacity 当前分配容量 由分配器决定,2 的幂增长
maxCapacity 容量上限 默认 Integer.MAX_VALUE,池化分配器下受 chunk 约束
readableBytes() writerIndex - readerIndex 用于判断可读长度
discardReadBytes() 把已读区间整体左移 会复制,频繁调用等于自己制造拷贝

discardReadBytes() 看似"回收空间",实际执行一次 System.arraycopy。正确做法是让 readerIndex 自己往前走,靠池化分配器在整块释放时回收。


2. 池化与非池化:Unpooled 的真实定位

分配器决定内存从哪来。池化分配器按 chunk/page 管理,申请和释放是 O(1) 的链表摘取,代价是内存不能立刻还给操作系统;非池化分配器每次走 ByteBuffer.allocate,简单但把压力交给 GC。

分配器 内存来源 申请成本 适用
PooledByteBufAllocator 预申请的 chunk(默认 16MB 量级) 极低,复用 服务端默认,高并发 IO
UnpooledByteBufAllocator 每次堆内/堆外新分配 高 短生命周期工具代码、测试
Unpooled 工具类 包装既有数组/ByteBuffer 零分配 适配旧 API、临时构造

Unpooled 常被误解成"非池化分配器",它其实是一组静态工厂:buffer() 走当前分配器,wrappedBuffer() 完全不分配。

byte[] payload = "GET /health HTTP/1.1\r\n".getBytes(StandardCharsets.US_ASCII);
ByteBuf wrapped = Unpooled.wrappedBuffer(payload);              // 共享底层数组,零拷贝
ByteBuf copied  = Unpooled.copiedBuffer(payload);               // 复制一份,独立生命周期
ByteBuf direct  = Unpooled.directBuffer(256);                   // 堆外,适合写 socket
ByteBuf empty   = Unpooled.EMPTY_BUFFER;                        // 单例,不要 write 进去

wrappedBuffer 的隐含契约是:被包装的数组在 ByteBuf 释放前不得修改。业务里"先 wrapper 再改原数组"是最常见的脏读来源,因为视图看到的是同一片内存。


3. 引用计数:谁负责 release

堆外内存不受 GC 管理,ByteBuf 用引用计数决定何时归还。规则只有一条:谁最后持有,谁负责释放;中间环节要用就 retain()。

ByteBuf buf = ctx.alloc().buffer();
try {
    buf.writeBytes(header);
    ByteBuf body = buf.retainedSlice(4, buf.readableBytes() - 4); // 共享内存,计数 +1
    try {
        handle(body);
    } finally {
        body.release();
    }
} finally {
    buf.release();   // 计数归零,归还 chunk
}
方法 语义 常见误用
retain() 计数 +1 忘了配对 release() → 泄漏
release() 计数 −1,归零即回收 同一个 buf 释放两次 → IllegalReferenceCountException
retainedSlice() 切片并 +1 当成 slice() 用,少一次 release()
slice() 切片不 +1,与父共享生命周期 把 slice 传给别的线程异步使用

Netty 的默认传播规则是"入站消息沿 pipeline 传递后由 tail 释放":SimpleChannelInboundHandler 会自动 release,而 ChannelInboundHandlerAdapter 不会。两类 handler 混用是最典型的泄漏源——业务里继承后者却按前者理解,消息既不释放也没往下传。

# 打开偏执级泄漏检测,命中时打印申请点栈
java -Dio.netty.leakDetection.level=paranoid -jar gateway.jar
检测级别 采样策略 开销 用途
disabled 不检测 0 明确关闭,生产不建议
simple 约 1% 采样 低 默认值
advanced 约 1% 采样,带最近访问点 中 定位"被谁用过"
paranoid 全量检测 高 只在压测/预发开

4. 零拷贝的四个层次

"零拷贝"在 Netty 里不是单一技术,而是四件不同的事,混着讲必然讲不清。

层次 手段 省掉的是 边界
视图层 slice / duplicate / retainedSlice 一次数组复制 共享生命周期,需引用计数配合
组合层 CompositeByteBuf / wrappedBuffer(a, b) 把多段拼成一段的复制 遍历有轻微索引开销
堆外层 DirectByteBuf JVM 堆到 native 的一次拷贝 分配贵,必须池化
系统调用层 FileRegion + sendfile 用户态缓冲区拷贝 仅文件传输,且平台需支持

CompositeByteBuf 是协议实现的主力:HTTP 的 header 和 body 来自不同缓冲区,拼起来不必复制,只在逻辑上是一个整体。

CompositeByteBuf msg = Unpooled.compositeBuffer(2);
msg.addComponent(true, headBuf);   // true = 增加 writerIndex
msg.addComponent(true, bodyBuf);   // 不复制数据
ctx.writeAndFlush(msg);            // 一次写出

底层 sendfile 的路径是 FileRegion 直写 FileChannel.transferTo,数据不经过 JVM 堆。判断是否真的走了零拷贝,看两处:Channel 是否为 NioSocketChannel(EpollChannel 走 sendfile 更稳),以及是否给 FileRegion 包了 ChunkedWriteHandler——后者会把整块读进内存再分片,直接消灭零拷贝。


5. EventLoop 注册与优雅关闭

ByteBuf 的读写并非线程安全,安全性来自"一个 Channel 绑定一个 EventLoop 线程"。跨线程写必须 writeAndFlush 而不是直接操作 buffer,因为 Netty 会把任务投递回所属线程。

EventLoopGroup boss = new NioEventLoopGroup(1);
EventLoopGroup worker = new NioEventLoopGroup();
ServerBootstrap b = new ServerBootstrap();
b.group(boss, worker)
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     @Override protected void initChannel(SocketChannel ch) {
         ch.pipeline().addLast(new LengthFieldBasedFrameDecoder(8192, 0, 4, 0, 4));
     }
 });

ChannelFuture f = b.bind(8080).sync();
Runtime.getRuntime().addShutdownHook(new Thread(() ->
    f.channel().close().syncUninterruptibly()));
f.channel().closeFuture().sync();
boss.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly();
worker.shutdownGracefully(0, 5, TimeUnit.SECONDS).syncUninterruptibly();

shutdownGracefully(quietPeriod, timeout, unit) 的两个参数含义不同:quietPeriod 是"多久没有新任务就开始关",timeout 是"最多等多久然后强制关"。只传一个参数(无参版本)会用到默认 2 秒/15 秒,停机窗口比预期长。

现象 根因 手法
停机后进程迟迟不退 shutdownGracefully 无参默认等待期长 显式传 quietPeriod、timeout
IllegalStateException: executor not terminated 只关了 worker 没关 boss 两个 group 都要关
关停时丢尾部请求 先关 group 再关 channel 顺序应为 channel → group
线程名前缀相同难定位 未自定义 DefaultThreadFactory 构造 group 时传工厂

6. 排错:六种高频故障

报错/现象 原因 处理
LEAK: ByteBuf.release() was not called handler 未释放,或跨线程持有未 retain 开 advanced 级别看申请栈,改 SimpleChannelInboundHandler
IllegalReferenceCountException: refCnt: 0 重复 release,或父 buf 已释放后使用 slice 检查切片是否 retainedSlice,父子只释放一次
读到的字节"莫名其妙变了" wrappedBuffer 包装的数组被业务改写 改用 copiedBuffer,或约定只读
IndexOutOfBoundsException: readerIndex 解码器读了超出可读区 先 readableBytes() 判断,配合 LengthFieldBasedFrameDecoder
堆外内存持续上涨、GC 不降 池化 chunk 未归还 看 PooledByteBufAllocator 的 chunkSize 与泄漏检测输出
大文件传输 CPU 高、内存涨 走了 ChunkedWriteHandler 全量读 改 FileRegion 或 DefaultFileRegion

排泄漏的顺序固定:先看泄漏日志里的申请栈定位到 ctx.alloc().buffer(),再对照 handler 类型判断释放责任,最后才怀疑业务缓存。


7. 边界:这些场景不该指望 ByteBuf

场景 更合适的做法 理由
单纯解析 JSON/Protobuf 直接用对应库的流式 API 自己切 buffer 只会引入边界 bug
线程池里做 CPU 密集处理 先把数据复制成 byte[] 再交出去 跨线程共享引用计数极易出错
小于几百字节的短连接 Unpooled + 普通阻塞 IO 池化收益小于复杂度
需要随机读写文件 FileChannel + MappedByteBuffer ByteBuf 是顺序读写模型
内存受限的嵌入式网关 直接 ByteBuffer 环形缓冲 池化 chunk 的 16MB 粒度太粗

ByteBuf 只解决"高频、顺序、可视图共享"的网络字节流,超出这个形状,引用计数就从保护变成负担。


8. 自测

  1. discardReadBytes() 与 clear() 在内存动作上的本质区别是什么?
  2. Unpooled.wrappedBuffer(byte[]) 的隐含契约是什么,违反后会出现什么症状?
  3. SimpleChannelInboundHandler 与 ChannelInboundHandlerAdapter 在释放责任上的差别在哪?
  4. 想让 HTTP header 与 body 不复制地拼成一个消息,用哪个类型、哪个方法?
  5. shutdownGracefully 的 quietPeriod 和 timeout 分别控制什么?
  6. 为什么说给 FileRegion 套上 ChunkedWriteHandler 会消灭零拷贝?
S02-X12 · Netty ByteBuf 与零拷贝:索引、引用计数与视图组合
https://www.clxhxhhr.top/posts/6026/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。