前言
先看一段最经典的 Java Socket 服务端代码:
try (ServerSocket server = new ServerSocket(9090)) {
while (true) {
Socket client = server.accept();
new Thread(() -> {
handle(client);
}).start();
}
}
它的逻辑非常自然:
等待连接
↓
创建线程
↓
等待数据
↓
处理请求
只有一个客户端时,这种写法几乎没有问题。
100 个客户端连接时,也能正常工作。
但如果连接数变成:
1 000
10 000
100 000
问题就来了:
一万个连接,是否意味着需要一万个线程?
线程都在等待数据时,为什么还会消耗大量资源?
把线程提前放进线程池,能不能解决问题?
把 Socket 设置成非阻塞后,为什么程序还是可能占满 CPU?
一个线程遍历十万个连接,真的比十万个线程更快吗?
Selector 为什么只返回“就绪”的连接?
select、poll 和 epoll 到底有什么区别?
epoll 是不是由网卡中断直接通知 Java 程序?
epoll 是异步 I/O 吗?
Java 21 有了虚拟线程以后,NIO 和 Netty 是否已经过时?
这些问题共同指向一个经典主题:
C10K
C10K 讨论的并不只是“能不能创建一万个 Socket”,而是:
一台服务器怎样以可接受的内存、CPU 和调度成本,同时管理大量网络连接?
Dan Kegel 的经典 C10K 页面系统整理了线程、非阻塞 I/O、事件通知和操作系统接口等解决思路,成为后来高并发网络编程的重要历史入口。
本文将沿着这条演进路线,理解:
BIO
↓
线程池
↓
非阻塞 I/O
↓
手工轮询
↓
select
↓
poll
↓
epoll
↓
Java Selector
↓
EventLoop
最后再讨论虚拟线程出现后,这条技术路线发生了什么变化。
一、C10K 是什么?
C10K 中的:
C = Concurrent Connections
10K = 10 000
表示一台服务器同时维持一万个客户端连接。
但要注意:
一万个连接
≠
一万 QPS
≠
一万个请求同时执行
≠
每秒传输一万条消息
连接数、请求并发、吞吐量和延迟是不同指标。
例如,一台即时通信服务器可能保持十万个连接,但绝大多数客户端都处于空闲状态:
100 000 条 TCP 连接
↓
每秒只有 2 000 条连接发送消息
另一台接口服务器可能只有几千条连接,却承担每秒数万次请求。
因此,C10K 真正考验的是:
大量连接的状态管理
文件描述符管理
线程与调度成本
Socket 缓冲区内存
就绪事件发现
业务处理能力
它不是一个单独的 API 问题,而是整个服务端并发模型的问题。
二、BIO 卡在哪?
BIO 可以理解为经典的阻塞式 I/O 模型。
一个典型服务端包含两个阻塞点:
Socket client = server.accept();
等待新连接。
以及:
int count = input.read(buffer);
等待客户端发送数据。
完整模型为:
主线程
↓
accept() 等待连接
↓
获得 Socket
↓
创建处理线程
↓
主线程继续 accept()
处理线程
↓
read() 等待数据
↓
处理业务
课程素材也把传统 BIO 概括为:主线程阻塞在 accept(),每获得一个连接就创建一个独立线程,再由该线程阻塞读取数据。
1. 为什么需要多线程?
假设服务端只有一个线程:
while (true) {
Socket client = server.accept();
handle(client);
}
而 handle() 中执行:
input.read(buffer);
如果第一个客户端连接后一直不发送数据,主线程就会永远停在这个连接的 read() 上。
后续客户端即使已经完成三次握手,也无法被应用继续处理。
所以传统 BIO 会把读取操作交给其他线程:
监听线程
├── 连接 A → 线程 A
├── 连接 B → 线程 B
├── 连接 C → 线程 C
└── 连接 D → 线程 D
这就是经典的:
一连接一线程
2. 线程在等待什么?
多数长连接并不会一直传输数据。
一个连接的生命周期可能是:
连接建立
↓
等待 30 秒
↓
收到一条消息
↓
处理 5 毫秒
↓
再等待 60 秒
这意味着处理线程绝大多数时间都不在计算,而是在等待:
线程 A:等待网络数据
线程 B:等待网络数据
线程 C:等待网络数据
线程 D:等待网络数据
阻塞等待本身不会持续占用一个 CPU 核心,但传统 Java 平台线程通常对应操作系统线程。大量平台线程仍会消耗线程栈、内核任务结构和运行时管理资源,并增加调度、监控和垃圾回收相关成本。OpenJDK 在设计虚拟线程时,也明确把操作系统线程数量有限、成本较高视为传统线程模型的扩展瓶颈。
三、线程池够吗?
为了避免不断创建和销毁线程,可以使用线程池:
ExecutorService executor =
Executors.newFixedThreadPool(200);
while (true) {
Socket client = server.accept();
executor.submit(() -> handle(client));
}
线程池确实解决了一部分问题:
复用线程
限制线程数量
减少频繁创建线程
控制系统资源上限
但它没有消除阻塞。
假设线程池只有 200 个线程,前 200 个客户端都建立长连接,并且一直等待数据:
200 个线程
↓
全部阻塞在 read()
第 201 个连接提交的任务只能进入工作队列。
于是:
连接已经建立
任务也已经提交
但没有线程读取它的数据
扩大线程池也只能推迟问题:
200 → 2 000 → 10 000
线程池解决的是:
线程怎样复用
而不是:
为什么每条空闲连接都要占用一个平台线程
课程素材中也指出,线程池可以减少线程创建成本,但无法消除大量线程在少量 CPU 核心上被调度的成本。
四、非阻塞是什么?
解决方向是:
没有连接或数据时,不要让当前线程一直等待。
Java NIO 可以把 ServerSocketChannel 设置为非阻塞:
ServerSocketChannel server =
ServerSocketChannel.open();
server.bind(new InetSocketAddress(9090));
server.configureBlocking(false);
此时执行:
SocketChannel client = server.accept();
如果当前没有等待接收的连接,不会阻塞线程,而是立即返回:
null
Java 官方文档明确规定,非阻塞模式下没有待接收连接时,ServerSocketChannel.accept() 会立即返回 null。它返回的新 SocketChannel 默认仍是阻塞模式,因此通常还要单独调用 configureBlocking(false)。
读取也可以设置为非阻塞:
client.configureBlocking(false);
int count = client.read(buffer);
返回值含义为:
count > 0
读取到 count 个字节
count == 0
当前没有可立即读取的数据
count == -1
对端关闭,已经到达流末尾
非阻塞 SocketChannel 的读取只会读取当前立即可用的数据,因此可能返回 0;到达流末尾时返回 -1。
这样,一个线程就有机会同时管理多个连接。
五、轮询为何变慢?
最直接的非阻塞服务端可以这样实现:
try (ServerSocketChannel server = ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(9090));
server.configureBlocking(false);
List<SocketChannel> clients = new ArrayList<>();
ByteBuffer buffer = ByteBuffer.allocate(4096);
while (true) {
SocketChannel client;
while ((client = server.accept()) != null) {
client.configureBlocking(false);
clients.add(client);
}
Iterator<SocketChannel> iterator = clients.iterator();
while (iterator.hasNext()) {
SocketChannel channel = iterator.next();
buffer.clear();
int count = channel.read(buffer);
if (count > 0) {
buffer.flip();
handle(channel, buffer);
} else if (count == -1) {
channel.close();
iterator.remove();
}
}
}
}
这段代码确实只用了一个线程。
但它引入了新的问题。
假设服务器管理十万个连接,只有三个连接有数据:
连接总数:100 000
就绪连接:3
程序仍然需要:
检查连接 1
检查连接 2
检查连接 3
……
检查连接 100 000
才能找到那三个连接。
每轮扫描的工作量取决于:
全部连接数
而不是:
本轮真正活跃的连接数
课程素材中的手工 NIO 模型正是把新连接加入列表,然后在同一个循环中遍历全部客户端;随着连接数增长,遍历和探测成本也会增长。
1. 空转问题
如果循环不休眠:
accept → 扫描全部连接 → accept → 再扫描
即使没有任何网络事件,CPU 也会不断执行循环。
这叫:
Busy Polling
忙轮询
2. 休眠问题
可以在循环末尾加入:
Thread.sleep(10);
CPU 使用率会下降,但事件可能刚好在休眠开始后到达:
数据到达
↓
线程还要睡 10 毫秒
↓
才能开始处理
于是延迟增加。
我们真正需要的不是:
应用不断询问每条连接是否就绪
而是:
让内核告诉应用,哪些连接已经就绪
六、多路复用来了
I/O 多路复用的核心思路是:
把很多文件描述符交给内核
↓
告诉内核关注哪些事件
↓
线程阻塞等待
↓
内核返回已经就绪的描述符
↓
应用只处理就绪连接
从:
应用遍历十万个连接
变成:
内核返回本轮就绪的三个连接
模型如下:
fd 4 ─┐
fd 5 │
fd 6 │
fd 7 ├──→ 多路复用器 ──→ fd 7、fd 108、fd 9201 就绪
... │
fd N ─┘
这里的“复用”表示:
一个线程的等待能力,被复用于很多 I/O 对象。
需要注意,多路复用器返回的是:
Readiness
就绪状态
例如:
可以 accept
可以 read
可以 write
连接发生异常
它不会自动替应用执行完整业务。
程序仍然需要调用:
accept()
read()
write()
因此,select、poll、epoll 和 Java Selector 通常属于:
同步 I/O 就绪通知
而不是“内核已经替应用把所有数据处理完成”的异步完成模型。
七、select 的代价
select() 可以同时监控多组文件描述符:
可读
可写
异常
它解决了:
一个线程只能等待一个 fd
的问题。
但在 Linux 上,它存在几个明显限制。
1. 描述符限制
Linux 手册明确警告,select() 只能监控数值小于 FD_SETSIZE 的文件描述符;常见的 FD_SETSIZE 为 1024,手册建议现代应用改用 poll() 或 epoll()。
注意,这个限制针对的是:
文件描述符编号
而不只是集合中元素数量。
如果进程当前出现:
fd = 1500
即使只监控这一个 fd,也无法直接放入标准的 fd_set。
2. 集合需要重复准备
select() 会修改传入的 fd 集合,标记哪些描述符已经就绪。
因此下一轮调用前,应用通常需要重新构造关注集合:
准备 fd 集合
↓
select()
↓
集合被修改
↓
扫描就绪 fd
↓
重新准备集合
3. 仍需扫描
应用需要从 0 扫描到传入的最大描述符范围,检查每个 fd 是否被标记为就绪。
因此,select() 的处理成本仍然会受到描述符规模影响。
它解决了“线程数量”的问题,但没有彻底解决“大量空闲连接反复扫描”的问题。
八、poll 改了什么?
poll() 与 select() 的目标相同:
等待一组文件描述符进入可执行 I/O 的状态。
区别是,poll() 使用一个结构体数组描述关注对象:
struct pollfd {
int fd;
short events;
short revents;
};
应用可以为每个 fd 指定关注事件:
POLLIN
POLLOUT
POLLERR
Linux 手册把 poll() 定义为等待一组文件描述符中的某一个进入 I/O 就绪状态;描述符通过 pollfd 数组传入。
相比 select():
不使用固定大小的 fd_set
不受传统 FD_SETSIZE=1024 的直接限制
接口表达更自然
但每次调用时,仍然要把数组提供给内核;返回后,应用也要检查数组中哪些元素的 revents 被设置。
所以它的基本模式仍然是:
提交全部关注对象
↓
内核检查
↓
应用扫描结果数组
当监控对象非常多、活跃对象很少时,处理成本依然与全部监控对象有关。
九、epoll 快在哪?
Linux 的 epoll 把“注册关注对象”和“等待就绪事件”拆开了。
主要接口为:
epoll_create1()
epoll_ctl()
epoll_wait()
1. 创建实例
int epfd = epoll_create1(0);
内核创建一个 epoll 实例,并返回一个文件描述符。
2. 注册事件
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);
告诉内核:
我要关注 fd
关注可读、可写或其他事件
以后需要修改或删除时,分别使用:
EPOLL_CTL_MOD
EPOLL_CTL_DEL
3. 等待事件
int count = epoll_wait(
epfd,
events,
maxEvents,
timeout
);
内核返回已经就绪的事件。
epoll 内部维护两个重要概念:
Interest List
应用注册并关注的文件描述符集合
Ready List
当前已经具备就绪事件的文件描述符集合
epoll_wait() 主要从就绪列表中取得事件,而不是让应用每次重新提交并扫描所有连接。Linux 的 epoll 文档明确区分了 interest list 和 ready list。
可以抽象为:
第一次:
把 fd 4、5、6、7 注册给 epoll
之后:
epoll_wait()
↓
只返回本轮就绪的 fd 5、7
这正适合:
连接很多
活跃连接较少
长连接较多
事件分布稀疏
的场景。
4. epoll 不是绝对 O(1)
经常有人把 epoll 简化成:
select:O(n)
epoll:O(1)
这种记忆方式过于粗糙。
epoll 仍然存在:
注册和修改事件的成本
内核维护就绪状态的成本
唤醒等待线程的成本
把事件复制到用户空间的成本
处理本轮就绪事件的成本
如果一轮有十万个 fd 同时就绪,应用仍然需要处理十万个事件。
更准确的优势是:
epoll 避免了应用在每次等待时反复提交、扫描大量没有事件的描述符,使等待成本更接近本轮真正发生的事件规模。
十、两种触发方式
epoll 支持两种常见触发模式:
Level Triggered
Edge Triggered
1. 水平触发
水平触发是默认模式。
只要 fd 当前仍然可读:
第一次 epoll_wait → 返回可读
应用只读了一部分
第二次 epoll_wait → 仍然返回可读
它像一个持续存在的状态:
只要水位还高于线
就继续通知
水平触发更容易编写正确代码。
2. 边缘触发
边缘触发只重点通知状态发生变化的时刻:
不可读 → 可读
如果应用收到事件后只读取了一小部分数据,剩余数据仍在缓冲区中,但没有发生新的“不可读到可读”变化,就可能暂时收不到新的通知。
因此,边缘触发通常要求:
使用非阻塞 fd
收到事件后持续读取
直到 read 返回 EAGAIN
Linux epoll 手册也建议,在 Edge Triggered 模式下使用非阻塞描述符,并持续处理直到读写返回 EAGAIN,以避免阻塞或遗漏剩余工作。
所以:
ET 比 LT 高级
并不等于:
ET 在任何场景都更快、更好
错误的 ET 代码反而更容易造成连接“假死”。
十一、epoll 靠中断吗?
网卡收到数据时,硬件中断或轮询机制可能参与把数据送入内核网络栈。
但不能简单描述为:
网卡中断
↓
直接通知 epoll
↓
直接回调 Java
实际链路更接近:
网卡收到数据
↓
驱动与内核网络栈处理
↓
数据进入 Socket 接收缓冲区
↓
Socket 就绪状态发生变化
↓
相关等待机制被唤醒
↓
fd 进入 epoll 的 Ready List
↓
epoll_wait() 返回
↓
Java Selector 返回 SelectionKey
而且 epoll 不只能监控网络 Socket。
它还可以与:
管道
eventfd
timerfd
signalfd
部分设备 fd
等可轮询文件描述符配合。
因此:
中断可能是某些网络事件的上游来源,但 epoll 的核心抽象是文件描述符就绪通知,而不是“网卡中断回调器”。
十二、Selector 怎么用?
Java NIO 对多路复用提供了统一抽象:
Selector
SelectableChannel
SelectionKey
1. 创建 Selector
Selector selector = Selector.open();
2. 注册监听 Channel
ServerSocketChannel server =
ServerSocketChannel.open();
server.bind(new InetSocketAddress(9090));
server.configureBlocking(false);
server.register(
selector,
SelectionKey.OP_ACCEPT
);
3. 等待事件
selector.select();
select() 会等待至少一个注册 Channel 出现感兴趣的就绪事件。
Java Selector 会通过底层 SelectorProvider 向操作系统查询已注册 Channel 的就绪状态,并维护注册键、已选择键和已取消键集合。
4. 处理事件
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 当前事件处理完成后,必须移除。
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
accept(selector, key);
}
if (key.isReadable()) {
read(key);
}
}
一个简化的完整服务端如下:
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.StandardCharsets;
import java.util.Iterator;
public final class SelectorServer {
public static void main(String[] args) throws IOException {
try (Selector selector = Selector.open();
ServerSocketChannel server =
ServerSocketChannel.open()) {
server.bind(new InetSocketAddress(9090));
server.configureBlocking(false);
server.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
selector.select();
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isAcceptable()) {
accept(selector, key);
}
if (key.isReadable()) {
read(key);
}
}
}
}
}
private static void accept(
Selector selector,
SelectionKey key
) throws IOException {
ServerSocketChannel server =
(ServerSocketChannel) key.channel();
SocketChannel client;
while ((client = server.accept()) != null) {
client.configureBlocking(false);
ConnectionContext context =
new ConnectionContext(
ByteBuffer.allocate(4096)
);
client.register(
selector,
SelectionKey.OP_READ,
context
);
}
}
private static void read(SelectionKey key)
throws IOException {
SocketChannel client =
(SocketChannel) key.channel();
ConnectionContext context =
(ConnectionContext) key.attachment();
ByteBuffer buffer = context.readBuffer();
buffer.clear();
int count = client.read(buffer);
if (count == -1) {
key.cancel();
client.close();
return;
}
if (count == 0) {
return;
}
buffer.flip();
String message =
StandardCharsets.UTF_8.decode(buffer).toString();
System.out.println(message);
}
private record ConnectionContext(
ByteBuffer readBuffer
) {
}
}
这段代码展示的是基本事件循环,真实网络协议还必须处理:
半包与粘包
消息边界
字符解码状态
写半包
写缓冲区
背压
连接超时
异常关闭
并发注册
Selector 只解决:
哪些 Channel 当前就绪
不会自动解决应用协议。
十三、EventLoop 做什么?
Selector 服务端的核心循环是:
等待事件
↓
取出就绪连接
↓
分发处理
↓
再次等待
这就是 EventLoop 的基本思想:
while (!stopped) {
selector.select();
processSelectedKeys();
runPendingTasks();
}
EventLoop 的关键原则是:
EventLoop 线程只负责短小、非阻塞的事件处理。
假设在 EventLoop 中直接执行:
查询数据库 3 秒
调用远程接口 5 秒
压缩大文件 10 秒
执行复杂计算
那么这个线程管理的其他连接也无法及时得到处理:
连接 A 执行慢任务
↓
连接 B、C、D 的事件全部等待
所以成熟框架通常会把工作分成:
EventLoop
负责连接与 I/O 事件
业务线程或任务系统
负责可能阻塞或耗时的业务
课程素材后半部分也把 Selector、EventLoop、EventLoopGroup 和事件执行器串联成一个精简版 Netty 骨架。
十四、十万连接还缺什么?
使用 epoll 或 Selector,并不代表服务器就能自动承受十万连接。
1. 文件描述符
一条已接收的 Socket 连接通常会占用一个文件描述符。
进程能够打开的 fd 数量受到:
RLIMIT_NOFILE
限制。
超过限制时,常见错误为:
Too many open files
EMFILE
Linux 还存在系统范围的 file-max,以及限制 RLIMIT_NOFILE 最高可提高到多少的 nr_open。RLIMIT_NOFILE 并不是 root 用户可以无条件突破的无限值。
可以查看:
ulimit -n
cat /proc/$$/limits
cat /proc/sys/fs/file-max
cat /proc/sys/fs/nr_open
2. 内核内存
每条 TCP 连接还需要维护:
TCP 状态
发送缓冲区
接收缓冲区
重传状态
定时器
队列节点
Socket 结构
十万条连接即使都处于空闲状态,也不可能完全不占内存。
3. 应用内存
应用可能为每个连接保存:
ByteBuffer
协议解析状态
用户会话
待发送消息
认证信息
超时任务
业务上下文
假设每条连接平均占用 20 KiB:
100 000 × 20 KiB
≈ 1.9 GiB
这还不包含内核 Socket 内存。
4. 客户端端口
服务端可以使用同一个监听端口承载大量连接,因为已建立 TCP 连接由本地和远端地址、端口组合区分。
但压测客户端连接同一目标时,可能先耗尽本地临时端口范围。
可以通过:
增加源 IP
增加客户端机器
连接不同目标地址
调整临时端口范围
扩展测试能力。
因此:
客户端端口耗尽
不等于:
服务端只能接受 65535 条连接
5. 业务能力
维持十万个空闲连接,和处理十万个活跃连接完全不同。
真正的上限还取决于:
消息频率
单条消息大小
TLS 开销
序列化成本
数据库能力
网络带宽
CPU 核心数
垃圾回收
下游服务
所以高并发测试不能只观察:
成功建立了多少 TCP 连接
还要观察:
吞吐量
P99 延迟
CPU
内存
网络带宽
丢包与重传
EventLoop 延迟
队列长度
错误率
十五、虚拟线程改了什么?
Java 21 正式引入虚拟线程后,经典的“一连接一平台线程”结论需要重新限定。
虚拟线程不是操作系统线程的一对一包装。
大量虚拟线程可以复用较少的平台线程。当虚拟线程在支持的 JDK 阻塞 I/O 操作上等待时,运行时通常可以挂起该虚拟线程,并释放承载它的平台线程去执行其他任务。JEP 444 的目标之一,就是让简单的 thread-per-request 编程模型重新具备较好的扩展能力。
一个虚拟线程服务端可以写成:
try (ServerSocket server = new ServerSocket(9090);
ExecutorService executor =
Executors.newVirtualThreadPerTaskExecutor()) {
while (true) {
Socket client = server.accept();
executor.submit(() -> {
handle(client);
return null;
});
}
}
这样可以保留清晰的阻塞式代码:
一个连接
↓
一个虚拟线程
↓
顺序 read / write
同时避免为每条阻塞连接永久占用一个操作系统线程。
但虚拟线程没有消除:
文件描述符限制
Socket 内核内存
应用连接状态
网络带宽
数据库连接上限
业务并发控制
下游容量
虚拟线程主要降低的是:
等待任务占用平台线程的成本
它不会让 CPU 密集型任务无限并发,也不会让十万个连接变成零成本。
因此,现代 Java 服务端存在两条都合理的路线:
阻塞 API + 虚拟线程
以及:
非阻塞 Channel + Selector / Netty
前者强调简单、顺序化的编程模型。
后者强调事件循环、精细的缓冲管理、协议处理和成熟的网络框架生态。
它们不是简单的“新技术淘汰旧技术”。
十六、常见误区
1. C10K 就是一万 QPS
错误。
它最初关注的是同时维持和处理大量并发连接,不直接等于请求吞吐量。
2. BIO 必然一连接一个操作系统线程
需要限定。
传统平台线程 BIO 经常采用一连接一平台线程;使用虚拟线程后,可以保留一连接一线程的代码结构,但不再一对一长期占用操作系统线程。
3. 线程池解决了 BIO 阻塞
错误。
线程池限制并复用线程,但阻塞连接仍会占用线程池中的工作线程。
4. 设置非阻塞就会自动高性能
错误。
如果应用手工遍历十万个 Channel,它仍然会消耗大量 CPU。
5. 非阻塞就是异步
错误。
非阻塞表示调用不能立即完成时快速返回。
异步则强调提交操作后,由系统在操作完成时提供完成结果。
6. Selector 会自动读取数据
错误。
Selector 只报告 Channel 已经就绪,应用仍需调用 accept()、read() 或 write()。
7. epoll 直接处理业务数据
错误。
epoll 负责事件等待和就绪通知,不负责应用协议解析和业务处理。
8. epoll 完全依赖网卡中断
错误。
网络中断可能触发上游数据处理,但 epoll 面向的是通用文件描述符就绪状态,并不限于网卡。
9. epoll 永远是 O(1)
不严谨。
epoll 避免每轮扫描全部空闲 fd,但注册、状态维护、唤醒和就绪事件处理仍然有成本。
10. Edge Triggered 一定更快
错误。
ET 减少部分重复通知,但代码更复杂,必须正确使用非阻塞 I/O,并读取到 EAGAIN。
11. SocketChannel.read() 返回 0 表示断开
错误。
非阻塞模式下返回 0 通常表示当前没有立即可读的数据;返回 -1 才表示流结束。
12. select 在所有系统上最多只能处理 1024 个连接
不够准确。
Linux 标准 fd_set 的 FD_SETSIZE 常为 1024,而且限制的是 fd 编号;其他系统和实现细节可能不同。Linux 手册明确建议现代高并发程序使用 poll 或 epoll。
13. Linux 没有异步 I/O
错误。
Selector 和 epoll 主要是就绪通知机制,但 Linux 还提供 AIO、io_uring 等异步接口。io_uring 使用提交队列和完成队列发起、完成异步 I/O。
14. 单线程一定比多线程快
错误。
单个 EventLoop 可以高效管理大量连接,但无法并行使用多个 CPU 核心,也不能承受长时间业务阻塞。
实际系统通常使用:
多个 EventLoop
+
合理的连接分配
+
业务任务调度
15. 虚拟线程让 Netty 过时了
错误。
虚拟线程降低阻塞式并发的线程成本,但 Netty 仍提供成熟的协议管线、缓冲区、背压、连接管理和事件循环体系。
十七、如何选择?
连接不多,逻辑简单
可以使用:
阻塞 Socket
+
平台线程池
代码简单,排查容易。
长连接很多
可以使用:
Java NIO Selector
Netty
其他事件驱动框架
重点控制:
EventLoop 阻塞
缓冲区
背压
连接状态
阻塞业务较多
现代 Java 可以评估:
阻塞 API
+
虚拟线程
尤其适合大量任务主要在等待:
Socket
数据库
远程服务
队列
的场景。
极致性能场景
不能只根据“BIO、NIO、epoll”几个名称判断。
必须在真实负载下测量:
连接数
活跃比例
消息频率
消息大小
CPU
内存
延迟
带宽
下游容量
最终选择的不是“理论上最快的 API”,而是:
在目标负载下,能够满足吞吐、延迟、资源和维护成本要求的并发模型。
总结
传统 BIO 的基本模型是:
一个连接
↓
一个平台线程
↓
阻塞等待数据
线程池可以复用线程,但不能消除连接等待对工作线程的占用。
非阻塞 I/O 把:
没有数据就一直等待
变成:
没有数据就立即返回
但手工轮询所有连接,会让应用的工作量随着总连接数增长。
I/O 多路复用把连接交给内核统一等待:
注册很多 fd
↓
内核等待事件
↓
返回就绪 fd
↓
应用处理事件
select() 能等待多个 fd,但在 Linux 上受到传统 FD_SETSIZE 限制,并需要反复准备和扫描集合。
poll() 去掉了固定 fd_set,但仍需要传递和检查整个描述符数组。
epoll 把注册和等待拆开,并通过 Interest List 与 Ready List 管理连接,使应用更适合处理:
连接很多
活跃连接较少
的场景。
Java Selector 将这些底层差异抽象为:
Channel
SelectionKey
Selector
应用注册感兴趣的事件,等待 Channel 就绪,再执行 accept()、read() 和 write()。
EventLoop 的本质则是:
等待事件
处理事件
执行任务
再次等待
Java 虚拟线程让阻塞式 thread-per-request 模型重新具备了很强的扩展能力,但没有消除文件描述符、Socket 内存、业务状态和下游资源限制。
最后,可以用一句话概括整个演进过程:
BIO 用线程等待每条连接,非阻塞 I/O 让线程主动检查连接,而多路复用让内核报告就绪连接;虚拟线程则从另一个方向降低了“等待一个连接需要一个线程”的成本。
评论区