《Netty 高性能网络编程》5.1 ByteBuf 索引与视图

用 Unpooled.buffer 把 readerIndex、writerIndex、capacity、maxCapacity 的每次变化跑成表,对照 JDK ByteBuffer 的 position 与 limit,讲清 readableBytes 为什么不能当消息长度,以及 slice、duplicate、retainedSlice 谁共享内容、谁增加引用计数。

本节目标:把 ByteBuf 的四个索引值与 JDK ByteBuffer 的 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 的对应关系如下:

概念ByteBufferByteBuf
读位置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 引用计数与泄漏 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发