在上一篇《你好,NIO》博客中的提到了NIO使用中半包粘包问题,解决方案是用帧状态机,这一篇单独摘出来细说一下。为什么凑齐一帧必须用"状态机"?
先说下我最初的想法,估计也是大多数人的第一反应:
java ByteBuffer header = ByteBuffer.allocate(4);
while (header.hasRemaining()) {
channel.read(header); // 头没凑齐就一直读
}
header.flip();
int len = header.getInt();
ByteBuffer body = ByteBuffer.allocate(len);
while (body.hasRemaining()) {
channel.read(body); // 内容没凑齐就一直读
}
// 凑齐了,处理消息
看起来天经地义——"没读满就继续读,直到凑齐为止",BIO 时代不就这么写的吗?但注意:这里是非阻塞通道(configureBlocking(false))。非阻塞的 read() 在没有数据时不会等待,而是立刻返回 0。
于是这段代码在两个场景下会出大事:
半包(一条消息被拆成多次到达)—— CPU 空转客户端发了一条 3000 字节的消息,真实网络上它被 TCP 拆成多个段,先后到达:

read() 立刻返回 0,但循环不会停——它以 100% CPU 空转 5 毫秒,等第二段数据;读到一半连接断开 —— 死循环比空转更狠:如果客户端在帧中间断开,read() 会一直返回 -1,而 body.hasRemaining() 永远为 true——这个循环永不退出,一个线程 100% CPU 空转直到天荒地老。
为什么 BIO 没这个问题
BIO 的 read() 是阻塞的——没数据它会真的睡过去等,代价是它绑定了整条线程;而 NIO 用 select() 替线程等待,read() 本身绝不阻塞。把 BIO 的"循环等到齐"搬进 NIO,等于把"等"的动作从 select() 手里抢回来自己做——而且是用空转的方式做。
首先之前提到过:一条完整消息的到达,可能需要好几次 OP_READ 事件才能凑齐。而每次事件之间,线程应该回到 select() 睡觉,而不是原地等。
那"凑到一半的进度"放哪?答案是连接的附件中;每个连接的附件互不相干;
key.attach() 挂到连接的 key 上;这就是一个两阶段状态机:

用代码表达,就是三个要素:一个长期持有的状态对象 + 一个"读一把并报告进度"的方法 + 调用方根据进度决定去留:
javastatic class FrameState {
private final ByteBuffer header = ByteBuffer.allocate(4); // 正在凑的帧头
private ByteBuffer body; // 正在凑的帧体(长度未知前为 null)
/**
* 尽力读一把,报告进度:-1 对端关闭;0 还在凑;1 凑齐一条完整消息
*/
int feed(SocketChannel sc) throws IOException {
if (header.hasRemaining()) { // 阶段1:只读帧头
int r = sc.read(header);
if (r == -1) return -1;
if (header.hasRemaining()) return 0; // 头没凑齐 → 撤,等下一次事件
header.flip();
body = ByteBuffer.allocate(header.getInt());
}
int r = sc.read(body); // 阶段2:帧体,能读多少读多少
if (r == -1) return -1;
return body.hasRemaining() ? 0 : 1; // 没读满 → 撤;读满 = 完整消息
}
}
调用方也就是事件循环里只需要:
java FrameState state = (FrameState) key.attachment(); // 取出这个连接的进度
int r = state.feed(channel);
if (r == -1) {
// 连接断开,清理
} else if (r == 1) {
String message = state.message(); // 凑齐了!取消息处理
}
// r == 0:半包没凑齐 → 什么都不做,等下一次 OP_READ —— 绝不循环空转
非阻塞 IO 下凑帧的规则要求:每次事件只读一把,没凑齐就把进度存在连接的状态里,回 select() 睡觉等下一次事件——让事件推进凑帧,绝不让循环空转等待。


本文作者:章鱼哥
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!