Netty:ByteBuf 引用计数、Handler 消息传播与处理器选型
一、ByteBuf 为什么需要引用计数?
普通 Java 对象通常由 JVM 垃圾回收器管理。Netty 的 ByteBuf 可能使用池化内存或直接内存,因此通过引用计数管理资源生命周期。
ByteBuf buffer = Unpooled.buffer();
buffer.writeBytes("HELLO".getBytes(CharsetUtil.UTF_8));
System.out.println(buffer.refCnt()); // 通常为 1引用计数常用方法:
例如:
buffer.retain(); // refCnt:1 -> 2
buffer.release(); // refCnt:2 -> 1
buffer.release(); // refCnt:1 -> 0引用计数归零后,底层内存可以被释放或归还内存池。此后不能继续访问该 ByteBuf,否则可能出现 IllegalReferenceCountException。
核心原则:谁持有引用,谁就要明确自己何时释放;消息传递时要明确所有权交给了谁。
二、ChannelInboundHandlerAdapter 与 SimpleChannelInboundHandler
继承关系:
ChannelInboundHandler(接口)
↑
ChannelInboundHandlerAdapter
↑
SimpleChannelInboundHandlerSimpleChannelInboundHandler 继承自 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. 两者的主要区别
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 共享底层内存的切片,不是复制一份独立数据。
示例:
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. 选择建议
不要简单地认为某一个 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() 必须配对吗?
每次额外增加的引用都必须有对应的释放责任。否则可能造成内存泄漏;释放过多则可能造成非法引用计数异常。
六、重点总结
ByteBuf 使用引用计数管理底层内存,引用计数归零后不能继续访问。
retain()增加引用计数,release()减少引用计数。ChannelInboundHandlerAdapter使用channelRead(),接收 Object,不自动释放入站消息。SimpleChannelInboundHandler<T>使用channelRead0(),通过泛型指定处理类型,默认自动释放匹配的引用计数消息。SimpleChannelInboundHandler 收到不匹配的消息时,会自动向下传播。
消息通过
fireChannelRead()传给下游后,不要再提前释放下游正在使用的引用。readSlice()不增加引用计数,readRetainedSlice()会增加引用计数。异步使用 ByteBuf 时,要复制数据,或 retain 后在任务结束时 release。
主要处理一种明确消息类型时,通常优先使用 SimpleChannelInboundHandler;需要多类型分发和更精细的生命周期控制时,可以使用 ChannelInboundHandlerAdapter。
