本节目标:把
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,整个缓冲区都可写。put12 字节后position=12,remaining=4,还能再写 4 字节。flip把limit设为 12、position设为 0,12 字节变成可读;capacity不变。get4 字节后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)。它不反转字节顺序,也不移动数据。名字来自「把缓冲区从写模式翻到读模式」这个动作,不是「翻转内容」。
四个最常用的状态切换方法:
| 方法 | position | limit | 未读数据 | 典型用途 |
|---|---|---|---|---|
flip() | 0 | 原来的 position | 保留 | 写完转读 |
rewind() | 0 | 不变 | 保留 | 重读同一段 |
clear() | 0 | capacity | 丢弃 | 整段处理完,重新写 |
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
推演一下:
put6 字节后position=6、limit=8。- 第一次
flip后position=0、limit=6,6 字节可读。 get()消费 1 字节,position=1、limit=6。- 第二次
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 与堆外内存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。