Java 网络通信编程(10):Channel 和 Selector

在上期博客中,我们提到了 BIO 的局限性:每来一个客户端就开启一条新线程处理读写。这种写法简单直观,但存在线程数量膨胀、上下文切换开销剧增的问题。因此高并发网络编程往往采用 NIO 方案,其核心就在于IO 多路复用 的设计思想,这主要依赖于两大关键组件:Channel(通道)Selector(多路复用器)

一、Channel

1.概念

Channel 直译是 NIO 中负责数据双向传输的载体,地位等价于 BIO 中的 Socket / ServerSocket。但和传统 IO 不同的是:BIO 的流是单向的,InputStream 只能读、OutputStream 只能写;而 NIO 的Channel是双向的,可以实现全双工通信。

Channel 永远和 Buffer 配合工作:数据从 Channel 读入 Buffer,也从 Buffer 写入 Channel,所有数据交互都通过缓冲区完成。

2.应用

最常用的通道是 ServerSocketChannel 和 SocketChannel,ServerSocketChannel 是服务端监听通道,唯一职责是监听新的客户端连接,本身不参与数据读写。其初始化如下:

java 复制代码
// 打开服务端通道
ServerSocketChannel server = ServerSocketChannel.open();
// 设置为非阻塞模式
server.configureBlocking(false);
// 绑定监听端口
server.bind(new InetSocketAddress(port));

通过 open() 方法创建实例后,再调用 configureBlocking(false)开启非阻塞模式。这是 NIO 的核心开关:非阻塞模式下, accept() 会立即返回结果,没有新连接就返回 null,不会阻塞线程。阻塞模式下,accept()、read()、connect() 都会一直等待,直到有结果才返回,一个线程只能管一个连接.非阻塞模式下,所有方法立即返回,线程不用原地等待,这才有了单线程管理多个通道的可能。

当有新连接接入时,通过 accept() 方法就能得到对应的数据通道:

java 复制代码
SocketChannel client = server.accept();

SocketChannel 是真正负责数据读写的通道,对应 BIO 中的 Socket,客户端和服务端各持有一端,通过它完成双向数据交互。

客户端创建并发起连接的代码:

java 复制代码
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
// 发起连接请求,非阻塞模式下立即返回
socketChannel.connect(new InetSocketAddress(ip, port));

读写操作也都围绕它完成:

java 复制代码
// 从通道读数据,写入 Buffer
int readByte = client.read(buffer);
// 从 Buffer 读数据,写入通道
client.write(writeBuff);

二、Selector

1.概念

Selector(多路复用器),它允许单个线程同时监控多个 Channel:只有当通道上有就绪的 IO 事件(新连接、数据到达等)时,线程才被唤醒去处理

2.SelectionKey与事件类型

当 Channel 注册到 Selector 时,需要明确指定要监听的事件类型。SelectionKey 作为注册凭证,完整记录了通道信息、监听事件类型以及附带资源等关键数据。

NIO 定义了 4 种标准网络事件,对应通道生命周期的不同阶段:

  • OP_ACCEPT(连接就绪):适用于 ServerSocketChannel,有新客户端发起连接请求,可以执行 accept 时触发;
  • OP_CONNECT(连接完成):适用于 SocketChannel,TCP 三次握手完成,连接建立成功时触发;
  • OP_READ(读就绪):对端发来数据,通道中有数据可读时触发;
  • OP_WRITE(写就绪):发送缓冲区有空闲,可以写入数据时触发。

在代码实现中:

  1. 服务端监听通道仅注册 OP_ACCEPT 事件,专门处理新连接请求;
  2. 客户端通道初始注册 OP_CONNECT 事件,待连接建立成功后切换为 OP_READ;
  3. 服务端接受的客户端通道直接注册OP_READ 事件,监听客户端数据发送。
3.Attachment

注册通道时,我们还可以传入一个Attachment(附加对象),最经典的用法就是绑定一个专属的 ByteBuffer:

java 复制代码
// 给每个客户端通道绑定专属缓冲区作为附件
client.register(selector, SelectionKey.OP_READ, buffer);

处理事件时,直接通过 key 就能取出对应的缓冲区

java 复制代码
ByteBuffer buffer = (ByteBuffer) key.attachment();

如此每个通道都有自己独立的读写缓冲区,多连接之间数据完全隔离,不会串扰。

4.工作流程

Selector 的使用有一套几乎固定的模板,我们结合服务端代码拆解:

第一步:创建 Selector

java 复制代码
Selector selector = Selector.open();

第二步:将通道注册到 Selector

java 复制代码
// 服务端监听通道注册,只监听新连接事件
server.register(selector, SelectionKey.OP_ACCEPT);

只有将 Channel 设置为非阻塞模式后才能成功地将其注册到 Selector 上。如果尝试注册一个处于阻塞模式的 Channel,系统会立即抛出 IllegalBlockingModeException 异常。

第三步:阻塞等待就绪事件

java 复制代码
int count = selector.select();

调用 select() 后,线程会阻塞挂起,直到至少有一个通道的监听事件就绪。返回值 count 表示本次新增的就绪通道数量。

第四步:遍历就绪事件集合

java 复制代码
Set<SelectionKey> keySet = selector.selectedKeys();
Iterator<SelectionKey> iterator = keySet.iterator();

while (iterator.hasNext()) {
    SelectionKey key = iterator.next();
    iterator.remove(); // 必须手动移除已处理事件
    
    // 根据事件类型分发处理
    if (key.isAcceptable()) {
        // 处理新连接
    } else if (key.isReadable()) {
        // 处理读事件
    }
}

此处需要注意,必须手动调用 iterator.next() 移除已处理的 key。 Selector 不会自动清理 selectedKeys() 集合中的事件,如果不移除,下次 select() 时这个已处理的事件还会留在集合里,导致事件被重复触发,甚至出现死循环空转。

三、完整流程

有了上面的基础,我们把整套回声服务的运行流程串起来,就能彻底理解 Channel 与 Selector 的配合方式。

1. 服务端执行流程

1.初始化阶段:创建 ServerSocketChannel,开启非阻塞,绑定 8888 端口;创建 Selector,将监听通道注册上去,监听 OP_ACCEPT 事件。

java 复制代码
    public static void server() throws Exception {
        int port = 8888;

        //创建服务器对象
        ServerSocketChannel server = ServerSocketChannel.open();
        server.configureBlocking(false);
        server.bind(new InetSocketAddress(port));

        //创建 Selector选择器,用来管理所有连接过来的客户端Channel
        Selector selector = Selector.open();
        //把channel 注册到选择器
        //让选择器监听该管道的连接事件
        server.register(selector, SelectionKey.OP_ACCEPT);

2.进入事件循环:调用 selector.select() 阻塞等待,没有事件时线程休眠,不消耗 CPU。

3.处理新连接(OP_ACCEPT)

  1. 有客户端发起连接,事件就绪,线程被唤醒;
  2. 调用 accept() 得到客户端的 SocketChannel,设置为非阻塞;
  3. 为该客户端创建专属 ByteBuffer,将通道注册到同一个 Selector,监听 OP_READ 事件,缓冲区作为附件绑定。

4.处理读事件(OP_READ)

  1. 客户端发送数据,通道读就绪;
  2. 从 key 中取出附件缓冲区,调用 read() 将数据写入缓冲区;
  3. 调用 flip() 切换 Buffer 为读模式,取出数据打印;
  4. 新建写缓冲区,将原消息写入后调用 write() 回发给客户端,完成回声效果。
java 复制代码
while (true) {
    int count = selector.select();
    //遍历所有待处理事件
    Set<SelectionKey> keySet = selector.selectedKeys();
    Iterator<SelectionKey> iterator = keySet.iterator();

    while (iterator.hasNext()) {
        //按顺序取出所有 key
        SelectionKey key = iterator.next();
        iterator.remove(); //移除已处理的 key

        //根据key 值实现对应处理
        if (key.isAcceptable()) {
            //获取连接过来的客户端管道
            SocketChannel client = (SocketChannel) server.accept();
            client.configureBlocking(false);

            //给每个连接过来的客户端设置固定缓冲区
            ByteBuffer buffer = ByteBuffer.allocate(1024);

            //选择器管理客户端管道,并且监听该管道的读事件
            client.register(selector, SelectionKey.OP_READ, buffer);

            System.out.println("客户端连接成功...");
        } else if (key.isReadable()) { //服务器读取客户端数据
            //获取该管道绑定的缓冲大小
            ByteBuffer buffer = (ByteBuffer) key.attachment();

            //读数据
            SocketChannel client = (SocketChannel) key.channel();
            int readByte = client.read(buffer);
            if (readByte > 0) {
                buffer.flip();
                byte[] data = new byte[buffer.remaining()];
                buffer.get(data);
                String msg = new String(data);
                System.out.println("客户端:" + msg);
                buffer.clear();

                //先发给自己
                ByteBuffer writeBuff = ByteBuffer.allocate(1024);
                writeBuff.put(msg.getBytes());
                writeBuff.flip();
                client.write(writeBuff);
                writeBuff.clear();
            }
        }
    }

}

5.循环往复:处理完所有就绪事件后,回到 select() 继续等待下一批事件。

2. 客户端执行流程

客户端设计相比服务端多了一个关键的双线程结构,主要原因在于:

  • Scanner.nextLine() 是阻塞方法,会一直等待用户输入;
  • 若将其与 Selector 轮询放在同一线程,输入阻塞会导致无法及时接收服务端响应。

因此代码做了线程拆分:

1.初始化阶段:创建 SocketChannel,开启非阻塞,发起连接;创建 Selector,注册 OP_CONNECT 事件。

java 复制代码
String ip = "127.0.0.1";
int port = 8888;

Selector selector = Selector.open();

//创建客户端的管道
SocketChannel socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);//设置非阻塞通信
socketChannel.connect(new InetSocketAddress(ip, port));//绑定IP 和端口

//注册监听器事件
socketChannel.register(selector, SelectionKey.OP_CONNECT);

//缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);

2.子线程:事件轮询与接收

  1. 监听 OP_CONNECT:连接建立完成后,调用 finishConnect() 确认连接,重新注册 OP_READ 事件,绑定专属读缓冲区;
  2. 监听 OP_READ:服务端回发数据到达时,读取并打印到控制台。
java 复制代码
new Thread(new Runnable() {
    @Override
    public void run() {
        try {
            while (true) {
                selector.select();
                Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    iterator.remove();

                    if (key.isConnectable()) {
                        SocketChannel client = (SocketChannel) key.channel();
                        if (client.finishConnect()) {
                            //缓冲区(子线程接收服务端数据专用)
                            ByteBuffer buffer = ByteBuffer.allocate(1024);
                            //在客户端注册读事件
                            client.register(selector, SelectionKey.OP_READ, buffer);
                        }
                    } else if (key.isReadable()) {
                        ByteBuffer buffer = (ByteBuffer) key.attachment();
                        SocketChannel client = (SocketChannel) key.channel();
                        int readByte = client.read(buffer);
                        if (readByte > 0) {
                            buffer.flip();
                            byte[] data = new byte[buffer.remaining()];
                            buffer.get(data);
                            String msg = new String(data);
                            System.out.println("server:" + msg);
                        }
                        buffer.clear();
                    }
                }
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}).start();

3.主线程:控制台输入发送

  1. 循环读取用户输入,将内容写入发送缓冲区;
  2. 调用 flip() 切换模式后,通过 SocketChannel 发送给服务端。
java 复制代码
Scanner scanner = new Scanner(System.in);
    while (true) {
        String line = scanner.nextLine();

        buffer.put(line.getBytes());
        buffer.flip();//将缓冲区切换成读模式
        //将数据写入管道
        socketChannel.write(buffer);
        buffer.clear();
    }
3.结果测试

客户端发送信息并收到回传消息:

服务端接受并回传消息:

如此我们初步了解了IO多路复用的设计理念:Channel负责数据传输,Selector则用于事件调度。目前仅测试了服务器回传功能,后续将实现多客户端的广播和私聊功能。

相关推荐
爱吃提升1 小时前
MATLAB impulse函数脉冲响应完整实操教程
开发语言·matlab
小饕1 小时前
从 1080 Ti 到树莓派 4:Qwen2.5-0.5B Function Calling 端侧部署七步实战
开发语言·人工智能
小张小张爱学习2 小时前
Apache Dubbo 3 + Spring Boot 3 多模块 Maven 项目,演示基于 Zookeeper 注册中心的 RPC 调用
java·dubbo
糯米导航2 小时前
Rust + ONNX Runtime 构建生产级 AI 推理服务:从零到压测
开发语言·人工智能·rust
阿pin2 小时前
Java随笔-ConcurrentHashMap
java·开发语言·哈希算法
秋田君2 小时前
QT_QT布局详解
开发语言·数据库·qt
ximen502_2 小时前
Python 语言知识总结
开发语言·python
m0_738120722 小时前
PHP代码审计基础——超全局变量(三)
开发语言·安全·网络安全·php
烟锁池塘柳02 小时前
【C/C++】解决C++控制台输出中文乱码问题
c语言·开发语言·c++