ByteBuf与编解码
1. 这是什么
学 Netty 时,很多人前一章刚建立起整体架构感,到了这一章又会卡住:
ByteBuf是什么?- 为什么不直接用 Java 的
ByteBuffer? - 编码器、解码器到底在做什么?
- 为什么总说“不要把编解码和业务逻辑混在一起”?
- 为什么引用计数会变成高频坑?
这篇就是把这些问题讲清楚。
如果只用一句话理解这一章,可以这样记:
ByteBuf 是 Netty 用来高效处理字节数据的核心容器,编解码则负责把“网络字节流”和“业务对象”互相转换。
你可以把它们看成两层:
ByteBuf:负责装和操作字节- 编解码器:负责把字节解释成业务消息,或者把业务消息编码成字节
2. 为什么重要
网络通信最终传的不是:
- Java 对象
- JSON 对象
- 业务实体类
而是:
- 一串字节
所以无论你上层写的是聊天消息、订单协议、RPC 请求还是心跳包,到了网络层都必须处理两个问题:
- 这些字节放在哪、怎么读写?
- 这些字节怎么翻译成业务对象?
这正是 ByteBuf 和编解码在解决的问题。
如果这层没理解好,后面你会反复踩这些坑:
- 粘包拆包看不懂
- 协议边界设计不清
- 内存泄漏排不明白
- 编解码和业务逻辑缠在一起难维护
所以这一章其实是:
Netty 协议处理与性能优化的起点。
3. 先建立直觉:为什么网络程序离不开字节缓冲区
你写的业务消息,通常长这样:
LoginRequest request = new LoginRequest("alice", "123456");但网络不会直接认识 LoginRequest 这个对象。
它只认识:
- 字节序列
所以发送时要做:
- 把对象编码成字节
接收时要做:
- 从字节流里读出完整消息
- 再把字节解码成对象
那么这些字节总得有个地方存着、读着、写着。
这个地方,在 Netty 里通常就是:
ByteBuf
4. ByteBuf 到底是什么
你可以先把 ByteBuf 理解成:
- 一个更适合网络编程的字节缓冲区
它和 Java NIO 里的 ByteBuffer 都能装字节,但 ByteBuf 在 Netty 里更好用,原因包括:
- 读写指针分离
- API 更友好
- 扩容机制更灵活
- 支持堆内存和直接内存
- 支持池化
- 支持引用计数
也就是说,它不是“为了换个名字”,而是为了更适合高性能网络处理。
5. ByteBuf 最核心的两个指针
理解 ByteBuf,首先要理解两个位置:
readerIndexwriterIndex
你可以先这样记:
readerIndex:下次从哪里开始读writerIndex:下次从哪里开始写
为什么这很重要
因为它避免了 ByteBuffer 那种经常要 flip() 的切换心智负担。
在 ByteBuf 里:
- 读和写的边界更清晰
- 代码更不容易乱
例如:
ByteBuf buf = Unpooled.buffer();
buf.writeByte(1);
buf.writeByte(2);
buf.writeByte(3);
System.out.println(buf.readerIndex()); // 0
System.out.println(buf.writerIndex()); // 3
System.out.println(buf.readByte()); // 1
System.out.println(buf.readerIndex()); // 1
System.out.println(buf.writerIndex()); // 3你会发现:
- 写入时
writerIndex往后走 - 读取时
readerIndex往后走 - 两者互不干扰
这个设计非常适合网络收发这种“边写边读、分阶段处理”的场景。
6. 动手看一眼读写指针变化
下面这个例子建议你亲手跑一遍:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
public class ByteBufDemo {
public static void main(String[] args) {
ByteBuf buf = Unpooled.buffer();
System.out.println("初始: readerIndex=" + buf.readerIndex() + ", writerIndex=" + buf.writerIndex());
buf.writeBytes(new byte[]{10, 20, 30, 40});
System.out.println("写入4字节后: readerIndex=" + buf.readerIndex() + ", writerIndex=" + buf.writerIndex());
byte b1 = buf.readByte();
byte b2 = buf.readByte();
System.out.println("读取2字节后: " + b1 + ", " + b2);
System.out.println("此时: readerIndex=" + buf.readerIndex() + ", writerIndex=" + buf.writerIndex());
}
}你应该能观察到:
- 写入后
writerIndex增加 - 读取后
readerIndex增加 - 还没读的数据依然留在缓冲区可读区域里
这一点看明白后,后面理解半包、拆包、累计缓冲都会顺很多。
7. 堆内存与直接内存
这是 Netty 里另一个非常常见的概念。
7.1 堆内存(Heap Buffer)
堆内存就是:
- JVM 堆上的内存
- 可以直接拿到底层字节数组
- 对 Java 开发者更直观
优点:
- 使用方便
- 访问友好
缺点:
- 做网络 I/O 时,某些场景下可能多一次内存拷贝
7.2 直接内存(Direct Buffer)
直接内存就是:
- 分配在 JVM 堆外的内存
- 更接近底层系统 I/O 使用方式
优点:
- 更适合网络 I/O
- 某些场景能减少拷贝,提高性能
缺点:
- 管理复杂一些
- 排查内存问题时不如堆内存直观
通俗理解
可以先粗略记成:
- 堆内存更好操作
- 直接内存更偏性能
但不是所有场景都要手动纠结选哪种,入门阶段先理解“为什么会有这两类”就够了。
8. 什么是引用计数,为什么它危险
这是 Netty 新手最容易翻车的地方之一。
ByteBuf 很多时候不是交给 JVM 垃圾回收就完了,而是采用了:
- 引用计数(reference count)
你可以先这样理解:
- 一个
ByteBuf初始可能计数为 1 - 谁还要继续使用它,就可能
retain() - 谁用完了,就要
release() - 当计数归零时,底层资源才真正可回收
为什么会出问题
因为如果你:
- 少
release()了
可能导致:
- 内存泄漏
如果你:
- 多
release()了
可能导致:
- 后面还在用已经失效的对象
- 抛出异常或出现诡异问题
所以它是一个典型的:
- 性能更强
- 但使用要求也更严格
的设计。
9. 编码器和解码器在做什么
现在回到“编解码”。
编码(Encode)
编码做的是:
- 把业务对象转成
ByteBuf或字节流
例如:
LoginRequest("alice", "123456")编码成:
[消息类型][用户名长度][用户名][密码长度][密码]解码(Decode)
解码做的是:
- 把收到的字节流重新还原成业务对象
例如收到上面那段字节后,再解析回:
new LoginRequest("alice", "123456")所以编解码的核心不是“语法技巧”,而是:
在协议层和业务层之间做翻译。
10. 为什么编解码要和业务逻辑分层
这是非常重要的一条工程习惯。
错误方式通常是:
- 在一个 Handler 里同时做拆包、解码、业务判断、数据库操作、回包
这样会导致:
- 代码难读
- 协议难改
- 业务难复用
- 排查困难
更推荐的方式是分层:
- 先解决消息边界(是否收完整)
- 再做解码(字节 -> 对象)
- 再做业务处理(对象 -> 业务结果)
- 最后做编码(对象 -> 字节)
这样一来:
- 协议问题归协议层
- 业务问题归业务层
职责会清晰很多。
11. 写一个最简单的字符串编码器/解码器
下面给你一个入门级例子,先别追求复杂协议,先感受“翻译层”这个角色。
11.1 编码器
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.MessageToByteEncoder;
import java.nio.charset.StandardCharsets;
public class SimpleStringEncoder extends MessageToByteEncoder<String> {
@Override
protected void encode(ChannelHandlerContext ctx, String msg, ByteBuf out) {
byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
out.writeInt(bytes.length);
out.writeBytes(bytes);
}
}11.2 解码器
import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.ByteToMessageDecoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class SimpleStringDecoder extends ByteToMessageDecoder {
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
if (in.readableBytes() < 4) {
return;
}
in.markReaderIndex();
int length = in.readInt();
if (in.readableBytes() < length) {
in.resetReaderIndex();
return;
}
byte[] data = new byte[length];
in.readBytes(data);
out.add(new String(data, StandardCharsets.UTF_8));
}
}这个例子里最重要的不是代码本身,而是你要看明白:
- 为什么先写长度
- 为什么解码时先判断可读字节数
- 为什么长度不够时要回退
readerIndex
这就是后面粘包拆包问题的基础。
12. 动手验证
建议你至少做下面 3 个小实验。
12.1 观察读写指针
目标:
- 搞清
readerIndex和writerIndex怎么动
12.2 写一个长度字段协议
目标:
- 理解为什么“消息长度”这么重要
12.3 故意把编解码和业务写混
然后你再拆开重构一次,会非常直观地感受到:
- 分层后代码更清晰
- 协议变更更容易
13. 最容易踩的坑
13.1 忽视引用计数
这会直接导致:
- 内存泄漏
- 非法访问已释放对象
13.2 不理解读写指针
一旦指针理解不清,解码逻辑很容易写错,后面粘包拆包会更乱。
13.3 编解码和业务逻辑混在一起
这是早期能跑、后期难维护的典型写法。
13.4 不理解直接内存的价值
如果只把它当“另一个内存概念”,就很难理解 Netty 为什么要在这层做优化。
13.5 没做长度校验就直接读
这会导致:
- 半包时读错
- 数据错位
- 解码异常
14. 自测问题
ByteBuf为什么比ByteBuffer更适合 Netty 这类网络编程场景?readerIndex和writerIndex分别表示什么?- 为什么引用计数是高频风险点?
- 编解码层为什么应该尽量和业务处理分层?
- 为什么很多自定义协议都会先写一个长度字段?
15. 这一章你至少要带走什么
如果你看完这一章只记住 5 件事,就记下面这 5 件:
- 网络里真正传的是字节,不是 Java 业务对象
- ByteBuf 是 Netty 高效处理字节数据的核心容器
- 读写指针分离,是 ByteBuf 非常重要的设计优势
- 编解码层负责协议翻译,应该尽量和业务逻辑解耦
- 引用计数和长度校验,是 Netty 开发里两个高频风险点
把这些点理解透了,后面你再学粘包拆包、协议设计、内存优化,就不会觉得它们是断开的知识点了。