Skip to content

网络通道核心概念

在进入 I/O 模型之前,先了解 Java NIO 中两个核心的网络通道:

通道角色主要方法
ServerSocketChannel服务端监听通道bind(), accept()
SocketChannel客户端/连接通道connect(), read(), write()

ServerSocketChannel 负责在指定端口监听客户端连接请求,每当有新客户端连接时,通过 accept() 方法返回一个新的 SocketChannel,代表与该客户端的连接。

TIP

简单理解:ServerSocketChannel 只负责接收连接,不负责数据通信。真正与客户端收发数据的是 accept() 返回的 SocketChannel。

阻塞 vs 非阻塞

在网络编程中,阻塞(Blocking)非阻塞(Non-Blocking) 是两种截然不同的 I/O 模型,它们决定了程序在等待数据时的行为方式。

阻塞 IO

阻塞 IO 是最传统的 IO 模型。当线程执行 read()accept() 等操作时,如果数据尚未就绪,线程会被挂起,直到数据到达或连接建立才会返回。

阻塞 IO 的特点

  • 线程在等待期间无法做其他事情
  • 一个连接需要一个线程处理
  • 编程模型简单直观

服务器端

java
import lombok.extern.slf4j.Slf4j;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;

@Slf4j
public class ClassicBlockingServer {
    public static void main(String[] args) {
        try (ServerSocket serverSocket = new ServerSocket(8080)) {
            log.debug("服务器已启动,等待连接...");

            while (true) {
                // 1. 主线程在此阻塞,直到新客户端连接
                Socket socket = serverSocket.accept();
                log.debug("检测到新连接: {}", socket.getRemoteSocketAddress());

                // 2. 为每个连接创建一个独立线程,防止阻塞主线程
                new Thread(() -> handleClient(socket)).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private static void handleClient(Socket socket) {
        try (InputStream input = socket.getInputStream()) {
            byte[] buffer = new byte[1024];
            while (true) {
                log.debug("线程 {} 正在等待读取来自 {} 的数据...", Thread.currentThread().getName(), socket.getRemoteSocketAddress());
                
                // 3. 子线程在此阻塞,直到该客户端发送数据或断开连接
                int len = input.read(buffer);
                
                if (len == -1) {
                    log.debug("客户端已断开连接");
                    break;
                }
                
                String msg = new String(buffer, 0, len);
                log.debug("收到数据: {}", msg);
            }
        } catch (IOException e) {
            log.error("通信异常: {}", e.getMessage());
        }
    }
}

客户端

java
import java.io.OutputStream;
import java.net.Socket;

public class SimpleClient {
    public static void main(String[] args) throws Exception {
        // 1. 建立连接
        try (Socket socket = new Socket("127.0.0.1", 8080)) {
            // 2. 写出数据
            OutputStream out = socket.getOutputStream();
            out.write("Hello, Blocking Server!".getBytes());
            out.flush();
        } // 3. 自动关闭连接
    }
}

问题:当并发连接数很高时(如 C10K 问题),每个连接一个线程会导致:

  • 线程资源耗尽(每个线程约消耗 1MB 栈内存)
  • 大量线程在阻塞等待,CPU 利用率低。(阻塞的表现是线程暂停了,暂停期间不会占用 cpu,线程相当于闲置)

非阻塞 IO

非阻塞 IO 模式下,当数据未就绪时,read() 等操作不会阻塞线程,而是立即返回(返回 0 或特定状态)。应用程序需要主动轮询检查数据是否就绪。

非阻塞 IO 的特点

  • 调用立即返回,不阻塞线程
  • 需要主动轮询检查状态
  • 单线程可以管理多个连接
java
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.List;

@Slf4j
public class NonBlockingServer {
    public static void main(String[] args) throws IOException {
        ByteBuffer buffer = ByteBuffer.allocate(16);
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));

        // 关键点 1:设置服务器通道为非阻塞
        ssc.configureBlocking(false);

        List<SocketChannel> channels = new ArrayList<>();
        log.debug("非阻塞服务器已启动...");

        while (true) {
            // 关键点 2:accept 不再阻塞。如果没有连接,直接返回 null
            SocketChannel sc = ssc.accept();
            if (sc != null) {
                log.debug("检测到新连接: {}", sc);
                // 关键点 3:设置客户端通道也为非阻塞
                sc.configureBlocking(false);
                channels.add(sc);
            }

            // 关键点 4:轮询处理已有的连接
            for (SocketChannel channel : channels) {
                // read 不再阻塞。如果没有收到数据,直接返回 0
                int read = channel.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    log.debug("收到来自 {} 的数据", channel.getRemoteAddress());
                    // 假设此处有 debugRead 工具打印 buffer
                    buffer.clear();
                }
            }
        }
    }
}

问题:虽然单线程可以处理多个连接,但需要不断轮询,当大部分连接都没有数据时:

  • CPU 空转,做大量无用功
  • 轮询间隔难以把控(太短浪费 CPU,太长响应慢)

多路复用

阻塞 IO 和非阻塞 IO 都需要监听 Channel 的状态变化。阻塞模式下,线程会挂起在单个 Channel 上,无法同时处理其他连接;非阻塞模式下,虽然可以轮询多个 Channel,但在大部分 Channel 空闲时会产生大量无效的系统调用,造成 CPU 资源浪费。

多路复用机制通过引入 Selector,将监听模式从"应用程序主动轮询"转变为"内核事件通知"。线程在 select() 调用上阻塞等待,当任一 Channel 就绪时由内核唤醒,从而实现了高效的事件驱动模型。

TIP

多路复用的核心思想:不主动查,等通知。用一个线程监听多个连接,哪个就绪处理哪个。

多路复用的特点

  • 事件驱动:只处理已就绪的通道,不做无用功
  • 单线程高并发:一个 Selector 可以管理成千上万个连接
  • 高效:底层使用 epoll(Linux)、kqueue(macOS)等系统调用

Selector 核心概念

组件说明
Selector选择器,用于监听多个 Channel 的事件
SelectionKey表示 Channel 在 Selector 上的注册关系,包含就绪的事件类型
Channel必须是非阻塞的(configureBlocking(false)

当你把一个 Channel 注册到 Selector 时,register() 方法会返回一个 SelectionKey。它代表了这个特定的通道在特定选择器上的注册关系。

一个 SelectionKey 包含了以下信息:

  1. 它关联的 Channel: 这个键是哪个通道的?(key.channel())
  2. 它关联的 Selector: 这个键属于哪个选择器?(key.selector())
  3. 它关注的事件(Interest Set): 这个通道在等什么?(是等“连接” OP_ACCEPT,还是等“数据” OP_READ?)

事件类型

事件常量含义
连接就绪SelectionKey.OP_CONNECT客户端连接成功
可接受连接SelectionKey.OP_ACCEPT服务端有新连接可接受
可读SelectionKey.OP_READ通道有数据可读
可写SelectionKey.OP_WRITE通道可以写入数据

selector的使用

  1. 创建Selector
java
Selector selector = Selector.open();
  1. 注册
java
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
  • 绑定的事件类型可以有
    • connect - 客户端连接成功时触发
    • accept - 服务器端成功接受连接时触发
    • read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
    • write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
  1. 监听

可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少 channel 发生了事件

方法1,阻塞直到绑定事件发生

java
int count = selector.select();

方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms)

java
int count = selector.select(long timeout);

方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件

java
int count = selector.selectNow();
  1. 处理发生的事件

事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则下次该事件仍会触发,这是因为 nio 底层使用的是水平触发。以下是针对不同事件的具体“处理”标准:

  • OP_READ (可读事件):调用 channel.read(buffer)。你必须将内核接收缓冲区(Receive Buffer)中的数据读完,或者读到返回 0。只要缓冲区里还留有一个字节,下轮 select() 依然会返回该 SelectionKey。
  • OP_ACCEPT (可连接事件):调用 serverSocketChannel.accept()accept() 方法会从已完成三次握手的队列中取走一个连接。一旦队列变空,该事件就不再触发。
  • OP_WRITE (可写事件):手动取消注册(因为缓冲区通常总是空闲的)。
  • OP_CONNECT (连接完成事件):用于客户端 SocketChannel.connect()。只有调用了channel.finishConnect()这个方法,底层才会认为连接握手流程正式结束,从而停止触发该事件。

cancel 会取消注册在 selector 上的 channel,并从 keys 集合中删除 key 后续不会再监听事件

并且在处理事件后,要记得把相关的 key给remove掉。因为selectedKeys() 集合在 NIO 中扮演的是一个待处理任务清单。当 select() 方法返回时,内核告诉 Selector 哪些 Channel 就绪了,Selector 会把这些 Key 添加(Add)到 selectedKeys 集合里。Selector 只负责加,不负责减。 它的逻辑是:如果你不手动从这个集合里移除(remove),它就会一直留在那里。

看一个例子:

第一轮循环:

  • 客户端 A 连接,ssckey(ServerSocketChannel)触发了 OP_ACCEPT。
  • selectedKeys 集合现在是:[ssckey]。
  • 你处理了 ssckey(执行了 accept()),但没有 remove。

第二轮:

  • 客户端 A 发送了数据,sckey(SocketChannel)触发了 OP_READ。
  • select() 方法执行,发现有新事件,于是把 sckey 加入集合。
  • 此时 selectedKeys 集合变成了:[ssckey, sckey] (注意:旧的 ssckey 还在里面)。

代码遍历集合,第一个拿到的还是 ssckey。由于它是水平触发,虽然你上次 accept() 过了,但如果此时正好没有新的连接进来,你再次调用 serverSocketChannel.accept() 可能会返回 null(在非阻塞模式下)。如果你的代码逻辑没判断 null,直接对返回的 socket 进行操作,空指针异常就出现了。

阻塞、非阻塞、同步、异步

  • 阻塞 vs 非阻塞(等待时的状态)这组概念关注的是:在内核准备好数据之前,调用者(你的程序)在做什么 调用 I/O 操作后,如果数据没准备好,是干等着(阻塞),还是立即返回去做别的事(非阻塞)。

  • 同步 vs 异步(数据搬运的方式)这组概念关注的是:谁负责完成整个 I/O 操作并获取结果

    • 同步 I/O:调用者必须主动参与数据从内核到用户空间的拷贝过程(即使是非阻塞轮询,也要自己调用 read())
    • 异步 I/O:调用者只负责发起请求和接收通知,整个数据拷贝过程由内核完成,完成后通过回调或信号通知

上面说到的三种模型,都属于是同步的(包括IO多路复用)。关键点在于:epoll/select 只是告诉你"哪些 fd 准备好了",但数据从内核拷贝到用户空间这一步,还是得你自己调用 read() 去完成。调用者仍然要亲自参与 I/O 的最后一步,所以本质上还是同步模型。只不过它能同时监听大量 fd,效率比逐个轮询高得多。

那么IO多路复用算不算阻塞?

可以算。因为当你调用 select()、poll() 或 epoll_wait() 时,如果没有任何 fd 就绪,你的线程就卡在那里不动了,直到有 fd 准备好或者超时。

这个"卡住等待"的行为就是阻塞。只不过它阻塞的对象不一样:

  • 传统阻塞 I/O:阻塞在单个 fd 上,等这一个 fd 的数据准备好
  • I/O 多路复用:阻塞在一批 fd 上,等其中任意一个准备好

所以它的价值在于:用一次阻塞,同时等待成千上万个连接,而不是为每个连接开一个线程去阻塞等待。

当然,你也可以把 epoll_wait 的 timeout 设成 0,让它立即返回,这样就变成非阻塞的轮询模式了。但实际使用中很少这么干,因为会空转浪费 CPU。

IO 模型分类对比

IO 模型同步/异步阻塞/非阻塞数据拷贝方式典型场景
传统阻塞 IO同步阻塞调用者主动 read()简单应用、低并发
非阻塞 IO(轮询)同步非阻塞调用者轮询 + read()不推荐单独使用
IO 多路复用同步阻塞*调用者 select() + read()高并发服务器(Nginx、Redis)
异步 IO(AIO)异步非阻塞内核完成后回调通知Windows IOCP、Linux io_uring

NOTE

* IO 多路复用的阻塞是可配置的(通过 timeout 参数),但实际应用中通常会阻塞等待事件。

关键区别

  • 同步 IO(前三种):调用者必须自己调用 read() 完成数据拷贝
  • 异步 IO:调用者只负责发起请求,内核完成所有工作后通知结果