0
0
0

Netty:ByteBuf 引用计数、Handler 消息传播与处理器选型

2026-11-01
2026-11-01
文章摘要
|

一、ByteBuf 为什么需要引用计数?

普通 Java 对象通常由 JVM 垃圾回收器管理。Netty 的 ByteBuf 可能使用池化内存或直接内存,因此通过引用计数管理资源生命周期。

ByteBuf buffer = Unpooled.buffer();
buffer.writeBytes("HELLO".getBytes(CharsetUtil.UTF_8));

System.out.println(buffer.refCnt()); // 通常为 1

引用计数常用方法:

方法

作用

refCnt()

查看当前引用计数

retain()

增加引用计数

release()

减少引用计数

ReferenceCountUtil.release(msg)

安全地释放引用计数对象

例如:

buffer.retain();  // refCnt:1 -> 2
buffer.release(); // refCnt:2 -> 1
buffer.release(); // refCnt:1 -> 0

引用计数归零后,底层内存可以被释放或归还内存池。此后不能继续访问该 ByteBuf,否则可能出现 IllegalReferenceCountException。

核心原则:谁持有引用,谁就要明确自己何时释放;消息传递时要明确所有权交给了谁。

二、ChannelInboundHandlerAdapter 与 SimpleChannelInboundHandler

继承关系:

ChannelInboundHandler(接口)
          ↑
ChannelInboundHandlerAdapter
          ↑
SimpleChannelInboundHandler

SimpleChannelInboundHandler 继承自 ChannelInboundHandlerAdapter,在适配器的基础上封装了消息类型匹配和自动释放逻辑。

1. ChannelInboundHandlerAdapter

入口方法:

public void channelRead(ChannelHandlerContext ctx, Object msg)

它接收 Object,不会自动释放入站消息。处理器需要自行决定消息类型、是否继续传播,以及由谁释放引用计数对象。

public class DispatchHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof ByteBuf) {
            ByteBuf buffer = (ByteBuf) msg;

            try {
                System.out.println(
                        buffer.toString(CharsetUtil.UTF_8)
                );
            } finally {
                // 当前 Handler 消费消息,不再向下传递,因此负责释放
                ReferenceCountUtil.release(msg);
            }
            return;
        }

        // 当前 Handler 不处理该消息,将所有权交给下一个 Handler
        ctx.fireChannelRead(msg);
    }
}

如果调用 ctx.fireChannelRead(msg) 将消息传递给下一个 Handler,通常表示把消息所有权交给下游处理。此时不要再提前释放同一个引用,否则下游可能拿到已经释放的对象。

2. SimpleChannelInboundHandler

入口方法:

protected void channelRead0(
        ChannelHandlerContext ctx,
        I msg)

通过泛型指定主要处理的消息类型:

public class DeviceHandler
        extends SimpleChannelInboundHandler<ByteBuf> {

    @Override
    protected void channelRead0(
            ChannelHandlerContext ctx,
            ByteBuf msg) {

        System.out.println(
                msg.toString(CharsetUtil.UTF_8)
        );

        // 默认自动释放匹配的引用计数消息,不需要手动 release()
    }
}

默认情况下:

  • 消息类型匹配:调用 channelRead0(),处理完成后自动释放消息。

  • 消息类型不匹配:自动向下一个 Handler 传播,不会由当前 Handler 消费。

自动释放默认开启,也可以通过构造方法关闭:

public DeviceHandler() {
    super(false); // 关闭自动释放,需要自行管理引用计数
}

3. 两者的主要区别

对比项

ChannelInboundHandlerAdapter

SimpleChannelInboundHandler

主要入口

channelRead()

channelRead0()

接收类型

Object

泛型指定的类型

类型不匹配

自己判断和处理

自动向下传播

匹配消息自动释放

不会

默认会

消息传播控制

完全由开发者控制

匹配消息通常由当前 Handler 消费;不匹配消息自动传播

适用场景

多类型分发、精细控制传播和生命周期

主要处理一种明确消息类型

4. SimpleChannelInboundHandler 中转发消息的注意事项

假设当前 Handler 匹配到一个 ByteBuf,但希望继续交给下一个 Handler:

@Override
protected void channelRead0(
        ChannelHandlerContext ctx,
        ByteBuf msg) {

    // retain() 增加一个引用,避免当前方法结束时自动释放后,下游无法使用
    ctx.fireChannelRead(msg.retain());
}

当前 Handler 方法结束时会自动释放自己持有的那一个引用;retain() 增加的引用交给下游,下游消费后仍需按自己的规则释放。

如果下游是 SimpleChannelInboundHandler<ByteBuf>,默认会自动释放;如果下游是 ChannelInboundHandlerAdapter,消费后需要自行释放,或者继续把所有权传给后续 Handler。

三、ByteBuf 切片与异步处理

1. readSlice() 与 readRetainedSlice()

两个方法都会返回与原 ByteBuf 共享底层内存的切片,不是复制一份独立数据。

方法

是否增加引用计数

生命周期特点

readSlice(length)

不增加

依赖原 ByteBuf 的生命周期

readRetainedSlice(length)

增加

为切片增加一个引用,需要由接收方释放

示例:

ByteBuf slice = in.readSlice(10);

这个切片没有独立增加引用计数,不能在原 ByteBuf 释放后继续使用。

如果需要把切片作为独立的引用计数消息交给下游:

ByteBuf slice = in.readRetainedSlice(10);
out.add(slice);

下游处理完成后必须释放该切片。另一种方式是复制数据,例如 readBytes(byte[]),得到普通字节数组后不再依赖原 ByteBuf 的生命周期。

2. 异步处理 ByteBuf 的风险

错误示例:

@Override
protected void channelRead0(
        ChannelHandlerContext ctx,
        ByteBuf msg) {

    executor.submit(() -> {
        // 风险:channelRead0 返回后,msg 默认会被自动释放
        System.out.println(
                msg.toString(CharsetUtil.UTF_8)
        );
    });
}

线程池任务可能还没执行,channelRead0() 就已经返回并释放了消息。异步线程继续访问可能触发 IllegalReferenceCountException。

方案一:先复制为普通 Java 数据,再提交任务,通常更简单:

@Override
protected void channelRead0(
        ChannelHandlerContext ctx,
        ByteBuf msg) {

    byte[] data = new byte[msg.readableBytes()];
    msg.getBytes(msg.readerIndex(), data);

    executor.submit(() -> {
        // 使用独立的 byte[],不依赖原 ByteBuf 生命周期
        handle(data);
    });
}

方案二:确实需要异步持有 ByteBuf 时,先 retain(),并确保任务最终释放:

@Override
protected void channelRead0(
        ChannelHandlerContext ctx,
        ByteBuf msg) {

    ByteBuf retained = msg.retain();

    try {
        executor.submit(() -> {
            try {
                handle(retained);
            } finally {
                retained.release();
            }
        });
    } catch (RejectedExecutionException e) {
        // 任务提交失败,异步任务不会执行,因此当前代码负责释放 retain 的引用
        retained.release();
        throw e;
    }
}

注意:异步任务必须有明确的完成和异常处理路径,确保每次 retain() 都有对应的 release()。

四、实际开发如何选择 Handler?

1. 优先使用 SimpleChannelInboundHandler 的场景

当一个 Handler 主要负责处理一种明确类型的消息时,通常优先使用它。

例如,协议解码器已经将 ByteBuf 转换成普通 Java 对象:

public class DeviceMessage {
    private String deviceId;
    private byte[] body;

    public String getDeviceId() {
        return deviceId;
    }

    public byte[] getBody() {
        return body;
    }
}

业务 Handler:

public class DeviceBusinessHandler
        extends SimpleChannelInboundHandler<DeviceMessage> {

    @Override
    protected void channelRead0(
            ChannelHandlerContext ctx,
            DeviceMessage msg) {

        // 直接处理明确类型的业务消息
        handleDeviceMessage(msg);
    }
}

DeviceMessage 是普通 Java 对象时,不涉及 ByteBuf 引用计数的自动释放问题。

2. 使用 ChannelInboundHandlerAdapter 的场景

当一个 Handler 需要接收多种消息类型、自行分发,或需要精细控制消息传播和引用计数生命周期时,可以使用它。

public class DispatchHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof DeviceMessage) {
            handleDeviceMessage((DeviceMessage) msg);
            return;
        }

        if (msg instanceof String) {
            handleText((String) msg);
            return;
        }

        // 未处理的消息继续向下传播
        ctx.fireChannelRead(msg);
    }
}

这里的 DeviceMessage 和 String 是普通 Java 对象,不需要像 ByteBuf 一样手动释放引用计数。

3. 选择建议

场景

推荐方式

主要处理一种明确类型的业务消息

SimpleChannelInboundHandler<T>

需要处理多种类型并自行分发

ChannelInboundHandlerAdapter

需要自行决定消息是否继续传播

ChannelInboundHandlerAdapter

需要长期或异步持有 ByteBuf

两者都可以,但必须明确 retain/release 生命周期

不要简单地认为某一个 Handler 一定更好。选择依据是消息类型、传播方式和资源生命周期。

五、常见问题与答案

1. SimpleChannelInboundHandler 是接口吗?

不是。它是一个抽象类,继承自 ChannelInboundHandlerAdapter。

2. SimpleChannelInboundHandler 只能处理 ByteBuf 吗?

不是。它可以处理指定泛型类型的消息,例如 String、自定义消息对象或 ByteBuf。

3. ChannelInboundHandlerAdapter 会自动释放 ByteBuf 吗?

不会。它不负责自动释放。当前 Handler 消费引用计数消息时,需要释放;如果把消息传递给下游,则应把所有权交给下游。

4. SimpleChannelInboundHandler 收到不匹配类型的消息会怎样?

默认会自动调用 ctx.fireChannelRead(msg),将消息传递给下一个 Handler。

5. 为什么不能在 channelRead0() 中直接异步使用 ByteBuf?

因为方法返回后默认自动释放消息,异步任务可能在释放之后才访问它。应复制数据,或先 retain 并在异步任务结束时 release。

6. retain() 和 release() 必须配对吗?

每次额外增加的引用都必须有对应的释放责任。否则可能造成内存泄漏;释放过多则可能造成非法引用计数异常。

六、重点总结

  1. ByteBuf 使用引用计数管理底层内存,引用计数归零后不能继续访问。

  2. retain() 增加引用计数,release() 减少引用计数。

  3. ChannelInboundHandlerAdapter 使用 channelRead(),接收 Object,不自动释放入站消息。

  4. SimpleChannelInboundHandler<T> 使用 channelRead0(),通过泛型指定处理类型,默认自动释放匹配的引用计数消息。

  5. SimpleChannelInboundHandler 收到不匹配的消息时,会自动向下传播。

  6. 消息通过 fireChannelRead() 传给下游后,不要再提前释放下游正在使用的引用。

  7. readSlice() 不增加引用计数,readRetainedSlice() 会增加引用计数。

  8. 异步使用 ByteBuf 时,要复制数据,或 retain 后在任务结束时 release。

  9. 主要处理一种明确消息类型时,通常优先使用 SimpleChannelInboundHandler;需要多类型分发和更精细的生命周期控制时,可以使用 ChannelInboundHandlerAdapter。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或者给予支持!

评论