编辑
2026-09-04
Java基础
00

目录

1、BIO的缺点
1.1 BIO代码示例
1.2 BIO产生的问题
1.3 扩展
2.为什么是NIO?
2.1 使用NIO的时候碰到的经典问题
2.1.1 半包问题(粘包/拆包)
2.1.2 空轮询 bug
2.1.3异常连接问题
2.1.4正常中断处理
2.2 实战用NIO写一个简单的群聊DEMO体验下NIO
2.2.1首先是服务端代码,
2.2.2 客户端代码
3.总结

1、BIO的缺点

1.1 BIO代码示例

在了解NIO之前先看下BIO它的缺点是什么,为什么最终像Tomcat服务器、Netty网络编程框架都选了NIO? 先看下BIO服务端代码看下他做了什么。

java
try (ServerSocket serverSocket = new ServerSocket(PORT)) { while (true) { Socket socket = serverSocket.accept(); // ① 阻塞点1:等待客户端连接 System.out.println("[新连接] " + socket.getRemoteSocketAddress()); new Thread(new ClientHandle(socket)).start(); // 每个连接分配一个独立线程 } } catch (IOException e) { throw new RuntimeException(e); }
java
// ClientHandle 内部 BufferedReader reader = new BufferedReader( new InputStreamReader(socket.getInputStream()) ); String message; while ((message = reader.readLine()) != null) { // ② 阻塞点2:等待客户端发数据 System.out.println("收到消息:" + message); }

1.2 BIO产生的问题

这就导致两个问题:

一是线程膨胀。 每来一个客户端,服务端就必须分配一个线程。如果并发量是1000,就需要1000个线程;

二是线程空转,比线程多更可怕的是,这些线程大部分时间都在“干等”。以 readLine() 为例,如果客户端连 接上了但迟迟不发数据,这个线程就会永远阻塞在这一行,既不能处理其他连接,也不能释放资源;

1.3 扩展

BIO在高并发下存在两个致命隐患,共同催生了一个颇为荒诞的困境:为了应对高并发,我们提升服务性能、增加线程数,结果线程却频繁阻塞。在高并发场景下,大量线程空转,加上频繁的CPU上下文切换,使得线程本身成为最大的资源消耗。这也是Tomcat 6及更早版本采用BIO模型时所遭遇的瓶颈——一旦并发量从几百攀升到几千,BIO的劣势便暴露无遗。所以从Tomcat 8.5开始,默认IO模型已由BIO切换为NIO。那NIO究竟是如何做到让一个线程高效管理成百上千个连接的呢?

2.为什么是NIO?

NIO实现了一条线程管理所有的连接,他有三个核心设计;

  • Channel: 数据传输的大门,服务端与客户端的通信数据都通过它,并且与BIO不同的是他是双向的、非阻塞的;
java
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(9999)); //绑定端口 serverSocketChannel.configureBlocking(false); //设置为非阻塞
  • Buffer: 数据传输的容器,他提供了通过指针拨动的方式灵活高效来操作字节缓冲,它主要通过四个重要的属性来实现它的功能,分别是;
java
public abstract class ByteBuffer { // 四个核心位置标记 private int mark = -1; // 标记位置 private int position = 0; // 当前位置 每次读或写N个字节 position=position+N private int limit; // 读写限制 private int capacity; // 缓冲区容量 //核心使用方法 /** * 拨动指针,将当前位置初始化,同时将容器的容量收窄成数据量 */ public Buffer flip() { limit = position; position = 0; mark = -1; return this; } /** *判断当前容器中是否还有可操作的字节,这个方法搭配着flip()方法能实现读写反转的效果,举个例子, *一个容量为9字节的byteBuffer 在容量没有用完之前是用来判断容器中是否还有位置读, *调用flip()之后就是判断容器中是否还有数据没有写出去 */ public final boolean hasRemaining() { return position < limit; } /** * 为容器设置一个初始容量,不可改变 */ public static ByteBuffer allocate(int capacity) { if (capacity < 0) throw createCapacityException(capacity); return new HeapByteBuffer(capacity, capacity, null); } /** * 它的作用是重置 position 到 0,为重新读取数据做准备。 */ public Buffer rewind() { position = 0; mark = -1; return this; } /** * 清空容器里的数据 */ public Buffer clear() { position = 0; limit = capacity; mark = -1; return this; } /** * 同clear不同,它会报存未处理的数据 */ public abstract ByteBuffer compact(); }

三种方式创建ByteBuffer,以下为简化示意,非 JDK 完整源码

java
// 1. 堆缓冲区Heap Buffer- JVM堆内存 ByteBuffer heapBuffer = ByteBuffer.allocate(1024); // 2. 直接缓冲区Direct Buffer- 操作系统内存 零copy ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024); // 3. 包装现有数组 byte[] data = new byte[1024]; ByteBuffer wrappedBuffer = ByteBuffer.wrap(data);
  • Selector:核心调度器,通过事件驱动来通知线程做具体的处理;
java
//注册`Selector`, 指定感兴趣的事件; Selector selector = Selector.open(); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println("群聊启动:端口9999"); //也可以多事件组合 serverSocketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);

SelectionKey

NIO的事件通过SelectionKey常量来指定,常量值通过位运算来指定效率极高,一共有以下事件

  • OP_READ        1 << 0 (0000 0001) 1 读就绪
  • OP_WRITE       1 << 2 (0000 0100) 4 写就绪
  • OP_CONNECT 1 << 3 (0000 1000) 8 连接就绪(客户端)
  • OP_ACCEPT     1 << 4 (0001 0000) 16 接受就绪(服务端)

2.1 使用NIO的时候碰到的经典问题

2.1.1 半包问题(粘包/拆包)

由于网络传输限制、接收缓冲区容量不足或服务端处理速度跟不上,一条完整的消息可能被拆分为多次接收(半包问题),也可能多条消息被合并到一次接收中(粘包问题),甚至出现一次只接收到“一条半”消息的情况。用“帧”来描述这类问题非常形象:一帧即一条完整消息,由帧头和帧体组成。若只收到部分内容,则属于半帧。接收端必须等完整帧到达后才能进行处理,否则应持续等待。基于这一逻辑,可以构建一个帧状态机,用于有效管理NIO通信中的粘包与半包问题。

提示

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

java
/** * 帧状态机:一个连接凑帧的进度。 * <p> * 两个阶段:阶段1 凑帧头(4字节长度) → 凑齐算出内容长度 → 阶段2 凑帧体(N字节) → 凑齐 = 一条完整消息。 * 注意:字段必须在连接建立时创建一次并一直复用,绝不能每次 OP_READ 现 new —— * 否则这次事件读到的半截数据下次事件就丢了。 */ static class FrameState { private final ByteBuffer header = ByteBuffer.allocate(4); // 正在凑的帧头(4 字节长度头) private ByteBuffer body; // 正在凑的帧体:长度未知前是 null /** * 尽力从通道读一把,报告进度: * 返回 -1 对端关闭;返回 0 还在凑(没凑齐,等下一次 OP_READ);返回 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(); // 把 limit 从容量收窄成数据量 body = ByteBuffer.allocate(header.getInt()); // 拿到长度才分配帧体 //生产代码需校验长度上限(如 > 1MB 拒绝),否则恶意客户端可构造超大 length 导致 OOM"。 } int r = sc.read(body); // 阶段2:帧体,能读多少读多少 if (r == -1) return -1; return body.hasRemaining() ? 0 : 1; // 没读满 → 撤;读满 = 一条完整消息 } /** 取出完整消息并重置,准备凑下一条(必须在 feed 返回 1 后调用) */ String message() { body.flip(); byte[] data = new byte[body.remaining()]; body.get(data); // 重置:回到阶段1。header.clear() 复用同一个 buffer(4 字节正好循环用) header.clear(); body = null; return new String(data, StandardCharsets.UTF_8); // 解码显式 UTF-8,别用平台默认编码 } }

那何时使用这个帧状态机呢,在客户端连接时候给连接单独挂一个状态机,后面处理这个连接的事件再取回即便是半帧状态也能很好保存;

java
/** * 每个连接在建立时(accept 后)创建一个帧状态对象,通过 key.attach() 挂到连接的 key 上; */ private static void acceptAll(ServerSocketChannel ssc, Selector selector) throws IOException { SocketChannel socketChannel; while ((socketChannel = ssc.accept()) != null) { socketChannel.configureBlocking(false); // 注册时把"这个连接的帧状态"挂上去,之后事件循环用 key.attachment() 取回 socketChannel.register(selector, SelectionKey.OP_READ).attach(new FrameState()); String who = String.valueOf(socketChannel.getRemoteAddress()); clients.put(who, socketChannel); System.out.println("新链接加入:" + who); broadcast("新链接加入:" + who, socketChannel); } } /** * 每次 OP_READ 事件,取出状态 */ private static void handleReadable(SelectionKey key) throws IOException { SocketChannel sc = (SocketChannel) key.channel(); FrameState state = (FrameState) key.attachment(); // 取出这个连接自己的凑帧进度 int r = state.feed(sc); if (r == -1) { cleanup(key, "链接断开"); } else if (r == 1) { String message = state.message(); System.out.println("收到消息:" + message); broadcast(message, sc); } // r == 0:半包没凑齐 —— 什么都不做,等下一次 OP_READ。不在这里循环空转 }

2.1.2 空轮询 bug

在linux平台上selector没有事件也会立即返回0无限循环导致CPU 100%!所以需要手动处理检测空轮训次数达到指定次数之后重建selector,将旧selector的channel和事件重新注册;但是我在windows平台测试的时候发现正常情况下也会立即返回0并没有导致CPU 100%;再深入的研究的到的结果是**:JDK 对 Linux 的 epoll 实现存在缺陷,无法正确处理底层异常事件(如 EPOLLHUP),导致 Selector 被异常唤醒。**

java
int read = selector.select(1000); int emptySelectCount = 0; if(read==0){ emptySelectCount++; // 如果连续多次空轮询,重建 Selector if (emptySelectCount > MAX_EMPTY_SELECT) { System.out.println("检测到空轮询,重建 Selector"); //重建select方法 rebuildSelector(selector); emptySelectCount = 0; // 重置计数 } continue; }

目前还存在这个问题

这个 Bug 是操作系统、JDK 底层实现和应用层事件模型三者共同作用的结果。 触发条件:当 Linux 的 epoll 机制在处理突然中断的连接(如收到 RST 包)时,会产生 EPOLLHUP 或 POLLERR 这类“错误/挂断”事件。 JDK 的局限:而 Java NIO 的 SelectionKey 只定义了 OP_READ, OP_WRITE, OP_ACCEPT, OP_CONNECT 四种就绪事件类型,没有接口去处理这些来自操作系统的异常事件。 结果:这些“异常事件”无法被 JDK 正确处理和消费,导致 Selector 的事件队列处于异常状态,从而被无限次错误唤醒,形成空轮询。

2.1.3异常连接问题

这个是我在测试的时候发现的,如果客户端或者服务端异常中断,没有对这种异常情况捕获处理的话就会导致整个服务端崩溃或者客户端异常显示;

java
//服务端代码 while (it.hasNext()) { SelectionKey key = it.next(); it.remove(); if (!key.isValid()) continue; try { if (key.isAcceptable()) { acceptAll((ServerSocketChannel) key.channel(), selector); } else if (key.isReadable()) { handleReadable(key); } } catch (IOException e) { // 单连接的异常不能拖垮事件循环:清理这个连接,继续服务别人 cleanup(key, "连接异常断开"); } }

2.1.4正常中断处理

服务端在消息传输中间断开时,这个循环会无限空转 CPU 100%,无论是客户端还是服务端,在读取消息的时候都要检查read = -1的情况

java
int read = channel.read(header); if(read==-1){ break; }

2.2 实战用NIO写一个简单的群聊DEMO体验下NIO

思路在前,代码在后:基于NIO实现群聊系统的关键在于事件驱动模型。

  • 1.服务端需维护一个全局用户会话容器,绑定TCP端口以监听连接;
  • 2.通过多路复用器(Selector)统一调度客户端的连接、读写及断开事件。
  • 3.当服务端解码消息后,遍历会话容器进行广播转发;
  • 4.客户端则负责建立连接、发送消息并处理服务端回显,从而完成双向通信。

2.2.1首先是服务端代码,

java
public class GroupServer { /** * 存储全局会话容器 */ private static final Map<String, SocketChannel> clients = new ConcurrentHashMap<>(); public static void main(String[] args) { try { ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress(9999)); serverSocketChannel.configureBlocking(false); Selector selector = Selector.open(); serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); System.out.println("群聊启动:端口9999"); while (true) { selector.select(); Iterator<SelectionKey> it = selector.selectedKeys().iterator(); while (it.hasNext()) { //一次 select 可能就绪多个 key,必须遍历完 SelectionKey key = it.next(); it.remove(); if (!key.isValid()) continue; try { if (key.isAcceptable()) { acceptAll((ServerSocketChannel) key.channel(), selector); } else if (key.isReadable()) { handleReadable(key); } } catch (IOException e) { // 单连接的异常不能拖垮事件循环:清理这个连接,继续服务别人 cleanup(key, "连接异常断开"); } } } } catch (IOException e) { throw new RuntimeException(e); } } /** 清理失效连接:取消注册、关通道、移出广播表、通知其他人 */ private static void cleanup(SelectionKey key, String reason) throws IOException { SocketChannel sc = (SocketChannel) key.channel(); String who = String.valueOf(sc.getRemoteAddress()); key.cancel(); clients.remove(who); sc.close(); System.out.println(reason + ":" + who); broadcast(reason + ":" + who, sc); } /** 给除 self 外的所有在线客户端广播一条带长度头的帧 */ private static void broadcast(String message, SocketChannel self) { byte[] content = message.getBytes(StandardCharsets.UTF_8); ByteBuffer buffer = ByteBuffer.allocate(4 + content.length); buffer.putInt(content.length); buffer.put(content); buffer.flip(); for (SocketChannel target : clients.values()) { if (target == self) continue; buffer.rewind(); // 同一个帧要给每个连接各写一遍,写前回到开头 try { while (buffer.hasRemaining()) { target.write(buffer); } } catch (IOException e) { // 写失败 = 对端已经死了:移出广播表并关掉,别让死连接污染以后的广播 // getRemoteAddress() 也抛 IOException,处理异常时不能再抛出,只能吞掉 try { System.out.println("广播失败,移除死连接:" + target.getRemoteAddress()); clients.remove(String.valueOf(target.getRemoteAddress())); } catch (IOException ignore) { } try { target.close(); } catch (IOException ignore) { } } } } }

2.2.2 客户端代码

java
public static void main(String[] args) { try { SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(new InetSocketAddress("127.0.0.1",9999)); System.out.println("已连接到群聊服务器"); //1.读取服务器消息,NIO传输的高性能字节,需要自己处理边界 ExecutorService executorService = Executors.newSingleThreadExecutor(); executorService.submit(()->{ try { while (true){ ByteBuffer byteBuffer = ByteBuffer.allocate(4); int read = socketChannel.read(byteBuffer); if(read==-1){ System.out.println("服务器断开连接"); break; } //1.先读四个字节 while (byteBuffer.hasRemaining()){ int readData = socketChannel.read(byteBuffer); if(read==-1){ System.out.println("服务器断开连接"); break; } } byteBuffer.flip(); //再读具体的消息长度 int contentLength = byteBuffer.getInt(); // 拿到消息体长度 ByteBuffer contentBuffer = ByteBuffer.allocate(contentLength); while (contentBuffer.hasRemaining()){ socketChannel.read(contentBuffer); } contentBuffer.flip(); //flip 标记出”有效数据”的末尾, byte[] data = new byte[contentBuffer.remaining()]; contentBuffer.get(data); String message = new String(data, StandardCharsets.UTF_8); System.out.println("收到服务端消息:"+message); System.out.print("请输入消息:"); } } catch (IOException e) { System.out.println("接收线程异常: " + e.getMessage()); } }); //2.接收键盘收入输出到群组 BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in)); String message = ""; System.out.print("请输入消息:"); while ((message=bufferedReader.readLine())!=null){ if ("exit".equalsIgnoreCase(message.trim())) { break; } byte[] data = message.getBytes(StandardCharsets.UTF_8); ByteBuffer byteBuffer = ByteBuffer.allocate(4+data.length); byteBuffer.putInt(data.length); byteBuffer.put(data); byteBuffer.flip(); while (byteBuffer.hasRemaining()) { socketChannel.write(byteBuffer); } } socketChannel.close(); } catch (IOException e) { throw new RuntimeException(e); } }

3.总结

从 BIO 到 NIO,本质是一场**"等待"的集中化革命**:

BIONIO
谁在等每条连接独占一条线程,阻塞在 read一个线程阻塞在 select
线程数O(连接数),高并发下线程本身成为最大开销固定少量,与连接数无关
数据形态字节流,读完即弃Buffer 块式容器,指针拨动
消息边界阻塞天然"等齐",代价是绑死线程必须自己定协议,自己凑帧

NIO 用三个设计兑现了"一条线程管所有连接":Channel 提供双向、非阻塞的数据通道;Buffer 用指针拨动的方式操作字节块;Selector 把等待集中化——谁就绪就处理谁。

但 NIO把复杂度从"线程"转移到了"应用代码":

  • TCP 是字节流、没有消息边界 → 要自己设计长度头协议,还要用帧状态机跨事件凑齐一条消息(非阻塞下绝不能循环空转等数据);
  • 连接断开有两条路——对端优雅关闭 read() 返回 -1,异常重置才抛 IOException,两条都要处理,漏一条就是死循环或崩溃;
  • Linux 上有 epoll 空轮询缺陷、异常连接随时可能拖垮事件循环……每个坑都要手写代码去填。

而这些手写的苦活——事件循环、拆帧、连接生命周期管理——正是 Netty 存在的理由:EventLoop 封装了 select 循环,Decoder 封装了凑帧状态机,先简单了解下,后面真是看Netty!

不过NIO 的线程依然在等——它阻塞在 select() 上,事件来了还要自己动手 read、自己解码。这叫做"同步非阻塞"。

那有没有一种模型,连"等"和"动手读"都省了——你发起一次 read 后转身去做别的事,内核把数据读完、主动调用你的回调

有。那就是 AIO(NIO.2,JDK 7 引入),也就是"异步非阻塞"。

下一篇《你好,AIO》见!

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

本文作者:章鱼哥

本文链接:

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