Skip to content

ByteBuffer 是 Java NIO 中最核心的缓冲区类,用于在通道(Channel)和应用程序之间传输数据。

一、快速入门

先看一个使用 FileChannel 配合 ByteBuffer 读取文件的例子。假设有一个文本文件 data.txt,内容为 1234567890abcd(共14个字节):

java
/**
 * flip() 切换到读模式:
 * position = 0 → 从头开始读;
 * limit = 之前的 position → 只读有效数据,不会读到垃圾;
 *
 * clear() 切换到写模式:
 * position = 0 → 从头开始写;
 * limit = capacity → 可以写满整个缓冲区;
 *
 * compact() 切换到写模式:是把未读完的部分向前压缩,然后切换至写模式
 */
@Slf4j
public class TestByteBuffer {
    public static void main(String[] args) {
        try (FileInputStream inputStream = new FileInputStream("data.txt");
             FileChannel channel = inputStream.getChannel()) {
            ByteBuffer buffer = ByteBuffer.allocate(10);
            while (true) {
                // channel.read方法返回值是读取的字节数,如果返回-1,则表示已经读取完文件
                int len = channel.read(buffer);
                if (len == -1) {
                    break;
                }
                log.info("读取到字节数:{}", len);
                buffer.flip(); // 读模式
                while (buffer.hasRemaining()) {
                    byte b = buffer.get(); // 从buffer中读取1个字节数据
                    log.info("{}", (char) b);
                }
                buffer.clear(); // 写模式
            }
        } catch (IOException e) {
            log.error("读取文件时发生异常", e);
        }
    }
}

由于缓冲区只有10字节,而文件有14字节,所以需要分两次读取。第一次读取10字节(1234567890),第二次读取4字节(abcd),第三次返回-1表示文件读取完毕。

二、ByteBuffer 的正确使用姿势

使用 ByteBuffer 的标准流程如下:

  1. 向 buffer 写入数据(如调用 channel.read(buffer)
  2. 调用 flip() 切换至读模式
  3. 从 buffer 读取数据(如调用 buffer.get()
  4. 调用 clear()compact() 切换至写模式
  5. 重复以上步骤

这个流程非常重要,忘记调用 flip() 是新手最常犯的错误,这会导致读取不到数据或者读到脏数据。

三、ByteBuffer 的内部结构

理解 ByteBuffer 的关键在于掌握它的三个核心属性:

属性含义
capacity缓冲区容量,创建时确定,不可更改
position当前读写位置
limit读写限制位置

3.1 写模式下的状态变化

刚创建一个容量为10的 ByteBuffer 时,position 为0,limit 等于 capacity(即10)。写入4个字节后,position 移动到4,limit 保持不变。此时 buffer 的状态是:已写入的数据在 [0, position) 区间,可继续写入的空间在 [position, limit) 区间。

3.2 flip() 切换到读模式

调用 flip() 后,limit 被设置为当前 position 的值,position 被重置为0。这样 [0, limit) 区间就是可读取的数据范围。

3.3 读取数据后的状态

每次调用 get() 读取一个字节,position 就向后移动一位。当 position 等于 limit 时,hasRemaining() 返回 false,表示数据已读完。

3.4 clear() 与 compact() 的区别

clear() 方法简单地将 position 重置为0,limit 重置为 capacity,相当于"清空"缓冲区(实际数据还在,只是被忽略了)。

compact() 方法更智能一些:它会将未读完的数据压缩到缓冲区开头,然后将 position 设置到未读数据之后,limit 重置为 capacity。这样既保留了未读数据,又可以继续写入新数据。

IMPORTANT

ByteBuffer是线程不安全的。

Buffer 内部有四个关键的索引,它们决定了当前“读到哪”或“写到哪”:

  • position: 下一个要读写的元素索引。
  • limit: 缓冲区中不可操作的第一个元素索引。
  • capacity: 缓冲区的最大容量。
  • mark: 备忘位置。

绝大多数 Buffer 操作都是“复合操作”。例如执行 buffer.put(byte b) 时,底层实际上发生了两步:

  1. 在 position 位置写入数据:hb[position] = b;
  2. 更新指针:position = position + 1;

如果线程 A 和 线程 B 同时对一个 Buffer 进行操作,会出现以下典型问题:

A. 竞态条件 (Race Condition)

假设 position 当前是 5:

  1. 线程 A 准备写数据,它拿到了 position = 5。
  2. 线程 B 此时也进来了,它也拿到了 position = 5。
  3. 线程 A 在位置 5 写入 'A',并将 position 改为 6。
  4. 线程 B 在位置 5 写入 'B'(覆盖了 A 的数据),也将 position 改为 6。

结果: 线程 A 的数据丢失了,且 position 只增加了一次,导致后续逻辑全部错乱。

B. 读写状态破坏

Buffer 的设计思路是“切换模式”。写完后必须调用 flip() 切换到读模式。

  • 如果线程 A 正在读取数据(position 正在增加)。
  • 线程 B 突然调用了 clear() 或 compact() 准备写入。

结果: 线程 A 读取到的数据瞬间变成了脏数据,甚至可能发生索引越界异常(IndexOutOfBoundsException)。

四、常用方法详解

4.1 分配空间

java
// 在堆内存分配
ByteBuffer heapBuffer = ByteBuffer.allocate(16);

// 在直接内存分配(性能更高,但分配和释放成本也更高)
ByteBuffer directBuffer = ByteBuffer.allocateDirect(16);

直接内存(Direct Memory)的分配和释放成本高,是因为它需要跨越 JVM 的管理边界,直接与操作系统打交道。

  • 堆内存:只是在 JVM 预先申请好的内存池中划出一块空间。
  • 直接内存:需要调用操作系统的内核函数(如 Linux 下的 malloc() 或 mmap())。这涉及到用户态与内核态的切换,这种上下文切换的开销远比简单的内存指针移动要大。而且,操作系统为了安全,在把一块物理内存交给进程之前,通常需要进行清零操作,以防止前一个进程的数据泄露。

4.2 写入数据

java
// 方式一:从通道读取
int readBytes = channel.read(buffer);

// 方式二:直接put
buffer.put((byte) 127);
buffer.put(new byte[]{1, 2, 3});

4.3 读取数据

java
// 方式一:写入通道
int writeBytes = channel.write(buffer);

// 方式二:直接get
byte b = buffer.get();        // 读取并移动position
byte b2 = buffer.get(5);      // 读取索引5的数据,不移动position

4.4 重复读取

java
// rewind:将position重置为0,可重新读取
buffer.rewind();

// mark + reset:标记当前位置,之后可返回
buffer.mark();      // 在当前position做标记
// ... 读取一些数据 ...
buffer.reset();     // 返回到mark的位置

需要注意的是,rewind()flip() 都会清除 mark 标记。

4.5 字符串与 ByteBuffer 互转

java
// 字符串 → ByteBuffer
ByteBuffer buffer = StandardCharsets.UTF_8.encode("你好");

// ByteBuffer → 字符串
CharBuffer charBuffer = StandardCharsets.UTF_8.decode(buffer);
String str = charBuffer.toString();

使用 encode() 方法得到的 ByteBuffer 已经处于读模式,可以直接读取或解码。

4.6 Scattering Reads

Scattering Reads(分散读取)允许将一个 Channel 中的数据分散到多个 Buffer 中。这在处理具有固定格式的数据时非常有用,比如协议头和协议体分开存储。

java
@Slf4j
public class TestScatteringReads {
    public static void main(String[] args) {
        // 假设文件内容为 "onetwothree"(共11个字节)
        try (FileChannel channel = new RandomAccessFile("words.txt", "rw").getChannel()) {
            ByteBuffer header = ByteBuffer.allocate(3);  // 存储 "one"
            ByteBuffer body = ByteBuffer.allocate(3);    // 存储 "two"
            ByteBuffer footer = ByteBuffer.allocate(5);  // 存储 "three"
            
            // 将数据分散读取到多个buffer中
            channel.read(new ByteBuffer[]{header, body, footer});
            
            // 切换到读模式
            header.flip();
            body.flip();
            footer.flip();
            
            log.info("header: {}", StandardCharsets.UTF_8.decode(header));
            log.info("body: {}", StandardCharsets.UTF_8.decode(body));
            log.info("footer: {}", StandardCharsets.UTF_8.decode(footer));
        } catch (IOException e) {
            log.error("读取文件时发生异常", e);
        }
    }
}

输出结果:

header: one
body: two
footer: three

工作原理:Channel 会按顺序依次填充 Buffer 数组中的每个 Buffer。第一个 Buffer 填满后,才会开始填充第二个,以此类推。

适用场景

  • 解析固定长度头部的消息协议(如 HTTP 头部与正文分离)
  • 读取定长字段的二进制文件格式
  • 将数据直接分发到不同的处理模块

4.7 Gathering Writes

与 Scattering Reads 相反,Gathering Writes(聚集写入)允许将多个 Buffer 的数据聚集后写入一个 Channel:

java
@Slf4j
public class TestGatheringWrites {
    public static void main(String[] args) {
        try (FileChannel channel = new RandomAccessFile("output.txt", "rw").getChannel()) {
            ByteBuffer header = StandardCharsets.UTF_8.encode("Header|");
            ByteBuffer body = StandardCharsets.UTF_8.encode("This is the body|");
            ByteBuffer footer = StandardCharsets.UTF_8.encode("Footer");
            
            // 将多个buffer的数据聚集写入channel
            // 注意:encode()返回的buffer已经是读模式
            channel.write(new ByteBuffer[]{header, body, footer});
            
            log.info("写入完成");
        } catch (IOException e) {
            log.error("写入文件时发生异常", e);
        }
    }
}

执行后,output.txt 的内容为:Header|This is the body|Footer

优势

  • 避免了先将所有数据拼接到一个大 Buffer 中再写入的开销
  • 减少内存拷贝,提升 I/O 性能
  • 代码结构更清晰,不同部分的数据可以独立构建

题目:NIO 缓冲区粘包与半包处理

题目描述】 在基于流(Stream)的网络通信中,由于底层传输机制,接收方读取到的数据边界可能与发送方定义的逻辑消息边界不一致,产生粘包(多个消息被合并)与半包(单个消息被截断)现象。

现有三条原始逻辑数据,以 \n 作为消息分隔符:

  1. Hello,world\n
  2. I'm zhangsan\n
  3. How are you?\n

接收端通过 ByteBuffer 分两次读取到了如下错乱数据:

第一次读取:"Hello,world\nI'm zhangsan\nHo"(含两个完整包和一个半包)

第二次读取:"w are you?\n"(半包的剩余部分)

要求】 编写程序,模拟处理上述两个 ByteBuffer。要求通过 ByteBuffer 的 API 手动解析出完整的数据包,并确保即便在发生半包的情况下,数据也能被正确还原并打印。

java
import lombok.extern.slf4j.Slf4j;

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

@Slf4j
public class ByteBufferSplitQuiz {
    public static void main(String[] args) {
        // 模拟两个错乱接收的 ByteBuffer
        ByteBuffer source = ByteBuffer.allocate(32);
        
        // 1. 模拟第一部分数据
        source.put("Hello,world\nI'm zhangsan\nHo".getBytes());
        decode(source);
        
        // 2. 模拟第二部分数据
        source.put("w are you?\n".getBytes());
        decode(source);
    }

    private static void decode(ByteBuffer source) {
        // 切换到读模式
        source.flip();
        
        for (int i = 0; i < source.limit(); i++) {
            // 找到一条完整的数据
            if (source.get(i) == '\n') {
                // 计算这条数据的长度(包含 \n)
                int length = i + 1 - source.position();
                ByteBuffer target = ByteBuffer.allocate(length);
                
                // 从 source 读入到 target
                for (int j = 0; j < length; j++) {
                    target.put(source.get());
                }
                
                // 打印结果
                target.flip();
                log.info("提取到数据: {}", StandardCharsets.UTF_8.decode(target).toString().replace("\n", ""));
            }
        }
        
        // 关键:未读完的部分(半包)需要移动到 Buffer 开头,下次读取继续追加
        source.compact();
    }
}