Java NIO ByteBuffer 实战:flip/clear/compact 三个绕晕人的方法与 position/limit 心智模型

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 的读写循环再也不会写错。

相关推荐
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之短信服务多供应商动态切换(阿里云与LeanCloud)
java·spring·阿里云
Doubbbbbbble云1 小时前
区间合并问题的常见算法模式与优化思路4
java·数据结构·算法
小溪学编程1 小时前
AQS 原理详解:从 CLH 队列到 ReentrantLock 的实现
java·开发语言
持敬chijing1 小时前
Web前后端开发-JavaScript 入门教程
开发语言·前端·javascript
专业程序开发源1 小时前
springbootLivehouse票务系统-计算机课程设计、毕业设计
vue.js·spring boot·后端·python·django·课程设计·pygame
PC2005_cloud1 小时前
Rust学习笔记:所有权、借用与生命周期——Rust的核心机制
前端·后端
蜗牛互联网1 小时前
语音AI开始边听边说,改变的不只是响应速度
java·人工智能·后端·语音识别
2601_966949651 小时前
从轮询到策略消费:量化系统如何高效处理五档盘口数据
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
Mav1 小时前
[个人学习记录]从零构建高性能 LLM 推理网关:Go 语言 SSE 流式转发、级联取消与背压
后端