Day63 | Java IO之NIO三件套--选择器(下)

在Day62中,我们讲了Buffer(缓冲区)和Channel(通道)。

这是NIO的数据载体和传输管道。

但如果只有这两个东西,NIO最多也就是一个支持双向读写的IO而已,还不能解决高并发的问题。

真正让NIO封神的,是第三个组件Selector(选择器)。

他是实现IO多路复用的关键。

今天我们就来了解下这个组件,看看他是怎么用一个线程处理成千上万个连接的。

一、为什么需要Selector?

1.1 BIO的痛点

我们回忆一下传统的BIO服务端模型。

当有一个客户端连接请求时,服务端就需要启动一个线程去处理这个连接。

如果这个客户端连上后不发数据,服务端的这个线程就得阻塞在read()方法上,一直傻等着。

java 复制代码
// BIO伪代码
while(true) {
    Socket socket = serverSocket.accept();
    new Thread(() -> {
        socket.getInputStream().read();
    }).start();
}

如果我们有1万个连接,但只有10个在真正发数据,BIO还是需要创建1万个线程。妥妥的浪费资源。

操作系统在几千个线程之间来回切换上下文,CPU的时间全浪费在切换上了,真正干活的时间很少。

就像我们去餐馆吃饭,每去一桌客人,餐馆就安排一个服务员站我们面前,我们看菜单点菜,点了一个小时,那服务员就在旁边等了一个小时,啥也不干,完全不合常理。

1.2 NIO多路复用

NIO引入了Selector,他的作用就是用一个线程去监控多个Channel的状态。

只有当Channel真正有事做(比如有数据来了、连接建立了)的时候,Selector才会通知线程去处理。如果大家都不说话,线程就歇着,或者去干别的事。

这就好比刚才我们去的餐馆升级了,只雇一个服务员当Selector,在大厅里面巡视,我们招手说要点菜的时候,他就过来服务我们这一桌。服务完继续巡视。哪怕有一千桌客人,只要不是同时点菜,一个Selector就能应付得过来。

二、Selector核心概念

Selector一般称之为选择器,也可以叫多路复用器。

2.1 核心组件关系

要使用Selector,不仅需要Selector本身,还需要把Channel注册到Selector上,并指定我们要监听的事件。

这里面涉及到三个核心的类。

只有继承了SelectableChannel这个类的Channel才能被注册。我们常用的SocketChannel和 ServerSocketChannel都是。

要注意的是FileChannel是不能注册的,因为他不仅是阻塞的,还没有事件这一说。

Selector是IO事件的监视器和调度器。像一个指挥官,同时监视成千上万的通道,但只在有实际工作(事件发生)时才唤醒应用程序线程。

这些是他的核心方法:

SelectionKey是Channel和Selector之间注册关系的凭证。包含了这次注册的所有元信息,当事件发生时,Selector返回的就是这些Key。

相当于是个令牌,当Channel注册到Selector上时,会返回一个SelectionKey,代表Channel和Selector之间的注册关系。

2.2 监听事件

我们在注册Channel的时候,必须告诉Selector,对什么事情感兴趣。SelectionKey定义了四种事件:

OP_CONNECT连接就绪。客户端连接服务端成功。

OP_ACCEPT接收就绪。服务端准备好接收新连接了。

OP_READ读就绪。通道里有数据可读了。

OP_WRITE写就绪。通道的发送缓冲区有空闲,可以写数据了。

三、案例

NIO的代码写起来确实比BIO要麻烦一些,但是逻辑还算清晰。

我们一起看一下一个示例代码,客户端发什么,服务端就回什么。

3.1 服务端

java 复制代码
package com.lazy.snail.day63.bio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

/**
 * @ClassName NioServer
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/11/25 10:45
 * @Version 1.0
 */
public class NioServer {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(8080));
        serverChannel.configureBlocking(false);
        Selector selector = Selector.open();
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("NIO Server started on port 8080...");
        while (true) {
            if (selector.select() == 0) {
                continue;
            }
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                iterator.remove();
                if (key.isAcceptable()) {
                    handleAccept(key);
                } else if (key.isReadable()) {
                    handleRead(key);
                }
            }
        }
    }

    private static void handleAccept(SelectionKey key) throws IOException {
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        SocketChannel client = server.accept();
        client.configureBlocking(false);
        client.register(key.selector(), SelectionKey.OP_READ);
        System.out.println("New Client Connected: " + client.getRemoteAddress());
    }

    private static void handleRead(SelectionKey key) {
        SocketChannel client = (SocketChannel) key.channel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        try {
            int len = client.read(buffer);
            if (len > 0) {
                buffer.flip();
                String msg = new String(buffer.array(), 0, len);
                System.out.println("Received: " + msg);
                client.write(ByteBuffer.wrap(("Echo: " + msg).getBytes()));
            } else if (len == -1) {
                client.close();
            }
        } catch (IOException e) {
            try {
                System.out.println("Client Disconnected unexpectedly.");
                client.close();
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }
    }
}

我还是画一张流程图来梳理服务端代码的流程:

示例代码可以分成几快来看。

服务器的初始化阶段

首先创建ServerSocketChannel相当于传统BIO中的ServerSocket。

绑定到8080端口,开始监听客户端连接请求。

关键的是调用configureBlocking(false)把通道设置成非阻塞模式,不然后续注册到Selector的时候就会抛出IllegalBlockingModeException。

非阻塞模式下,accept()方法会立即返回,没有新连接时返回null,而不是一直阻塞等待。

接着创建Selector实例,这是整个NIO模型的核心调度器。

然后把服务端通道注册到Selector,并指定监听OP_ACCEPT事件。

相当于是告诉Selector,帮我监视这个端口,有新的客户端连接请求时就通知我。

到这儿,服务器初始化完成,准备进入事件循环。

事件循环

selector.select()方法会阻塞当前线程,直到以下情况之一发生:

至少有一个注册的事件就绪(有新连接或数据到达)。

被其他线程调用selector.wakeup()唤醒。

到达指定的超时时间。

当有事件发生的时候,select()返回就绪的通道数量,如果为0就继续等待。

通过selector.selectedKeys()获取所有就绪事件的SelectionKey集合。

每个SelectionKey包含事件类型和对应的通道信息。

用迭代器遍历集合,因为需要在遍历过程中删除已处理的Key。

处理完每个Key后必须从集合中移除。如果不移除,这个Key会一直留在集合里,下次循环还会被处理。

遍历的时候根据Key的事件类型,调用相应的处理方法。

isAcceptable表示新连接到达,isReadable表示有数据可读。

handleAccept和handleRead方法分别处理上面两种情况。

处理新连接

从Key中获取之前注册的ServerSocketChannel。调用accept()接受客户端连接,返回代表该连接的SocketChannel。

把新的客户端通道也设置成非阻塞模式

把客户端通道注册到同一个Selector,但这次监听的是OP_READ事件。

相当于告诉Selector,这个客户端连接,当有数据可读时通知我。

发现没有,之前Selector只监视服务端通道的OP_ACCEPT事件。

现在Selector同时监视服务端的OP_ACCEPT + 所有客户端的OP_READ事件。

处理数据读取

创建1024字节的缓冲区,read方法把通道数据读入Buffer,非阻塞模式下,可能只读取部分数据,需要多次读取。

buffer.flip()把Buffer从写模式切换成读模式,position重置为0,limit设置为实际数据长度。

然后从Buffer中提取字符串消息,把响应数据写回客户端。

从整个流程来看,只有一个主线程处理所有连接。只在数据真正准备好时才进行IO操作。所有IO操作都是非阻塞的,立即返回。

3.2 客户端

客户端可以用BIO写,也可以用NIO写。为了简单点,我就不写代码了,直接telnet。

我们直接启动Server,用telnet发送消息。

四、NIO的注意事项

4.1 手动移除key

这一点我们在上面的服务端中已经提到过。

同一个事件被重复处理了,可能会死循环,CPU直接飘红。所以必须在处理之前就移除掉。

4.2 设置非阻塞

Selector是基于非阻塞IO设计的,阻塞模式下,一个Channel的IO操作会阻塞整个Selector线程。

4.3 半包和粘包

半包现象:

java 复制代码
客户端发送: "HelloWorld"  
服务端接收: "He" + "lloWorld"  // 一个包被拆成多个

粘包现象:

java 复制代码
客户端发送: "HelloWorld"  
服务端接收: "He" + "lloWorld"  // 一个包被拆成多个

这个问题跟BIO、NIO没关系,只是NIO基于事件驱动模型让问题更明显了,不得不解决。

问题的本质其实是TCP的缓冲区机制。

我们通过应用程序写入消息 A、B、C。

TCP为了性能(比如Nagle算法),不会我们发一个字节他就走一趟,而是会把几个小包凑成一个大包发出去(粘包),或者我们的包太大,他给我们切碎了发出去(半包)。

接收端通过网卡把数据读到内核缓冲区。这时候,消息A的尾巴可能和消息B的头贴在一起了。

当我们用buffer.read()的时候,我们根本不知道读到的是A的一部分,还是A+B的混合体。

TCP协议只保证字节序的正确,发出去是lazysnail,收到也是lazysnail,但不保证消息边界。

为了解决这个问题,我们就得在应用层定义协议,固定长度、特定分隔符、或者在包头加长度字段都是办法。

其实Netty就帮我们干了这些。

结语

回顾我们的这两篇文章内容,从BIO的每个连接都必须独占一个线程,不管他是不是在真正工作。

到NIO的Selector模型进行高效的调度,动态分配计算资源,只在需要时才进行处理。

其实这种调度的思想,在现代计算中,不管是线程池还是连接池,跟事件驱动架构的配合。

都是在做一件事,就是用最少的资源做最多的事。

我们学东西不要单纯的学API,因为API可能会过时,但是设计思路,思想可能会一直受用。

像Netty框架、Tomcat的NIO连接器、RocketMQ、Kafka的网络通信层都会用到NIO。

带着NIO的设计思路再去学习这些中间件或者技术会快很多。

下一篇预告

待定

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

相关推荐
JavaGuide17 分钟前
美团2026届后端一二面(附详细参考答案)
java·后端
打工人你好19 分钟前
如何设计更安全的 VIP 权限体系
java·jvm·安全
L.EscaRC27 分钟前
Spring IOC核心原理与运用
java·spring·ioc
摇滚侠40 分钟前
2025最新 SpringCloud 教程,Nacos-总结,笔记19
java·笔记·spring cloud
在逃热干面43 分钟前
(笔记)获取终端输出保存到文件
java·笔记·spring
爱笑的眼睛1144 分钟前
深入理解MongoDB PyMongo API:从基础到高级实战
java·人工智能·python·ai
笃行客从不躺平1 小时前
遇到大SQL怎么处理
java·开发语言·数据库·sql
q***87601 小时前
Spring Boot 整合 Keycloak
java·spring boot·后端
Billow_lamb1 小时前
Spring Boot2.x.x全局拦截器
java·spring boot·后端