《Java 网络编程基础》5.1 Buffer 的 position、limit 与 flip

一次 read 之后 position 落在末尾,直接 get 什么也读不到;flip 早了丢数据,flip 两次会重放旧数据。本节用 16 字节缓冲区画出 position、limit、capacity 的每次变化,讲清 flip/clear/compact/rewind 与 slice、只读视图,并给出 linkd 读写切换的排错清单。

本节目标:把 position、limit、capacity 三个值的变化画出来,能在读写切换时判断该 flip、compact 还是 clear。
适用版本:JDK 25(Temurin 25.0.5+8);java.nio.ByteBuffer。

承接的问题

4.3 的编解码器把一条消息还原成 byte[],但在 linkd 的 I/O 路径上,字节先落在 ByteBuffer 里。SocketChannel.read(ByteBuffer) 只负责把字节放进缓冲区,不负责告诉你「读到了哪里」。这个信息由 position 和 limit 表达。任何一次读写切换写错,表现通常不是异常,而是「少读了几个字节」「响应只发出去一半」「同一帧被处理两次」。

先看一个真实会遇到的故障。FrameDecoder 从连接上读到一个长度域帧,长度是 16 字节,但这次 read 只带回了 11 字节。调用方如果看到「读到了数据」就 clear() 缓冲区,剩下那 11 字节会被丢掉;下一次 read 从索引 0 重新开始,帧头被当成新数据解析,日志里出现 bad frame length。另一种写法是 flip() 调用两次:第二次 flip 把 limit 设成当前 position,后半段数据被关在 limit 之外,解码器看起来「什么都没读到」。

这一节不急着上 Channel。先用一个 16 字节的缓冲区把三个值的变化跑一遍,再把同一套规则放回 linkd 的 BufferPool 和 FrameDecoder。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8。

三个值与一条不变量

ByteBuffer 的所有读写都受四个值约束:

  • capacity:分配时确定,之后不变。
  • limit:第一个不应该读或写的字节索引。
  • position:下一个要读或写的字节索引。
  • mark:一个可选的记忆点,默认未设置。

它们满足一条不变量:

0 <= mark <= position <= limit <= capacity

写模式和读模式的差别,只是 position 和 limit 的摆放不同:

写模式:allocate 或 clear 之后
0              12              16
+--------------+---------------+
|   已写入 12  |   还能写 4    |
+--------------+---------------+
position=12   limit=16   capacity=16

读模式:flip 之后
0              12              16
+--------------+---------------+
|   可读 12    |   不参与读取  |
+--------------+---------------+
position=0    limit=12   capacity=16

用 "GET /healthz" 这 12 个 ASCII 字节跑一遍,每一步都把三个值打印出来:

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class BufferStateDemo {
    public static void main(String[] args) {
        ByteBuffer b = ByteBuffer.allocate(16);
        print("allocate", b);
        b.put("GET /healthz".getBytes(StandardCharsets.US_ASCII));
        print("after put", b);
        b.flip();
        print("after flip", b);
        byte[] head = new byte[4];
        b.get(head);
        print("after get(4)", b);
        b.compact();
        print("after compact", b);
        b.clear();
        print("after clear", b);
    }

    static void print(String label, ByteBuffer b) {
        System.out.printf("%-14s pos=%d lim=%d cap=%d remaining=%d%n",
                label, b.position(), b.limit(), b.capacity(), b.remaining());
    }
}

本机输出:

allocate       pos=0 lim=16 cap=16 remaining=16
after put      pos=12 lim=16 cap=16 remaining=4
after flip     pos=0 lim=12 cap=16 remaining=12
after get(4)   pos=4 lim=12 cap=16 remaining=8
after compact  pos=8 lim=16 cap=16 remaining=8
after clear    pos=0 lim=16 cap=16 remaining=16

逐行读:

  • allocate 后 limit == capacity,整个缓冲区都可写。
  • put 12 字节后 position=12,remaining=4,还能再写 4 字节。
  • flip 把 limit 设为 12、position 设为 0,12 字节变成可读;capacity 不变。
  • get 4 字节后 position=4,可读区间变成 [4,12)。
  • compact 把 [4,12) 这 8 字节搬到索引 0,position 变成 8,limit 恢复成 16,缓冲区回到写模式。
  • clear 不擦除字节,只把 position 设 0、limit 设回 16;旧内容仍在数组里,但已经不可读。

最后一条很重要:clear 只是重置索引,不是清零;旧字节仍在数组里,敏感数据要显式覆盖。

flip 不是反转,是切换读写视角

flip() 的实现只有两步:limit(position)、position(0)。它不反转字节顺序,也不移动数据。名字来自「把缓冲区从写模式翻到读模式」这个动作,不是「翻转内容」。

四个最常用的状态切换方法:

方法positionlimit未读数据典型用途
flip()0原来的 position保留写完转读
rewind()0不变保留重读同一段
clear()0capacity丢弃整段处理完,重新写
compact()剩余字节数capacity搬到开头保留只处理了一部分,继续读

rewind() 和 flip() 容易混。rewind 只把 position 拨回 0,limit 保持不动,适合「同一段数据要读两遍」;flip 会把 limit 改成当前 position,适合「写完转读」。写完之后如果只 rewind,limit 仍然是 capacity,读取会越过真正写入的末尾,读到旧数据或零。

compact() 的成本经常被忽略。它要把 [position, limit) 的剩余字节复制到索引 0,剩余越多,复制越多。一个 64 KB 的缓冲区如果每次只消费 4 字节就 compact,等于每读 4 字节搬 64 KB,吞吐会被 memmove 拖住。正确做法是尽量在一次 decode 里消费完所有完整帧,只在确实剩下半包时才 compact。

mark() 和 reset() 是另一组状态。mark 记住当前 position,reset 把 position 拨回去。FrameDecoder 用它来处理「帧头读完了,但帧体还没到」的情况:先 mark,读 4 字节长度,发现剩余不足,reset 回到帧头,等下一次读。要注意,flip、clear、rewind、compact 都会丢弃 mark,position(int) 在把位置拨到 mark 之前时也会丢弃它。

一次 flip 两次,数据会少一截

看一个具体的错误写法:

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class DoubleFlipDemo {
    public static void main(String[] args) {
        ByteBuffer b = ByteBuffer.allocate(8);
        b.put("abcdef".getBytes(StandardCharsets.US_ASCII));
        b.flip();
        b.get();
        b.flip();
        System.out.println("remaining=" + b.remaining()
                + " limit=" + b.limit() + " position=" + b.position());
    }
}

本机输出:

double flip remaining=1 limit=1 position=0

推演一下:

  1. put 6 字节后 position=6、limit=8。
  2. 第一次 flip 后 position=0、limit=6,6 字节可读。
  3. get() 消费 1 字节,position=1、limit=6。
  4. 第二次 flip 把 limit 设成 1、position 设成 0,可读区间只剩 [0,1),后 5 字节被关在 limit 之外。

第二次 flip 没有抛异常,也没有丢字节,但解码器只能看到 1 字节。如果这是一个长度域帧,解码器会一直等「还差 5 字节」,连接看起来卡住。排障时看到「客户端说发了 6 字节,服务端只解析出 1 字节」,先数 flip 的次数。

另一个常见错误是 clear() 用得太早。clear 把 limit 设回 capacity,未读数据虽然还在数组里,但已经不在可读区间。下一次 read 从索引 0 覆盖写入,旧数据被新数据盖掉。半包场景必须用 compact(),不能用 clear()。

flip 之后直接 put 也会出问题。flip 把 limit 设成原来的 position,此时 put 从索引 0 开始写,会覆盖刚读过的数据;写到 limit 时抛 BufferOverflowException。本机原文:

put past limit -> java.nio.BufferOverflowException
get past limit -> java.nio.BufferUnderflowException

读越过 limit 抛 BufferUnderflowException,写越过 limit 抛 BufferOverflowException。如果 position == limit,相对 get 不会抛异常,而是直接返回「没有数据」;这一点最容易被误判成「对端没发」。

slice、duplicate 与只读视图

slice() 从当前 position 切出一个新缓冲区。新缓冲区的 position=0、limit=capacity=原剩余字节数,内容和原缓冲区共享。改 slice 的内容,原缓冲区能看到;改原缓冲区,slice 也能看到。

import java.nio.ByteBuffer;
import java.nio.ReadOnlyBufferException;

public class ViewDemo {
    public static void main(String[] args) {
        ByteBuffer src = ByteBuffer.allocate(8);
        for (int i = 0; i < 8; i++) {
            src.put((byte) i);
        }
        src.position(2).limit(6);
        ByteBuffer slice = src.slice();
        ByteBuffer ro = src.asReadOnlyBuffer();
        System.out.printf("src pos=%d lim=%d cap=%d%n",
                src.position(), src.limit(), src.capacity());
        System.out.printf("slice pos=%d lim=%d cap=%d first=%d%n",
                slice.position(), slice.limit(), slice.capacity(), slice.get(0));
        System.out.printf("readonly pos=%d lim=%d cap=%d%n",
                ro.position(), ro.limit(), ro.capacity());
        try {
            ro.put((byte) 99);
        } catch (ReadOnlyBufferException e) {
            System.out.println("ro.put -> " + e.getClass().getSimpleName());
        }
    }
}

本机输出:

src pos=2 lim=6 cap=8
slice pos=0 lim=4 cap=4 first=2
readonly pos=2 lim=6 cap=8
ro.put -> ReadOnlyBufferException

slice 的容量是 4,正好是原缓冲区 [2,6) 的长度;first=2 说明它从原索引 2 开始。asReadOnlyBuffer 保留原来的 position 和 limit,但任何写操作都抛 ReadOnlyBufferException。

duplicate() 和 slice() 不同:duplicate 复制原缓冲区当前的 position、limit、capacity,内容仍然共享,两个缓冲区的索引各自独立。适合「同一段数据,一个线程读前半,另一个线程读后半」这种场景,但 ByteBuffer 本身不是线程安全的,索引独立不等于并发安全。

还有一个容易踩的点:slice() 出来的缓冲区如果被长期持有,原缓冲区就不能释放回池。linkd 的 BufferPool.release 只接受容量等于池规格的缓冲区,slice 的容量通常更小,会被直接丢弃;但原缓冲区如果已经 release 并被别的连接复用,slice 看到的内容会变。要长期保留一小段数据,应该复制成 byte[],不要留着 slice。

在 linkd 里落地

5.1 的 BufferPool 先用堆内缓冲区,把索引管理跑通;5.2 再换成直接内存。池子的职责只有两个:借出时处于写模式,归还时清掉旧状态。

package com.plume.linkd.core;

import java.nio.ByteBuffer;
import java.util.ArrayDeque;
import java.util.Deque;

public final class BufferPool {
    private final int bufferSize;
    private final Deque<ByteBuffer> free = new ArrayDeque<>();

    public BufferPool(int bufferSize) {
        this.bufferSize = bufferSize;
    }

    public ByteBuffer acquire(int minCapacity) {
        if (minCapacity > bufferSize) {
            return ByteBuffer.allocate(minCapacity);
        }
        ByteBuffer buffer = free.pollFirst();
        if (buffer == null) {
            buffer = ByteBuffer.allocate(bufferSize);
        }
        buffer.clear();
        return buffer;
    }

    public void release(ByteBuffer buffer) {
        if (buffer == null || buffer.isReadOnly()
                || buffer.capacity() != bufferSize) {
            return;
        }
        buffer.clear();
        free.addFirst(buffer);
    }
}

acquire 返回写模式:clear() 把 position 设 0、limit 设成容量。release 先 clear() 再入栈,避免把一个还停在读模式、limit 很小的缓冲区借给下一个连接。容量不等于池规格的缓冲区不入池,避免池里出现大小不一的块,降低复用率。

这个版本不是线程安全的,只适合单事件循环线程。linkd 在 6.x 会把 BufferPool 绑到事件循环线程上,跨线程归还的问题到 12.3 再展开。

解码器的契约要和调用方对齐。linkd-core 的 FrameDecoder 在 4.2 已经定成一个只有方法的接口:List<byte[]> decode(ByteBuffer in)。接口只约束签名,四条契约靠实现和调用方共同遵守:只消费完整帧、半包返回空列表、一次可以返回多帧、返回 byte[] 拷贝。4.2 的 LengthFieldFrameDecoder 在内部先 mark 再读长度,帧体不足就 reset 回帧头;如果它不 reset,position 停在帧体中间,下一次 compact 会把半截帧体搬到开头。

调用方负责 flip 和 compact:

int n = channel.read(in);
if (n < 0) {
    connection.close();
    return;
}
if (n == 0) {
    return;
}
in.flip();
List<byte[]> frames = decoder.decode(in);
in.compact();
for (byte[] frame : frames) {
    handler.onFrame(frame);
}

假设缓冲区容量 65536,这次 read 带回 37 字节,其中 30 字节是一个完整帧,后 7 字节是下一帧的开头。状态变化是:

read 之后      pos=37   lim=65536   (写模式)
flip 之后      pos=0    lim=37      (读模式)
decode 消费 30 pos=30   lim=37
compact 之后   pos=7    lim=65536   (写模式,保留 7 字节)

如果最后一步写成 clear(),那 7 字节就没了;如果写成第二次 flip(),limit 会变成 30,解码器只看到已经消费完的区间,下一帧的开头反而被排除在外。compact 是半包场景唯一正确的收尾。

实现里如果不 reset,position 停在帧体中间;调用方再 compact,半截帧体会被搬到开头,帧头丢失。

常见错误与边界

错误一:读之前不 flip。 read 之后 position 在末尾,remaining 为 0,get() 什么也读不到,也不抛异常。日志里表现为「明明 read 返回了 37,解码器却收到 0 帧」。先 flip 再读。

错误二:半包之后 clear()。 未读字节被移出可读区间,下一次 read 从索引 0 覆盖。用 compact() 保留剩余字节。

错误三:flip() 两次。 第二次把 limit 设成当前 position,后半段数据被排除。本机 6 字节缓冲区在消费 1 字节后二次 flip,remaining 只剩 1。

错误四:在 flip() 之后直接 put。 写位置回到 0,会覆盖已读数据;写满 limit 抛 BufferOverflowException。要写就先 compact() 或 clear()。

错误五:对直接缓冲区调用 array()。 ByteBuffer.allocateDirect 没有可访问的 Java 数组,hasArray() 返回 false,array() 抛 UnsupportedOperationException。本机输出里 direct.hasArray=false;只读堆缓冲区的 hasArray() 也是 false。

错误六:用 equals() 比较缓冲区内容。 ByteBuffer.equals 只比较 [position, limit) 的剩余字节,不比较 capacity,也不比较绝对位置。两个内容相同但 position 不同的缓冲区可能不相等。要比较完整内容,先 duplicate() 再 clear(),或者直接比较 byte[]。

边界:ByteBuffer 不是线程安全的。 绝对 get(int) / put(int, byte) 不移动 position,但和相对读写混用时仍然没有同步语义。一个缓冲区同一时刻只应有一个线程操作。

边界:position(int) 不能超过 limit。 超过会抛 IllegalArgumentException;要把 limit 调大,先 limit(int),且不能超过 capacity。limit(int) 把 limit 调到 position 之前时,position 会被一并调整。

小结

  • capacity 固定,limit 是读写边界,position 是下一个读写位置;始终满足 0 <= position <= limit <= capacity。
  • flip() 只做 limit=position; position=0,不反转字节;本机 12 字节写入后 flip 得到 pos=0 lim=12 cap=16。
  • rewind 只回 position,clear 丢弃未读数据,compact 把未读数据搬到开头并回到写模式。
  • 半包场景必须 compact;本机示例保留 7 字节,clear 会把这 7 字节丢掉。
  • 读越过 limit 抛 BufferUnderflowException,写越过 limit 抛 BufferOverflowException,position==limit 时相对 get 静默返回空。

索引规则跑通之后,下一个问题是缓冲区放在哪里。堆内数组好分配、好调试,但每次交给 Channel 都要多一次拷贝;下一节把缓冲区搬到堆外,同时处理它带来的回收和上限问题。

阅读导航:上一节:4.3 编解码器设计与错误处理 · 下一节:5.2 DirectBuffer 与堆外内存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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