网络通道核心概念
在进入 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 的特点:
- 线程在等待期间无法做其他事情
- 一个连接需要一个线程处理
- 编程模型简单直观
服务器端
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());
}
}
}客户端
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 的特点:
- 调用立即返回,不阻塞线程
- 需要主动轮询检查状态
- 单线程可以管理多个连接
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 包含了以下信息:
- 它关联的 Channel: 这个键是哪个通道的?(key.channel())
- 它关联的 Selector: 这个键属于哪个选择器?(key.selector())
- 它关注的事件(Interest Set): 这个通道在等什么?(是等“连接” OP_ACCEPT,还是等“数据” OP_READ?)
事件类型
| 事件 | 常量 | 含义 |
|---|---|---|
| 连接就绪 | SelectionKey.OP_CONNECT | 客户端连接成功 |
| 可接受连接 | SelectionKey.OP_ACCEPT | 服务端有新连接可接受 |
| 可读 | SelectionKey.OP_READ | 通道有数据可读 |
| 可写 | SelectionKey.OP_WRITE | 通道可以写入数据 |
selector的使用
- 创建Selector
Selector selector = Selector.open();- 注册
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);- 绑定的事件类型可以有
- connect - 客户端连接成功时触发
- accept - 服务器端成功接受连接时触发
- read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
- write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
- 监听
可以通过下面三种方法来监听是否有事件发生,方法的返回值代表有多少 channel 发生了事件
方法1,阻塞直到绑定事件发生
int count = selector.select();方法2,阻塞直到绑定事件发生,或是超时(时间单位为 ms)
int count = selector.select(long timeout);方法3,不会阻塞,也就是不管有没有事件,立刻返回,自己根据返回值检查是否有事件
int count = selector.selectNow();- 处理发生的事件
事件发生后,要么处理,要么取消(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:调用者只负责发起请求,内核完成所有工作后通知结果
