Skip to content

ByteBuf与编解码

1. 这是什么

学 Netty 时,很多人前一章刚建立起整体架构感,到了这一章又会卡住:

  • ByteBuf 是什么?
  • 为什么不直接用 Java 的 ByteBuffer
  • 编码器、解码器到底在做什么?
  • 为什么总说“不要把编解码和业务逻辑混在一起”?
  • 为什么引用计数会变成高频坑?

这篇就是把这些问题讲清楚。

如果只用一句话理解这一章,可以这样记:

ByteBuf 是 Netty 用来高效处理字节数据的核心容器,编解码则负责把“网络字节流”和“业务对象”互相转换。

你可以把它们看成两层:

  • ByteBuf:负责装和操作字节
  • 编解码器:负责把字节解释成业务消息,或者把业务消息编码成字节

2. 为什么重要

网络通信最终传的不是:

  • Java 对象
  • JSON 对象
  • 业务实体类

而是:

  • 一串字节

所以无论你上层写的是聊天消息、订单协议、RPC 请求还是心跳包,到了网络层都必须处理两个问题:

  1. 这些字节放在哪、怎么读写?
  2. 这些字节怎么翻译成业务对象?

这正是 ByteBuf 和编解码在解决的问题。

如果这层没理解好,后面你会反复踩这些坑:

  • 粘包拆包看不懂
  • 协议边界设计不清
  • 内存泄漏排不明白
  • 编解码和业务逻辑缠在一起难维护

所以这一章其实是:

Netty 协议处理与性能优化的起点。


3. 先建立直觉:为什么网络程序离不开字节缓冲区

你写的业务消息,通常长这样:

java
LoginRequest request = new LoginRequest("alice", "123456");

但网络不会直接认识 LoginRequest 这个对象。

它只认识:

  • 字节序列

所以发送时要做:

  • 把对象编码成字节

接收时要做:

  • 从字节流里读出完整消息
  • 再把字节解码成对象

那么这些字节总得有个地方存着、读着、写着。

这个地方,在 Netty 里通常就是:

  • ByteBuf

4. ByteBuf 到底是什么

你可以先把 ByteBuf 理解成:

  • 一个更适合网络编程的字节缓冲区

它和 Java NIO 里的 ByteBuffer 都能装字节,但 ByteBuf 在 Netty 里更好用,原因包括:

  • 读写指针分离
  • API 更友好
  • 扩容机制更灵活
  • 支持堆内存和直接内存
  • 支持池化
  • 支持引用计数

也就是说,它不是“为了换个名字”,而是为了更适合高性能网络处理。


5. ByteBuf 最核心的两个指针

理解 ByteBuf,首先要理解两个位置:

  • readerIndex
  • writerIndex

你可以先这样记:

  • readerIndex:下次从哪里开始读
  • writerIndex:下次从哪里开始写

为什么这很重要

因为它避免了 ByteBuffer 那种经常要 flip() 的切换心智负担。

ByteBuf 里:

  • 读和写的边界更清晰
  • 代码更不容易乱

例如:

java
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. 动手看一眼读写指针变化

下面这个例子建议你亲手跑一遍:

java
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 或字节流

例如:

java
LoginRequest("alice", "123456")

编码成:

text
[消息类型][用户名长度][用户名][密码长度][密码]

解码(Decode)

解码做的是:

  • 把收到的字节流重新还原成业务对象

例如收到上面那段字节后,再解析回:

java
new LoginRequest("alice", "123456")

所以编解码的核心不是“语法技巧”,而是:

在协议层和业务层之间做翻译。


10. 为什么编解码要和业务逻辑分层

这是非常重要的一条工程习惯。

错误方式通常是:

  • 在一个 Handler 里同时做拆包、解码、业务判断、数据库操作、回包

这样会导致:

  • 代码难读
  • 协议难改
  • 业务难复用
  • 排查困难

更推荐的方式是分层:

  1. 先解决消息边界(是否收完整)
  2. 再做解码(字节 -> 对象)
  3. 再做业务处理(对象 -> 业务结果)
  4. 最后做编码(对象 -> 字节)

这样一来:

  • 协议问题归协议层
  • 业务问题归业务层

职责会清晰很多。


11. 写一个最简单的字符串编码器/解码器

下面给你一个入门级例子,先别追求复杂协议,先感受“翻译层”这个角色。

11.1 编码器

java
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 解码器

java
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 观察读写指针

目标:

  • 搞清 readerIndexwriterIndex 怎么动

12.2 写一个长度字段协议

目标:

  • 理解为什么“消息长度”这么重要

12.3 故意把编解码和业务写混

然后你再拆开重构一次,会非常直观地感受到:

  • 分层后代码更清晰
  • 协议变更更容易

13. 最容易踩的坑

13.1 忽视引用计数

这会直接导致:

  • 内存泄漏
  • 非法访问已释放对象

13.2 不理解读写指针

一旦指针理解不清,解码逻辑很容易写错,后面粘包拆包会更乱。

13.3 编解码和业务逻辑混在一起

这是早期能跑、后期难维护的典型写法。

13.4 不理解直接内存的价值

如果只把它当“另一个内存概念”,就很难理解 Netty 为什么要在这层做优化。

13.5 没做长度校验就直接读

这会导致:

  • 半包时读错
  • 数据错位
  • 解码异常

14. 自测问题

  • ByteBuf 为什么比 ByteBuffer 更适合 Netty 这类网络编程场景?
  • readerIndexwriterIndex 分别表示什么?
  • 为什么引用计数是高频风险点?
  • 编解码层为什么应该尽量和业务处理分层?
  • 为什么很多自定义协议都会先写一个长度字段?

15. 这一章你至少要带走什么

如果你看完这一章只记住 5 件事,就记下面这 5 件:

  1. 网络里真正传的是字节,不是 Java 业务对象
  2. ByteBuf 是 Netty 高效处理字节数据的核心容器
  3. 读写指针分离,是 ByteBuf 非常重要的设计优势
  4. 编解码层负责协议翻译,应该尽量和业务逻辑解耦
  5. 引用计数和长度校验,是 Netty 开发里两个高频风险点

把这些点理解透了,后面你再学粘包拆包、协议设计、内存优化,就不会觉得它们是断开的知识点了。