Skip to content

Buffer 缓冲区

你有没有想过:NIO 的「缓冲区」到底是怎么工作的?

当你往一个 ByteBuffer 写入数据时,JVM 是怎么知道哪些是「已读」的数据、哪些是「未读」的数据?position 和 limit 这两个指针到底在玩什么把戏?

如果你对这些问题感到困惑,这篇文章就是为你准备的。

Buffer 的本质:数据的中转站

在 BIO 的世界里,程序直接对着流读写:

BIO:应用程序 → 流(逐字节) → 磁盘

数据像水管里的水流,读就是倒进去,写就是抽出来。

而 NIO 引入了 Buffer 这个中间层:

NIO:应用程序 → Buffer ←→ Channel ←→ 磁盘

Buffer 就像一个中转仓库。数据从磁盘来,先放到仓库里,应用程序慢慢从仓库取;应用程序产出数据,先存到仓库,再统一送到磁盘。

这样设计的好处是什么?读写解耦。你可以一次性往 Buffer 写大量数据,然后慢慢处理;也可以预读数据到 Buffer,让程序随时有数据可用。

三个指针:Buffer 的精髓

理解 Buffer,关键在于理解这三个指针:positionlimitcapacity

┌─────────────────────────────────────────────────────┐
│  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()

java
// 写模式:刚创建 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 归零了,可以重新写入。

读写操作:两种方式

写数据

java
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();

读数据

java
// 方式 1:通过 Channel 写出
channel.write(buffer);

// 方式 2:直接 get
while (buffer.hasRemaining()) {
    byte b = buffer.get();
    System.out.println(b);
}

compact():边读边写的秘诀

有时候你读了一部分数据,还想保留未读的部分继续处理。这时 compact() 就派上用场了:

java
// 场景: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 直接缓冲区

java
// 堆缓冲区:JVM 堆内存,GC 会回收
ByteBuffer heapBuffer = ByteBuffer.allocate(8192);

// 直接缓冲区:操作系统内存,绕过 JVM 堆
ByteBuffer directBuffer = ByteBuffer.allocateDirect(8192);
对比堆缓冲区直接缓冲区
内存位置JVM 堆操作系统内存
创建速度慢(需要 JNI 调用)
读写性能一般高 20%~50%
GCGC 自动回收需手动释放(close 时释放)
适用场景临时数据、频繁创建销毁大文件、长期持有

选择建议:如果不是对性能有极致追求,用 allocate() 就行。直接缓冲区虽然快,但创建成本高,而且需要手动管理生命周期。

其他类型的 Buffer

Buffer 不只有 ByteBuffer:

java
CharBuffer charBuffer = CharBuffer.allocate(1024);
IntBuffer intBuffer = IntBuffer.allocate(256);
LongBuffer longBuffer = LongBuffer.allocate(128);
// 操作方式和 ByteBuffer 完全一样,只是元素类型不同

记住这个口诀:

写用 put,读用 get;写完 flip,读完 clear;边读边写用 compact。

这就是 Buffer 的全部精髓。

下一节,我们来聊聊 Buffer 的好搭档——Channel。

基于 VitePress 构建