编辑
2026-09-09
Java基础
00

目录

1、NIO的半包粘包问题
1.1学习NIO的一个错误写法
1.2场景一
1.3场景二
2.正确思路:让"事件"推进凑帧,而不是让"循环"空转等
3、总结

1、NIO的半包粘包问题

在上一篇《你好,NIO》博客中的提到了NIO使用中半包粘包问题,解决方案是用帧状态机,这一篇单独摘出来细说一下。为什么凑齐一帧必须用"状态机"?

1.1学习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。 于是这段代码在两个场景下会出大事:

1.2场景一

半包(一条消息被拆成多次到达)—— CPU 空转客户端发了一条 3000 字节的消息,真实网络上它被 TCP 拆成多个段,先后到达:

image.png

  • t=0 事件到来,读走头 4 字节 + 内容 1456 字节,body.hasRemaining() 还有 1544 → 继续循环;
  • t=0~5ms 之间数据还没到,read() 立刻返回 0,但循环不会停——它以 100% CPU 空转 5 毫秒,等第二段数据;
  • 一个连接空转 5ms 看似不长,当几百个连接同时处于半包状态,CPU 直接被打满。

1.3场景二

读到一半连接断开 —— 死循环比空转更狠:如果客户端在帧中间断开,read() 会一直返回 -1,而 body.hasRemaining() 永远为 true——这个循环永不退出,一个线程 100% CPU 空转直到天荒地老。

为什么 BIO 没这个问题

BIO 的 read() 是阻塞的——没数据它会真的睡过去等,代价是它绑定了整条线程;而 NIO 用 select() 替线程等待,read() 本身绝不阻塞。把 BIO 的"循环等到齐"搬进 NIO,等于把"等"的动作从 select() 手里抢回来自己做——而且是用空转的方式做。

2.正确思路:让"事件"推进凑帧,而不是让"循环"空转等

首先之前提到过:一条完整消息的到达,可能需要好几次 OP_READ 事件才能凑齐。而每次事件之间,线程应该回到 select() 睡觉,而不是原地等。

那"凑到一半的进度"放哪?答案是连接的附件中;每个连接的附件互不相干;

  1. 每个连接在建立时(accept 后)创建一个帧状态对象,通过 key.attach() 挂到连接的 key 上;
  2. 每次 OP_READ 事件,取出状态,尽力读一把,然后报告进度:没凑齐 → 什么都不做,回到 select() 等下一 次事件(水平触发保证:只要还有数据没读走,就一定会再次通知你);凑齐了 → 取出完整消息处理,然后重置状态,开始凑下一条。

这就是一个两阶段状态机:

image.png

用代码表达,就是三个要素:一个长期持有的状态对象 + 一个"读一把并报告进度"的方法 + 调用方根据进度决定去留:

java
static 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 —— 绝不循环空转

3、总结

非阻塞 IO 下凑帧的规则要求:每次事件只读一把,没凑齐就把进度存在连接的状态里,回 select() 睡觉等下一次事件——让事件推进凑帧,绝不让循环空转等待。

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:章鱼哥

本文链接:

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