Java NIO ByteBuffer 实战:flip/clear/compact 三个绕晕人的方法与 position/limit 心智模型
第一次用 ByteBuffer 读网络数据的人,几乎都会栽在同一个地方:明明把数据 put 进去了,get 出来却是一堆 0;或者从 Channel 读完准备解析,结果 remaining() 返回 0,什么都没读到。翻 API 文档看到 flip()、clear()、compact() 三个方法,注释写得云里雾里,越看越懵。
问题的根源不在这三个方法,而在于你没建立起 position / limit / capacity 这三个指针的心智模型。把这个模型搞清楚,三个方法就是它的自然推论,再也不用死记。
先把三个指针钉死
ByteBuffer 内部就是一个数组,加上三个 int 指针:
capacity:数组总容量,创建后不变。position:下一个要读或写的下标。limit:读写的边界,position不能越过它。
三者恒满足 0 <= position <= limit <= capacity。所有操作本质上都是在挪这三个指针,理解了这点,后面全通。
先看一段最朴素的、会出 bug 的写法:
java
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class BufferWrong {
public static void main(String[] args) {
ByteBuffer buf = ByteBuffer.allocate(16);
// 写入 5 个字节,position 从 0 挪到 5,limit 仍是 16
buf.put("hello".getBytes(StandardCharsets.UTF_8));
// 直接读:从 position=5 开始读到 limit=16,读出的全是没写过的 0
byte[] out = new byte[buf.remaining()]; // remaining = limit - position = 11
buf.get(out);
System.out.println("读到:[" + new String(out, StandardCharsets.UTF_8) + "]");
// 输出:读到:[ ...一堆空字符...],根本不是 hello
}
}
写完之后 position=5,此时 remaining()(即 limit - position)是 11,你从下标 5 一直读到 15,读的全是没写过的区域。这就是「写完直接读读不到」的真相。
flip():从写模式切到读模式
要把「刚写进去的 5 个字节」读出来,你需要让 limit 指到写入结束的位置(5),再把 position 归零。这正是 flip() 干的事:
java
public final Buffer flip() {
limit = position; // 边界设为当前写到的位置
position = 0; // 读指针回到开头
return this;
}
一句话记忆:flip = 把 limit 钉在数据末尾,position 拉回开头。改对后:
java
ByteBuffer buf = ByteBuffer.allocate(16);
buf.put("hello".getBytes(StandardCharsets.UTF_8)); // position=5, limit=16
buf.flip(); // position=0, limit=5,现在 remaining=5
byte[] out = new byte[buf.remaining()];
buf.get(out); // 读走 5 个字节,position 挪回 5
System.out.println("读到:[" + new String(out, StandardCharsets.UTF_8) + "]");
// 读到:[hello]
「写完要读,先 flip」 ------这是 NIO 的第一条肌肉记忆。从 Channel.read() 读进 buffer 后同理,因为 read 也是往 buffer 里写,读它之前必须 flip。
clear():读完回到写模式(但没清数据)
数据读完了,想复用这个 buffer 再写一批,就调 clear():
java
public final Buffer clear() {
position = 0;
limit = capacity; // 边界重新拉回容量顶
return this;
}
注意看,clear() 根本没动数组里的数据 ,它只是把两个指针复位。名字叫 clear,很有迷惑性------它清的是指针,不是内容。老数据还在,只是接下来的 put 会从下标 0 覆盖它们。
一个典型的「读文件写到另一个 Channel」循环,clear 和 flip 配对出现:
java
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
public class CopyFile {
public static void main(String[] args) throws Exception {
try (FileChannel in = new FileInputStream("src.dat").getChannel();
FileChannel out = new FileOutputStream("dst.dat").getChannel()) {
ByteBuffer buf = ByteBuffer.allocate(1024);
while (in.read(buf) != -1) { // read 往 buf 里写,position 前进
buf.flip(); // 切读模式:limit=写到哪,position=0
out.write(buf); // write 从 buf 里读,position 前进到 limit
buf.clear(); // 切回写模式,准备下一轮 read
}
}
}
}
这个 read→flip→write→clear 的四步循环,是 NIO 文件/网络拷贝的标准骨架。为什么这里能用 clear 而不是 compact?因为 out.write(buf) 保证把 buffer 里的数据全写光了(position 追平 limit),没有残留,直接 clear 复位最省事。
compact():只清「已读走的」,保留没读完的
clear 有个隐患:如果一次没把 buffer 读干净就复位,剩下的数据全丢了。网络编程里这种情况极常见------你收到半个数据包,解析器只能处理完整包,剩下的半个必须留到下次和新数据拼一起。这时候要用 compact():
java
public ByteBuffer compact() {
// 把 position..limit 之间还没读的数据,搬到数组最前面
System.arraycopy(hb, position, hb, 0, remaining());
position = remaining(); // 写指针接在残留数据后面
limit = capacity; // 边界拉回顶,可以继续写
return this;
}
一句话记忆:compact = 把没读完的数据挪到开头,position 停在它后面,准备追加写。对比一下三者对指针的处理:
| 方法 | position | limit | 数据 | 用途 |
|---|---|---|---|---|
flip() |
→ 0 | → 旧 position | 保留 | 写完转读 |
clear() |
→ 0 | → capacity | 保留(会被覆盖) | 读完转写、丢弃残留 |
compact() |
→ remaining | → capacity | 未读数据搬到开头 | 读完转写、保留残留 |
看一个「粘包」半包处理的例子,体会 compact 的价值:
java
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
public class HalfPacket {
public static void main(String[] args) {
ByteBuffer buf = ByteBuffer.allocate(16);
// 第一次收到 "hello\nwor",只有 hello 是完整行(\n 结尾)
buf.put("hello\nwor".getBytes(StandardCharsets.UTF_8));
buf.flip(); // 转读
// 解析出一整行 hello,读走 6 个字节(含 \n),position=6
byte[] line = new byte[6];
buf.get(line);
System.out.print("解析出:" + new String(line, StandardCharsets.UTF_8));
// 此时 position=6, limit=9,还剩 "wor" 没读完
buf.compact(); // 把 "wor" 搬到开头,position=3,limit=16
System.out.println("compact 后 position=" + buf.position()); // 3
// 第二次收到 "ld\n",接着写,和 "wor" 拼成 "world\n"
buf.put("ld\n".getBytes(StandardCharsets.UTF_8));
buf.flip();
byte[] line2 = new byte[buf.remaining()];
buf.get(line2);
System.out.print("解析出:" + new String(line2, StandardCharsets.UTF_8));
}
}
输出:
解析出:hello
compact 后 position=3
解析出:world
如果这里用 clear() 而不是 compact(),那半个 wor 就被丢了,第二次拼出来的就是错的 ld。「读了一部分、还要继续接收」的场景,永远用 compact 而不是 clear------这是 Netty 等框架处理 TCP 流的底层逻辑。
两个最容易踩的坑
坑一:flip 两次。 flip 之后再 flip,会把 limit 设成当前 position(往往是 0),buffer 直接废了,remaining() 变 0。切模式的方法不能连着调,写→读用 flip、读→写用 clear/compact,一次一个。
坑二:忘了 flip 就丢给 Channel 写。 从别处 put 完数据直接 channel.write(buf),因为 position=limit(都在末尾),write 一个字节都发不出去,还以为是网络问题。记住:任何「转去读它」的动作前,都要先 flip。
再补一个查状态的小技巧,调试时打出来一目了然:
java
static void dump(String tag, ByteBuffer b) {
System.out.printf("%s pos=%d lim=%d cap=%d remaining=%d%n",
tag, b.position(), b.limit(), b.capacity(), b.remaining());
}
在每次 flip/clear/compact 前后各打一行,三个指针怎么挪的立刻看清,比对着文档猜快得多。
小结
ByteBuffer的一切操作都是在挪position/limit/capacity三个指针,先建模型再谈方法。- flip :写→读。
limit=position; position=0。写完要读它,先 flip。 - clear :读→写,丢弃残留。
position=0; limit=capacity。数据没真清,靠后续覆盖。 - compact :读→写,保留没读完的。未读数据搬到开头,
position停其后。粘包/半包必用。 - 两大坑:别连续 flip;转读之前一定先 flip。
一句话记忆点:flip 转读、clear 全推倒重来、compact 留半截接着写------把这三句刻进脑子,NIO 的读写循环再也不会写错。