一个 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. 自测
discardReadBytes()与clear()在内存动作上的本质区别是什么?Unpooled.wrappedBuffer(byte[])的隐含契约是什么,违反后会出现什么症状?SimpleChannelInboundHandler与ChannelInboundHandlerAdapter在释放责任上的差别在哪?- 想让 HTTP header 与 body 不复制地拼成一个消息,用哪个类型、哪个方法?
shutdownGracefully的quietPeriod和timeout分别控制什么?- 为什么说给
FileRegion套上ChunkedWriteHandler会消灭零拷贝?