本节目标:把
ByteBuf的四个索引值与 JDKByteBuffer的position/limit对上号,能在解码器里正确读写、切片与复位。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-buffer模块)。
承接的问题
4.3 把 handler 拆成无状态单例之后,linkd-v2 的 9093 端口上每条连接都复用同一个 LinkdFrameDecoder 实例。单例意味着解码器不能把「这条连接读到哪儿了」存进成员变量,位置只能放在传进来的 ByteBuf 里。于是所有解码逻辑都建立在一组索引上:读到哪里、写到哪里、还能扩到多大。
卷一的 ByteBuffer 只有 position 和 limit 两个游标,读写切换要靠 flip()。Netty 的 ByteBuf 把它们拆成读、写两组,读写不再互斥,也就不用 flip。便利的代价是概念变多:readerIndex、writerIndex、capacity、maxCapacity 四个值,加上 readableBytes()、writableBytes() 这些派生量。
一个真实故障是这样出现的。9093 端口在一个 TCP 段里收到两个 PING 帧,共 32 字节。handler 里写了 int len = in.readableBytes();,把 32 当成单帧长度去读负载,结果第一个帧的 length 字段(真实值是 0)被忽略,两个帧被拼成一条超长消息,requestId 取自第二帧的字节,回包发给了错误的请求。日志上看是「PONG 的 requestId 对不上」,根因却是把「缓冲区里现有的字节数」当成了「这一帧的长度」。
这一节先把索引规则跑成可复现的表,再回到 LinkdFrameDecoder 和 LinkdHttpHandler。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。标注「本机输出」的内容来自这台机器,单次运行,未做统计。
四个索引与 JDK ByteBuffer 的对照
ByteBuf 用两个独立游标描述读写位置:
readerIndex:下一个要读的字节。writerIndex:下一个要写的字节。capacity:当前已分配的容量,可以增长。maxCapacity:容量上限,超过就抛异常。
它和 ByteBuffer 的对应关系如下:
| 概念 | ByteBuffer | ByteBuf |
|---|---|---|
| 读位置 | position(读模式下) | readerIndex |
| 写位置 | position(写模式下) | writerIndex |
| 可读边界 | limit(读模式下) | writerIndex |
| 可写边界 | limit(写模式下) | capacity |
| 容量上限 | 无,capacity 固定 | maxCapacity |
| 读写切换 | flip() | 不需要,两组游标同时存在 |
ByteBuffer 的 position 在读模式和写模式下含义不同,所以每次切换都要 flip;ByteBuf 的读游标和写游标同时存在,写完可以直接读,读的时候写游标不动。这个差别让解码器不必在方法之间传递「现在是什么模式」,也少了一整类 flip 两次的故障。
两条不变量始终成立:
0 <= readerIndex <= writerIndex <= capacity <= maxCapacity
和 ByteBuffer 不同,ByteBuf 的 capacity 会在写入时自动增长,上限是 maxCapacity。用 Unpooled.buffer(16, 64) 把一次写入、读取、复位、丢弃跑一遍:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
public class ByteBufIndexDemo {
public static void main(String[] args) {
ByteBuf buf = Unpooled.buffer(16, 64);
print("allocate", buf);
buf.writeBytes("GET /healthz".getBytes(StandardCharsets.US_ASCII));
print("write 12", buf);
buf.markReaderIndex();
byte[] head = new byte[4];
buf.readBytes(head);
print("read 4", buf);
System.out.println("head=" + new String(head, StandardCharsets.US_ASCII));
buf.resetReaderIndex();
print("resetReader", buf);
buf.readBytes(4);
buf.discardReadBytes();
print("discardDone", buf);
System.out.println("readable=" + buf.readableBytes()
+ " writable=" + buf.writableBytes()
+ " isReadable=" + buf.isReadable());
}
static void print(String label, ByteBuf b) {
System.out.printf("%-12s ridx=%d widx=%d cap=%d max=%d readable=%d writable=%d%n",
label, b.readerIndex(), b.writerIndex(), b.capacity(),
b.maxCapacity(), b.readableBytes(), b.writableBytes());
}
}
本机输出:
allocate ridx=0 widx=0 cap=16 max=64 readable=0 writable=16
write 12 ridx=0 widx=12 cap=16 max=64 readable=12 writable=4
read 4 ridx=4 widx=12 cap=16 max=64 readable=8 writable=4
head=GET
resetReader ridx=0 widx=12 cap=16 max=64 readable=12 writable=4
discardDone ridx=0 widx=8 cap=16 max=64 readable=8 writable=8
readable=8 writable=8 isReadable=true
逐行读:
- 刚分配时
readerIndex和writerIndex都是 0,writableBytes()等于capacity,maxCapacity是 64,说明容量还能涨到 64。 - 写 12 字节后
writerIndex=12,可读 12 字节。注意此时还没有任何「切换」动作,直接读就行。 - 读 4 字节后
readerIndex=4,可读区间变成[4,12),writerIndex不动。 markReaderIndex()记住 4;resetReaderIndex()把读游标拨回 0,12 字节重新可读。- 再读 4 字节并
discardReadBytes(),前 4 字节被丢弃,剩余 8 字节搬到索引 0,writerIndex从 12 减到 8,容量仍是 16。
readerIndex 和 writerIndex 都可以显式设置,writerIndex(int) 常用于「从 socket 读到一段后,手工把写游标推到位」;setIndex(int, int) 一次设置两个。超过边界的设置会抛 IndexOutOfBoundsException,不会静默截断。
readableBytes 不是消息长度
readableBytes() 等于 writerIndex - readerIndex,它回答的是「缓冲区里现在有多少字节还没被读」,不回答「这一条消息有多长」。两者只有在「缓冲区恰好只装了一条完整消息」时才相等。
用 9093 端口的帧头验证。协议头 16 字节,length 字段在偏移 12,两个 PING 帧连着到达时,缓冲区里有 32 字节,但每帧的负载长度都是 0:
一个 TCP 段里的两个 PING 帧
offset 0 offset 31
+----------------+ +----------------+
| 16 字节头 | 0 负载 | | 16 字节头 | 0 负载 |
+----------------+ +----------------+
readableBytes() = 32 真实单帧长度 = 16
把 32 当成长度,解码器会认为第一帧的负载有 32 字节,实际上它把第二帧的帧头也算进去了。表现是 type、requestId 全部错位,而不是抛异常。
正确写法是先看帧头里的 length,再判断「缓冲区里是否已经凑齐 16 + length 字节」。ByteBuf 的绝对读方法 getInt(int) 只读指定位置,不移动 readerIndex,适合在不动游标的前提下窥探帧头:
static int peekPayloadLength(ByteBuf in) {
if (in.readableBytes() < ProtocolConstants.HEADER_LENGTH) {
return -1;
}
int base = in.readerIndex();
int length = in.getInt(base + 12);
if (length < 0 || length > ProtocolConstants.MAX_PAYLOAD) {
throw new TooLongFrameException("payload length " + length);
}
return length;
}
这段方法可以在半包时反复调用:头部不完整返回 -1,调用方直接 return 等下一次读;头部完整但负载没到齐,调用方拿 readableBytes() 和 16 + length 比较。注意这里的 readableBytes() 只用来判断「够不够」,不再被当成消息长度。
readableBytes() 还有两个容易忽略的边界。第一,它包含缓冲区里所有未读字节,可能横跨多条消息,也可能是上一条消息的尾巴。第二,writerIndex 可以被 setIndex 或 writerIndex(int) 直接推大,此时 readableBytes() 会把从未写入的区域也算进去。判断「有没有数据」用 isReadable(),判断「这一帧齐了没有」永远要用帧头里的长度。
mark、reset 与 discardReadBytes
markReaderIndex() / resetReaderIndex() 是一对记忆点,语义和 ByteBuffer.mark() / reset() 相同,只作用于读游标。ByteToMessageDecoder 内部就用类似机制处理半包:读了一部分发现不够,回退到帧头,等下一次数据到达再重新解析。
discardReadBytes() 把 [readerIndex, writerIndex) 的未读字节搬到索引 0,同时把两个游标各减去已读字节数。它等价于 ByteBuffer.compact() 的效果,但不需要先 flip。
这个方法有真实成本:剩余字节越多,搬得越多;剩余越少,越值得调用。本机示例里剩余 8 字节,搬一次很便宜。如果一个 64 KB 的缓冲区每次只消费 16 字节就 discardReadBytes(),等于每读 16 字节搬 64 KB,吞吐会被 memmove 拖住。
在 ByteToMessageDecoder 的子类里通常不需要自己调用它,父类在 channelRead 收尾时会处理累积缓冲区。手写解码循环或者在 handler 里直接操作 ByteBuf 时,才需要考虑。linkd-v2 的约定是:只有在「确认剩余字节属于下一条消息,且剩余量小于已读量」时才丢弃;否则把游标留着,让下一次读接着往后写。
slice、duplicate 与 retainedSlice
三个方法都返回「视图」,都不复制字节,区别在索引是否独立、引用计数是否增加:
| 方法 | 内容 | 索引 | 引用计数 | 需要单独 release |
|---|---|---|---|---|
slice() | 共享 | 独立,从 0 开始 | 不增加 | 否 |
slice(int, int) | 共享 | 独立,从 0 开始 | 不增加 | 否 |
duplicate() | 共享 | 独立,复制当前读写位置 | 不增加 | 否 |
retainedSlice() | 共享 | 独立,从 0 开始 | 加 1 | 是 |
跑一段代码看共享与独立性:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
public class ViewDemo {
public static void main(String[] args) {
ByteBuf src = Unpooled.buffer(16);
src.writeBytes("LKDFPAYLOAD".getBytes(StandardCharsets.US_ASCII));
src.writerIndex(6);
ByteBuf slice = src.slice(2, 4);
ByteBuf dup = src.duplicate();
System.out.println("src " + describe(src) + " refCnt=" + src.refCnt());
System.out.println("slice " + describe(slice) + " refCnt=" + slice.refCnt());
System.out.println("dup " + describe(dup) + " refCnt=" + dup.refCnt());
slice.setByte(0, 'X');
System.out.println("slice.setByte -> src.getByte(2)=" + (char) src.getByte(2));
dup.readerIndex(4);
System.out.println("dup.readerIndex(4) -> src.readerIndex=" + src.readerIndex()
+ " dup.readerIndex=" + dup.readerIndex());
ByteBuf retained = src.retainedSlice(0, 4);
System.out.println("retainedSlice -> src.refCnt=" + src.refCnt()
+ " retained.refCnt=" + retained.refCnt());
retained.release();
System.out.println("retained.release -> src.refCnt=" + src.refCnt());
}
static String describe(ByteBuf b) {
return "ridx=" + b.readerIndex() + " widx=" + b.writerIndex()
+ " cap=" + b.capacity() + " readable=" + b.readableBytes();
}
}
本机输出:
src ridx=0 widx=6 cap=16 readable=6 refCnt=1
slice ridx=0 widx=4 cap=4 readable=4 refCnt=1
dup ridx=0 widx=6 cap=16 readable=6 refCnt=1
slice.setByte -> src.getByte(2)=X
dup.readerIndex(4) -> src.readerIndex=0 dup.readerIndex=4
retainedSlice -> src.refCnt=2 retained.refCnt=2
retained.release -> src.refCnt=1
三条结论:
slice(2, 4)容量是 4,读写从 0 开始,但setByte(0, 'X')改的是源缓冲区索引 2。视图不复制内容。duplicate()复制了源当前的readerIndex/writerIndex,之后dup.readerIndex(4)不影响源。索引独立,内容共享。retainedSlice()让源和视图的refCnt都变成 2;retained.release()之后回到 1。视图持有了一份独立的引用,必须自己释放。
在 linkd-v2 里落地
LinkdFrameDecoder 在 6.1 才补全 decode,这一节先落地它读取帧头的静态方法(下面是节选,decode 与构造器在 6.1 补齐,单独贴出来不可直接编译)。该方法只做三件事:检查头部是否完整、用绝对读取出 length、校验上限。它不动 readerIndex,因此可以在半包时反复调用。
package com.plume.linkd.v2.protocol;
import io.netty.buffer.ByteBuf;
import io.netty.handler.codec.ByteToMessageDecoder;
import io.netty.handler.codec.TooLongFrameException;
public final class LinkdFrameDecoder extends ByteToMessageDecoder {
static int peekPayloadLength(ByteBuf in) {
if (in.readableBytes() < ProtocolConstants.HEADER_LENGTH) {
return -1;
}
int base = in.readerIndex();
int length = in.getInt(base + 12);
if (length < 0 || length > ProtocolConstants.MAX_PAYLOAD) {
throw new TooLongFrameException("payload length " + length);
}
return length;
}
}
HEADER_LENGTH 是 16,MAX_PAYLOAD 是 1 MiB(第 7 章的协议常量)。getInt(base + 12) 读的是 length 字段,返回值按有符号整数处理,所以 length < 0 必须先判,否则一个 0xFFFFFFFF 会被当成合法的 42 亿字节长度,随后 16 + length 溢出成负数。这段判断放在读取负载之前,比读到一半再抛异常容易定位。
第二个落点是 LinkdHttpHandler。它继承 SimpleChannelInboundHandler<FullHttpRequest>,拿到的请求已经过 HttpObjectAggregator 聚合,content() 里的字节就是完整 body,此时 readableBytes() 才等于 body 长度:
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
ByteBuf body = req.content();
if (body.readableBytes() > 64 * 1024) {
sendError(ctx, HttpResponseStatus.REQUEST_ENTITY_TOO_LARGE);
return;
}
byte[] raw = new byte[body.readableBytes()];
body.getBytes(body.readerIndex(), raw);
handle(ctx, raw);
}
这里 getBytes(readerIndex, raw) 是绝对读,不移动读游标;raw 是拷贝,handler 返回后 SimpleChannelInboundHandler 自动释放请求,raw 仍然可用。如果改成 body.readBytes(raw),读游标会前移,虽然不影响自动释放,但同一 handler 里后续再读 content() 就会少一截。
如果换成流式处理、没有聚合器,content().readableBytes() 只是「本次到达的字节数」,直接当 body 长度用就会截断。这也是 readableBytes() 不能当消息长度的另一种表现。
常见错误与边界
错误一:把 readableBytes() 当帧长度。 一个 TCP 段可能带多条消息,缓冲区里未读字节数大于单帧长度。本机 32 字节两个 PING 帧的例子中,真实单帧是 16 字节。长度必须从帧头读。
错误二:对普通 slice() 调用 release()。 slice() 不增加引用计数,但它返回的视图把 release() 委托给源缓冲区。本机把 plain slice.release() 执行后,源的 refCnt 从 1 变成 0,缓冲区被回收,后续读取会抛 IllegalReferenceCountException。只有 retainedSlice() 出来的视图才归调用方释放。
错误三:长期持有 slice。 视图共享源内存,源缓冲区被池复用后,视图看到的内容会变成下一条消息的字节。要跨事件循环或跨请求保留一小段数据,复制成 byte[],不要留视图。
错误四:写入超过 maxCapacity。 Unpooled.buffer(4, 8) 写 4 字节后再写 8 字节,本机报错原文是 writerIndex(4) + minWritableBytes(8) exceeds maxCapacity(8): ... (ridx: 0, widx: 4, cap: 4/8)。ensureWritable(4) 会把容量扩到 8,再要 8 字节就失败。解码器分配缓冲区时要把 maxCapacity 设成 HEADER_LENGTH + MAX_PAYLOAD,不要用默认的 Integer.MAX_VALUE,否则一个伪造的长度字段就能让进程分配巨量内存。
错误五:用 capacity() 判断已写入多少。 capacity 是分配量,writerIndex 才是写入量。Unpooled.buffer(64) 刚创建时 capacity=64、writerIndex=0,直接按 capacity 读会读到 64 个零字节。
边界:ByteBuf 不是线程安全的。 读写索引是普通字段,没有同步。一个 ByteBuf 同一时刻只能由一个 EventLoop 线程操作;跨线程传递要么走 pipeline 的事件传播,要么自己保证 happens-before。retainedDuplicate() 能让两个线程各自持有引用,但内容仍然是同一块内存,写入方仍需保证不并发写。
小结
ByteBuf用readerIndex/writerIndex两组游标替代ByteBuffer的position/limit,读写不再需要flip。capacity可增长,上限是maxCapacity;本机Unpooled.buffer(4, 8)写 8 字节时抛出exceeds maxCapacity(8)。readableBytes()是「缓冲区里未读的字节数」,不是消息长度;9093 端口两个PING帧共 32 字节,单帧只有 16 字节。markReaderIndex/resetReaderIndex只回退读游标;discardReadBytes()搬移剩余字节,剩余越多成本越高。slice()/duplicate()共享内容、索引独立、不增加引用计数;retainedSlice()增加 1,必须由持有者释放。- 对普通
slice()调release()会直接减源缓冲区的引用计数,本机实测会把它减到 0。
索引规则清楚之后,下一个问题是这块内存归谁释放。5.1 里出现的 retainedSlice() 已经把引用计数摆到台面上,下一节把 retain()/release() 的所有权规则、自动释放的边界和泄漏检测流程一次讲完。
阅读导航:上一节:4.3 Handler 共享与线程安全 · 下一节:5.2 引用计数与泄漏 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。