Buffer 与 Channel:状态与部分读写
一次 read 没读满、一次 write 没写完,都是 Channel 允许的行为。真正需要维护的是:哪些字节已经有效,哪些已经处理,哪些必须留到下次。本文以 JDK 21 为基线,用同一个缓冲区跟踪这些边界。
三个数字决定可操作范围
capacity 是容量,创建后不变;position 是下一次相对读写的位置;limit 是当前操作的排他上界。始终有 0 <= position <= limit <= capacity,remaining() 就是 limit - position。
把 [position, limit) 看成当前允许消费的窗口:接收数据时,它是可写空间;发送或解析数据时,它是可读内容。Buffer 没有一个自动切换的“读模式”布尔字段,模式是程序对边界的约定。ByteBuffer.allocate(8) 初始为 0/8/8,这里的单位是字节,而 CharBuffer 的单位是字符元素。
相对 get()、put() 会推进 position;绝对 get(index)、put(index, value) 不推进,但仍受 limit 限制。向已无剩余空间的缓冲区相对写入会抛出 BufferOverflowException,相对读取则是 BufferUnderflowException。Buffer 官方契约给出了这些边界。
flip、clear、compact 的时序
先写入 ASCII 字节 ABCDE,再消费 AB。下面每一行都承接上一行,最后一行除外,它表示另一次独立选择。
| 操作 | position | limit | capacity | 含义 |
|---|---|---|---|---|
| allocate(8) | 0 | 8 | 8 | 可接收八字节 |
| put(ABCDE) | 5 | 8 | 8 | 有效数据结束于 5 |
| flip() | 0 | 5 | 8 | 准备消费五字节 |
| get 两次 | 2 | 5 | 8 | 尚有 CDE |
| compact() | 3 | 8 | 8 | CDE 搬到开头,尾部可追加 |
| put(F) | 4 | 8 | 8 | 有效数据成为 CDEF |
| flip() | 0 | 4 | 8 | 准备消费 CDEF |
| 若在 get 两次后 clear() | 0 | 8 | 8 | 放弃 CDE 的边界信息 |
flip() 先把 limit 设为原 position,再把 position 归零,因此应在生产数据后、消费数据前调用。连续调用两次会把 limit 变为零;忘记调用则可能从有效数据尾部继续读取,甚至读到旧内容。
clear() 只重置边界,不擦除底层字节;不能把它当作敏感数据清零。只有旧内容可以全部丢弃时才适合使用。compact() 则把 [position, limit) 的剩余内容移到起点,将 position 设为剩余长度、limit 设为 capacity,随后可以接着写。它可能移动数据,并非零成本。这三个操作都会丢弃 mark;rewind() 只将 position 归零、保留 limit,适合重读而非追加。具体行为见 ByteBuffer 官方文档。
Channel 的方向与部分读写
channel.read(buffer) 是从 Channel 读、向 Buffer 写;channel.write(buffer) 是从 Buffer 读、向 Channel 写。两者成功传输 n 字节后,position 都增加 n,limit 不变。
读返回正数代表本次进度;返回 0 表示本次没有进度,非阻塞通道尤其常见;返回 -1 才表示流结束。缓冲区没有剩余空间也不能期待 read 自动扩容。写入也可以只完成一部分,甚至返回 0,必须保留未发送区间。对应契约见 ReadableByteChannel 与 WritableByteChannel。
下面是发送阶段的核心逻辑,调用前已 flip,下一次可写回调继续使用同一个 buffer:
while (buffer.hasRemaining()) {
int n = channel.write(buffer);
if (n == 0) {
return; // 保存 buffer,等待下一次可写事件
}
}
// 全部发送完毕,再回收缓冲区或取下一条消息若在部分发送后 clear,待发数据就失去边界;若再次 flip,则会把已发送前缀重新暴露出来。返回 0 后原地反复重试会空转。在 Selector 中应等待 OP_WRITE,发送完移除该关注;就绪也不保证能写完整条消息,参见 SelectionKey。
半包为什么需要 compact
假设协议是“一字节长度 + 正文”,收到 [3, A] 时不能宣称得到完整消息。flip 后先检查长度字段和正文是否齐全;不足时把 position 恢复到这条消息起点,再 compact,保留 [3, A]。下一次 read 追加 B, C,再 flip 才能解析完整消息。一次 read 也可能包含多条消息,应循环解析完整帧。
若只留下正文而丢掉长度,解析状态就会错位;若完整帧超过容量,compact 后仍无空间,应按协议上限扩容或拒绝,不能无限重试。EOF 到来时也要先处理已有完整帧,并将残缺尾帧作为协议错误处理。字节边界同样不等于字符边界,UTF-8 解码还需维护解码器状态。
处理接收与发送时,最好分别持有缓冲区,明确谁拥有当前位置。解析器应只推进已经确认消费的字节,发送器应只按实际返回值确认进度。复用缓冲区前还要确认没有异步任务继续引用它;否则即使三个数字都合法,内容也可能被下一条消息覆盖。Buffer 本身不提供并发安全保证,跨线程共享需要同步或所有权转移。
可复现的 JDK 21 示例
完整源码在 examples/java-advanced/nio-buffer-channel/BufferChannelDemo.java。它检查 CDE 经 compact 后与 F 拼成 CDEF,再用受控 Channel 将读取拆为 2、2、1,写入拆为 2、0、2、1,在零进度处退出后模拟下一次可写事件。它验证契约和恢复逻辑,没有建立真实网络连接。
javac -encoding UTF-8 --release 21 -d docs/upgrade-100/checks/nio-buffer-channel/classes examples/java-advanced/nio-buffer-channel/BufferChannelDemo.java
java -cp docs/upgrade-100/checks/nio-buffer-channel/classes BufferChannelDemo程序包含预期失败样例:连续 flip 后 get 抛出 BufferUnderflowException,捕获并验证后正常结束;它不代表编译或运行失败。实际版本、命令、退出码及输出保存在 docs/upgrade-100/checks/nio-buffer-channel/run.txt。示例无需数据库或 Redis。
继续阅读:IO 与资源管理、Selector 与连接生命周期、FileChannel 与零拷贝边界,或返回技术进阶阅读入口。
