Buffer 缓冲区
你有没有想过:NIO 的「缓冲区」到底是怎么工作的?
当你往一个 ByteBuffer 写入数据时,JVM 是怎么知道哪些是「已读」的数据、哪些是「未读」的数据?position 和 limit 这两个指针到底在玩什么把戏?
如果你对这些问题感到困惑,这篇文章就是为你准备的。
Buffer 的本质:数据的中转站
在 BIO 的世界里,程序直接对着流读写:
BIO:应用程序 → 流(逐字节) → 磁盘数据像水管里的水流,读就是倒进去,写就是抽出来。
而 NIO 引入了 Buffer 这个中间层:
NIO:应用程序 → Buffer ←→ Channel ←→ 磁盘Buffer 就像一个中转仓库。数据从磁盘来,先放到仓库里,应用程序慢慢从仓库取;应用程序产出数据,先存到仓库,再统一送到磁盘。
这样设计的好处是什么?读写解耦。你可以一次性往 Buffer 写大量数据,然后慢慢处理;也可以预读数据到 Buffer,让程序随时有数据可用。
三个指针:Buffer 的精髓
理解 Buffer,关键在于理解这三个指针:position、limit、capacity。
┌─────────────────────────────────────────────────────┐
│ ByteBuffer 结构 │
├─────────────────────────────────────────────────────┤
│ │
│ 数组: ┌────┬────┬────┬────┬────┬────┬────┬────┐ │
│ │ D1 │ D2 │ D3 │ D4 │ │ │ │ │ │
│ └────┴────┴────┴────┴────┴────┴────┴────┘ │
│ ▲ ▲ │
│ │ │ │
│ position limit │
│ │
│ capacity = 8(总容量,创建时指定) │
│ position = 4(下一个读写位置的索引) │
│ limit = 6(第一个不能读写的索引,即有效数据末尾+1) │
└─────────────────────────────────────────────────────┘让我用更通俗的语言解释:
capacity(容量):仓库的总大小,创建时就固定了。比如分配
ByteBuffer.allocate(8),capacity 就是 8。position(位置):当前读写到哪个位置了。写模式下从 0 开始,每写入一个字节就 +1;读模式下也是从 0 开始。
limit(限制):第一个不能读写的位置。它不是「已读数据」的边界,而是「还能用」的边界。
两种模式的切换
这是最核心的操作——flip():
// 写模式:刚创建 Buffer
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.put((byte) 1);
buffer.put((byte) 2);
// 现在:position=2, limit=8, capacity=8
// 只有位置 2-7 是空的,可以继续写
// 切换到读模式
buffer.flip();
// 现在:position=0, limit=2, capacity=8
// 位置 0-1 是有效数据,可以读了
// 读完数据后重置
buffer.clear();
// 现在:position=0, limit=8, capacity=8
// 回到写模式,但注意——数据还在数组里!clear() 不是「清空数据」,而是「重置指针」。数组里的数据还在,只是 position 归零了,可以重新写入。
读写操作:两种方式
写数据
ByteBuffer buffer = ByteBuffer.allocate(8);
// 方式 1:通过 Channel 读入
channel.read(buffer);
// 方式 2:直接 put
buffer.put((byte) 1);
buffer.put((byte) 2);
buffer.put("Hello".getBytes());
// 写完后切换到读模式
buffer.flip();读数据
// 方式 1:通过 Channel 写出
channel.write(buffer);
// 方式 2:直接 get
while (buffer.hasRemaining()) {
byte b = buffer.get();
System.out.println(b);
}compact():边读边写的秘诀
有时候你读了一部分数据,还想保留未读的部分继续处理。这时 compact() 就派上用场了:
// 场景:Buffer 里已经有 10 字节数据,读了 6 字节
buffer.position(6); // position 移到 6
buffer.compact(); // 把已读的 6 字节移到数组开头
// 现在:position = 4(原 limit - 原 position),limit = capacity
// 未读的 4 字节在数组开头,可以继续写简单说:compact() 把已读数据压缩走,让未读数据靠前排列,然后把 position 移到可用空间的开头。
其他常用方法
| 方法 | 说明 |
|---|---|
allocate(int capacity) | 在 JVM 堆上分配缓冲区 |
allocateDirect(int capacity) | 在操作系统内存上分配直接缓冲区 |
put(byte b) | 写 1 字节 |
get() | 读 1 字节 |
flip() | 切换读模式(limit=position, position=0) |
clear() | 重置缓冲区(position=0, limit=capacity) |
compact() | 压缩已读数据,继续写入 |
rewind() | 重绕(position=0,可重新读) |
hasRemaining() | 是否还有未读数据 |
remaining() | 剩余可读字节数 |
mark() / reset() | 标记位置 / 重置到标记位置 |
堆缓冲区 vs 直接缓冲区
// 堆缓冲区:JVM 堆内存,GC 会回收
ByteBuffer heapBuffer = ByteBuffer.allocate(8192);
// 直接缓冲区:操作系统内存,绕过 JVM 堆
ByteBuffer directBuffer = ByteBuffer.allocateDirect(8192);| 对比 | 堆缓冲区 | 直接缓冲区 |
|---|---|---|
| 内存位置 | JVM 堆 | 操作系统内存 |
| 创建速度 | 快 | 慢(需要 JNI 调用) |
| 读写性能 | 一般 | 高 20%~50% |
| GC | GC 自动回收 | 需手动释放(close 时释放) |
| 适用场景 | 临时数据、频繁创建销毁 | 大文件、长期持有 |
选择建议:如果不是对性能有极致追求,用 allocate() 就行。直接缓冲区虽然快,但创建成本高,而且需要手动管理生命周期。
其他类型的 Buffer
Buffer 不只有 ByteBuffer:
CharBuffer charBuffer = CharBuffer.allocate(1024);
IntBuffer intBuffer = IntBuffer.allocate(256);
LongBuffer longBuffer = LongBuffer.allocate(128);
// 操作方式和 ByteBuffer 完全一样,只是元素类型不同记住这个口诀:
写用 put,读用 get;写完 flip,读完 clear;边读边写用 compact。
这就是 Buffer 的全部精髓。
下一节,我们来聊聊 Buffer 的好搭档——Channel。
