ByteBuffer 是 Java NIO 中最核心的缓冲区类,用于在通道(Channel)和应用程序之间传输数据。
一、快速入门
先看一个使用 FileChannel 配合 ByteBuffer 读取文件的例子。假设有一个文本文件 data.txt,内容为 1234567890abcd(共14个字节):
/**
* 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 的标准流程如下:
- 向 buffer 写入数据(如调用
channel.read(buffer)) - 调用
flip()切换至读模式 - 从 buffer 读取数据(如调用
buffer.get()) - 调用
clear()或compact()切换至写模式 - 重复以上步骤
这个流程非常重要,忘记调用 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) 时,底层实际上发生了两步:
- 在 position 位置写入数据:hb[position] = b;
- 更新指针:position = position + 1;
如果线程 A 和 线程 B 同时对一个 Buffer 进行操作,会出现以下典型问题:
A. 竞态条件 (Race Condition)
假设 position 当前是 5:
- 线程 A 准备写数据,它拿到了 position = 5。
- 线程 B 此时也进来了,它也拿到了 position = 5。
- 线程 A 在位置 5 写入 'A',并将 position 改为 6。
- 线程 B 在位置 5 写入 'B'(覆盖了 A 的数据),也将 position 改为 6。
结果: 线程 A 的数据丢失了,且 position 只增加了一次,导致后续逻辑全部错乱。
B. 读写状态破坏
Buffer 的设计思路是“切换模式”。写完后必须调用 flip() 切换到读模式。
- 如果线程 A 正在读取数据(position 正在增加)。
- 线程 B 突然调用了 clear() 或 compact() 准备写入。
结果: 线程 A 读取到的数据瞬间变成了脏数据,甚至可能发生索引越界异常(IndexOutOfBoundsException)。
四、常用方法详解
4.1 分配空间
// 在堆内存分配
ByteBuffer heapBuffer = ByteBuffer.allocate(16);
// 在直接内存分配(性能更高,但分配和释放成本也更高)
ByteBuffer directBuffer = ByteBuffer.allocateDirect(16);直接内存(Direct Memory)的分配和释放成本高,是因为它需要跨越 JVM 的管理边界,直接与操作系统打交道。
- 堆内存:只是在 JVM 预先申请好的内存池中划出一块空间。
- 直接内存:需要调用操作系统的内核函数(如 Linux 下的 malloc() 或 mmap())。这涉及到用户态与内核态的切换,这种上下文切换的开销远比简单的内存指针移动要大。而且,操作系统为了安全,在把一块物理内存交给进程之前,通常需要进行清零操作,以防止前一个进程的数据泄露。
4.2 写入数据
// 方式一:从通道读取
int readBytes = channel.read(buffer);
// 方式二:直接put
buffer.put((byte) 127);
buffer.put(new byte[]{1, 2, 3});4.3 读取数据
// 方式一:写入通道
int writeBytes = channel.write(buffer);
// 方式二:直接get
byte b = buffer.get(); // 读取并移动position
byte b2 = buffer.get(5); // 读取索引5的数据,不移动position4.4 重复读取
// rewind:将position重置为0,可重新读取
buffer.rewind();
// mark + reset:标记当前位置,之后可返回
buffer.mark(); // 在当前position做标记
// ... 读取一些数据 ...
buffer.reset(); // 返回到mark的位置需要注意的是,rewind() 和 flip() 都会清除 mark 标记。
4.5 字符串与 ByteBuffer 互转
// 字符串 → 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 中。这在处理具有固定格式的数据时非常有用,比如协议头和协议体分开存储。
@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:
@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 作为消息分隔符:
- Hello,world\n
- I'm zhangsan\n
- How are you?\n
接收端通过 ByteBuffer 分两次读取到了如下错乱数据:
第一次读取:"Hello,world\nI'm zhangsan\nHo"(含两个完整包和一个半包)
第二次读取:"w are you?\n"(半包的剩余部分)
【要求】 编写程序,模拟处理上述两个 ByteBuffer。要求通过 ByteBuffer 的 API 手动解析出完整的数据包,并确保即便在发生半包的情况下,数据也能被正确还原并打印。
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();
}
}