Java Socket:从 BIO、NIO 到 Linux epoll 的完整内核链路
本文以 Linux TCP、Java 17 附近的 OpenJDK 实现为背景,串联 Java API、Linux 系统调用、文件描述符、TCP 内核结构、等待队列和 epoll。
不同 JDK 和 Linux 内核版本中的具体函数名、字段名可能略有变化,但核心对象关系和事件链路是一致的。
1. 先建立整体认知
BIO、NIO 和 epoll 不在同一个抽象层次:
| 名称 | 所在层次 | 核心含义 |
|---|---|---|
| BIO | Java 编程模型 | accept/read/connect 等调用没有完成时,调用线程阻塞 |
| Java NIO | Java API 与编程模型 | Channel 可设置非阻塞,并用 Selector 管理多个 Channel |
O_NONBLOCK |
Linux fd 状态 | 系统调用不能立即完成时返回 EAGAIN 或 EINPROGRESS |
| epoll | Linux I/O 多路复用机制 | 一个线程等待大量 fd 的"就绪事件" |
必须先记住四句话:
- BIO 和 NIO 最终操作的都是 Linux socket。
- NIO 不会绕过 TCP 协议栈,仍然需要
socket/bind/listen/connect/accept/read/write。 - epoll 只通知"哪个 fd 现在可能可以操作",不负责替应用执行
accept/read/write。 - epoll 不保存业务数据,TCP 数据仍然保存在各连接 socket 的接收队列中。
整体关系如下:
2. fd、struct file、struct socket 和 struct tcp_sock
2.1 fd 只是进程文件描述符表的下标
假设 Linux 为 socket 返回 fd=3,这个数字不是内核地址,只是数组下标:
text
current->files->fdt->fd[3] → struct file *
核心对象关系是:
text
进程 fd=3
│
▼
struct file
│ file->private_data
▼
struct socket
│ socket->sk
▼
一整块 struct tcp_sock 内存
struct file 不一定表示磁盘文件。Linux 的 socket、管道、eventfd 和 epoll 实例都可以通过 struct file 暴露成 fd。
2.2 struct socket 是系统调用层包装
简化后的结构:
c
struct socket {
socket_state state;
short type; // SOCK_STREAM
struct file *file;
struct sock *sk; // 指向协议对象
const struct proto_ops *ops; // bind/listen/accept/connect 等
};
对于 IPv4 TCP:
text
socket->type = SOCK_STREAM
socket->ops = inet_stream_ops
socket->sk = TCP 协议对象
2.3 四种 sock 结构是同一块内存的不同视图
它们不是四个对象用指针串起来,而是结构体逐层嵌套:
c
struct inet_sock {
struct sock sk;
// IP 地址、端口等
};
struct inet_connection_sock {
struct inet_sock icsk_inet;
// Accept 队列、连接定时器等
};
struct tcp_sock {
struct inet_connection_sock inet_conn;
// TCP 序列号、窗口、拥塞控制等
};
内存布局可以理解成:
text
一整块 struct tcp_sock 内存
┌──────────────────────────────────────────┐
│ struct sock │
│ sk_state、sk_wq、sk_receive_queue 等 │
├──────────────────────────────────────────┤
│ struct inet_sock 新增部分 │
│ 本地/远端 IP 和端口 │
├──────────────────────────────────────────┤
│ struct inet_connection_sock 新增部分 │
│ icsk_accept_queue、连接定时器等 │
├──────────────────────────────────────────┤
│ struct tcp_sock 新增部分 │
│ snd_nxt、rcv_nxt、snd_cwnd 等 │
└──────────────────────────────────────────┘
内核根据需要切换视角:
c
struct sock *sk = socket->sk;
struct inet_sock *inet = inet_sk(sk);
struct inet_connection_sock *icsk = inet_csk(sk);
struct tcp_sock *tp = tcp_sk(sk);
这些指针通常拥有相同的起始地址,只是编译器用不同类型解释该内存。
2.4 重要字段和队列
| 字段或队列 | 作用 | 哪类 socket 主要使用 |
|---|---|---|
sk->sk_state |
TCP_LISTEN/TCP_SYN_SENT/TCP_ESTABLISHED 等 |
所有 TCP socket |
inet->inet_num |
本地主机字节序端口 | 所有已绑定 socket |
inet->inet_sport |
本地网络字节序端口 | 所有已绑定 socket |
inet->inet_daddr/dport |
对端 IP 和端口 | 已连接 socket |
icsk->icsk_accept_queue |
等待应用 accept() 的连接 |
监听 socket |
sk->sk_receive_queue |
等待应用读取的数据 | 已连接 socket |
sk->sk_wq |
socket 事件等待队列 | 阻塞 I/O、poll、epoll 都会关联 |
sk->sk_ack_backlog |
当前 Accept 队列长度 | 监听 socket |
sk->sk_max_ack_backlog |
Accept 队列上限 | 监听 socket |
客户端和服务端都使用相同大小的 struct tcp_sock。客户端对象在内存布局上也有 icsk_accept_queue 字段,但只有 TCP_LISTEN socket 会真正使用它。
3. BIO 服务端:bind、listen、accept
示例:
java
ServerSocket serverSocket = new ServerSocket(8080);
Socket socket = serverSocket.accept();
3.1 创建、绑定和监听
第一行核心系统调用链路:
text
socket(AF_INET/AF_INET6, SOCK_STREAM, IPPROTO_TCP)
→ bind(fd, 0.0.0.0:8080 或 [::]:8080)
→ listen(fd, 50)
状态变化:
text
socket() 后
sk_state = TCP_CLOSE
local_port = 0
bind() 后
sk_state = TCP_CLOSE
local_ip = 0.0.0.0
local_port = 8080
加入端口绑定哈希体系
listen() 后
sk_state = TCP_LISTEN
sk_max_ack_backlog = min(50, net.core.somaxconn)
初始化 Accept 队列
加入监听哈希表
bind() 解决"这个 socket 占用哪个本地地址和端口";listen() 解决"这个 TCP socket 是否接受被动连接"。
3.2 accept 队列与线程等待队列不是同一个队列
监听 socket 同时涉及两个概念:
text
listener struct tcp_sock
├── icsk_accept_queue
│ 保存已完成握手、等待 accept() 的 child socket
│
└── sk_wq->wait
保存正在等待 socket 事件的线程或 poll/epoll 回调节点
前者放"连接",后者关联"等待者"。
3.3 BIO accept 阻塞链路
大致内核调用链:
text
accept4()
→ __sys_accept4()
→ socket->ops->accept()
→ inet_accept()
→ inet_csk_accept()
如果 Accept 队列非空:
text
从 icsk_accept_queue 取出 child struct sock
→ 为它创建 struct socket
→ 创建 struct file
→ 分配新 fd
→ accept4() 返回新 fd
如果 Accept 队列为空且 fd 是阻塞模式:
text
当前线程加入 listener->sk_wq->wait
→ 线程状态设为 TASK_INTERRUPTIBLE
→ schedule()
→ 从 CPU 可运行队列移出
简化伪代码:
c
while (accept_queue_empty(listener)) {
prepare_to_wait_exclusive(sk_sleep(listener), current);
set_current_state(TASK_INTERRUPTIBLE);
schedule();
}
child = dequeue_accept_queue(listener);
线程阻塞时不自旋,不持续占用 CPU。它保留内核栈和执行上下文,等待网络事件或信号将其唤醒。
4. BIO 客户端:connect
示例:
java
Socket socket = new Socket();
socket.connect(new InetSocketAddress("localhost", 8080));
4.1 Java 对象与内核 socket
现代 JDK 通常延迟创建 native socket:
text
new Socket()
→ 创建 Java Socket 和 SocketImpl
→ 可能尚未调用 Linux socket()
connect() 时才确保 native fd 存在:
text
解析 localhost
→ socket()
→ connect()
localhost 通常通过 /etc/hosts 解析成 127.0.0.1 或 ::1。
4.2 connect 内核链路
IPv4 TCP 大致经过:
text
connect()
→ __sys_connect()
→ socket->ops->connect()
→ inet_stream_connect()
→ tcp_v4_connect()
内核自动选择本地地址和临时端口:
text
local = 127.0.0.1:52346
remote = 127.0.0.1:8080
客户端状态:
text
TCP_CLOSE
→ TCP_SYN_SENT
→ TCP_ESTABLISHED
阻塞式 connect() 在握手未完成时,大致等待于:
text
sk_stream_wait_connect()
→ 当前线程关联到 client socket 的 sk_wq
→ TASK_INTERRUPTIBLE
→ schedule()
4.3 connect 和服务端 accept 的关系
TCP 三次握手完成后:
text
客户端 socket
sk_state = TCP_ESTABLISHED
connect() 可以返回
服务端 listener
Accept 队列加入 child socket
唤醒可能阻塞的 accept() 线程
客户端 connect() 不等待服务端 Java 代码实际调用 accept()。只要三次握手完成、服务端内核能把 child socket 放入 Accept 队列,客户端 connect() 就可以成功返回。
5. BIO read 阻塞在哪里
连接建立后:
java
int n = socket.getInputStream().read(buffer);
最终进入类似:
text
read()/recvfrom()
→ sock_read_iter()
→ inet_recvmsg()
→ tcp_recvmsg()
如果 sk_receive_queue 中有数据,内核复制到用户缓冲区并立即返回。
如果没有数据,阻塞线程等待的是:
text
connection->sk_receive_queue 非空
或者收到 FIN、RST、错误、超时、信号
线程仍然通过连接 socket 的 sk_wq 进入等待:
text
connection struct tcp_sock
├── sk_receive_queue = empty
└── sk_wq->wait
└── read() 线程,TASK_INTERRUPTIBLE
accept() 和 read() 的底层休眠机制相似,但条件不同:
| 调用 | 操作对象 | 等待条件 |
|---|---|---|
accept() |
TCP_LISTEN socket |
Accept 队列非空 |
read() |
TCP_ESTABLISHED socket |
接收队列有数据、EOF 或错误 |
connect() |
TCP_SYN_SENT socket |
进入 TCP_ESTABLISHED 或发生错误 |
6. BIO 的线程模型
典型 BIO 服务端:
java
while (true) {
Socket client = serverSocket.accept();
executor.submit(() -> handle(client));
}
结构:
text
一个 accept 线程
├── 连接 A → 工作线程 A 阻塞 read()
├── 连接 B → 工作线程 B 阻塞 read()
└── 连接 C → 工作线程 C 阻塞 read()
空闲连接仍可能占用一个平台线程。线程阻塞本身不持续消耗 CPU,但大量线程会带来:
- 线程栈内存;
- 调度和上下文切换;
- 线程池容量管理;
- 慢连接导致的线程长期占用。
BIO 并不等于"一定性能差"。连接数量有限、业务简单、使用虚拟线程或者线程模型容易维护时,BIO 可能是更合适的实现。
7. Java NIO:Channel 的非阻塞含义
服务端核心代码:
java
ServerSocketChannel server = ServerSocketChannel.open();
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
SocketChannel client = server.accept();
if (client == null) {
// 当前 Accept 队列为空,立即返回
}
7.1 ServerSocketChannel.open()
通常会创建 native socket:
text
ServerSocketChannel.open()
→ socket(AF_INET/AF_INET6, SOCK_STREAM, ...)
→ 得到 fd
此时内核对象仍然是普通 TCP socket:
text
fd
→ struct file
→ struct socket
→ struct tcp_sock
sk_state = TCP_CLOSE
7.2 configureBlocking(false)
JDK 通常通过类似调用把 fd 设置为非阻塞:
text
fcntl(fd, F_SETFL, O_NONBLOCK)
重要的是:
text
O_NONBLOCK 修改的是 fd 对应 open file description 的行为
不会把 struct tcp_sock 换成另一种结构
不会改变 TCP 协议状态机
不会创建 epoll
BIO socket 和 NIO socket 在 Linux TCP 层仍然使用相同的 struct tcp_sock。
7.3 非阻塞 accept
如果 Accept 队列有连接:
text
accept4()
→ 取出 child socket
→ 返回新 fd
如果 Accept 队列为空:
text
accept4()
→ 不把线程加入 listener 等待队列休眠
→ 返回 -EAGAIN/-EWOULDBLOCK
→ Java ServerSocketChannel.accept() 返回 null
如果应用不断这样调用:
java
while (server.accept() == null) {
}
就会形成用户态忙轮询,占满 CPU。Selector/epoll 的意义就是避免这种忙轮询。
7.4 非阻塞 read
java
int n = channel.read(buffer);
可能返回:
| 返回值 | 含义 |
|---|---|
n > 0 |
读取了 n 字节 |
n == 0 |
当前没有数据,非阻塞立即返回 |
n == -1 |
对端正常关闭写方向,读到 EOF |
Linux 层没有数据时返回 EAGAIN,JDK 把它转换成 Java NIO 的 0。
7.5 非阻塞 connect
java
SocketChannel channel = SocketChannel.open();
channel.configureBlocking(false);
boolean done = channel.connect(address);
通常发生:
text
connect(fd, server)
→ 开始发送 SYN
→ sk_state = TCP_SYN_SENT
→ 握手尚未完成
→ 返回 -EINPROGRESS
→ Java connect() 返回 false
握手完成后,fd 通常表现为"可写",应用再调用:
java
channel.finishConnect();
finishConnect() 检查连接最终结果:
text
TCP_ESTABLISHED → 成功
SO_ERROR 非 0 → 抛出 ConnectException 等异常
仍在连接中 → 尚未完成
8. Selector 与 epoll 的关系
Java API:
java
Selector selector = Selector.open();
在 Linux OpenJDK 中通常使用基于 epoll 的 Selector 实现:
text
Java Selector
→ EPollSelectorImpl
→ epoll_create1()
→ epoll_ctl()
→ epoll_wait()
但规范只规定 Selector 行为,不保证所有操作系统都使用 epoll:
text
Linux → 通常 epoll
macOS → kqueue
其他系统 → 对应平台实现
8.1 Selector 事件与 epoll 事件映射
| Java SelectionKey | 典型 epoll 事件 | 内核就绪条件 |
|---|---|---|
OP_ACCEPT |
EPOLLIN |
监听 socket 的 Accept 队列非空 |
OP_CONNECT |
EPOLLOUT 加错误状态 |
非阻塞 connect 完成或失败 |
OP_READ |
EPOLLIN |
接收队列有数据、FIN 或错误 |
OP_WRITE |
EPOLLOUT |
发送缓冲区有足够空间 |
同一个 EPOLLIN 在不同 socket 状态下含义不同:
text
TCP_LISTEN socket
EPOLLIN → 可以 accept()
TCP_ESTABLISHED socket
EPOLLIN → 可以 read(),或者存在 EOF/错误
9. epoll 的内核对象
9.1 epoll_create1()
调用:
c
int epfd = epoll_create1(EPOLL_CLOEXEC);
Linux 会创建一个 epoll 实例,并同样通过 fd 暴露:
text
epfd=6
→ struct file
→ private_data
→ struct eventpoll
简化后的 eventpoll:
c
struct eventpoll {
struct rb_root_cached rbr; // 关注集合,保存注册的 epitem
struct list_head rdllist; // 就绪链表
wait_queue_head_t wq; // epoll_wait() 线程等待队列
// 锁、溢出链表、被监控 file 等
};
每个被监控的 fd 对应一个类似 epitem 的注册项:
c
struct epitem {
struct rb_node rbn; // 挂在关注集合中
struct list_head rdllink; // 就绪时挂入 rdllist
struct epoll_filefd ffd; // 被监控的 file/fd
struct eventpoll *ep; // 所属 epoll 实例
struct epoll_event event; // EPOLLIN/EPOLLOUT 等
// 与目标文件等待队列关联的信息
};
可以理解成:
text
eventpoll
├── 关注集合 rbr
│ ├── epitem(listener fd)
│ ├── epitem(client A fd)
│ └── epitem(client B fd)
│
├── 就绪链表 rdllist
│ └── 当前已经就绪的 epitem
│
└── epoll_wait 等待队列 wq
└── 当前事件循环线程
红黑树是常见内核实现细节,用于管理关注集合。对应用最重要的是"关注集合"和"就绪链表"的区别。
9.2 epoll_ctl(ADD) 做了什么
Java:
java
server.register(selector, SelectionKey.OP_ACCEPT);
最终会使 JDK 执行类似:
c
epoll_ctl(epfd, EPOLL_CTL_ADD, serverFd, &event);
内核大致完成:
- 找到 epfd 对应的
struct eventpoll。 - 找到 serverFd 对应的
struct file。 - 创建
struct epitem。 - 把 epitem 插入 eventpoll 的关注集合。
- 调用目标文件的
poll方法查询当前状态。 - 把 epoll 回调节点注册到目标 socket 的等待队列。
关键不是 epoll 反复扫描所有 socket,而是注册时建立回调关系:
text
listener socket 的 sk_wq
│
└── epoll callback
│
▼
eventpoll 中对应的 epitem
更完整的关系:
内核注册阶段会通过 socket 的 poll 实现检查就绪条件。TCP 常见路径可以概念化为:
text
epoll_ctl(ADD)
→ target_file->f_op->poll()
→ sock_poll()/tcp_poll()
→ poll_wait(file, sk_sleep(sk), ...)
→ 将 epoll 回调挂到 socket 的 sk_wq
9.3 epoll_wait() 阻塞在哪里
Java:
java
int count = selector.select();
Linux:
c
epoll_wait(epfd, events, maxEvents, timeout);
如果 rdllist 已经有就绪项,立即把事件复制到用户态。
如果 rdllist 为空:
text
当前事件循环线程加入 eventpoll->wq
→ TASK_INTERRUPTIBLE
→ schedule()/带超时调度
此时线程主要睡在 epoll 实例的等待队列上,而不是分别睡到每一个 socket 的等待队列上:
text
多个 socket
├── socket A sk_wq ──callback──┐
├── socket B sk_wq ──callback──┼──> eventpoll
└── socket C sk_wq ──callback──┘ │
▼
eventpoll->wq
│
▼
一个 epoll_wait 线程
这就是一个线程能够等待大量连接的核心原因。
10. 新连接如何通过 epoll 唤醒 Selector
假设 listener 已注册 OP_ACCEPT,事件循环阻塞在:
text
selector.select()
→ epoll_wait()
客户端完成三次握手后:
text
1. 内核创建 child struct tcp_sock
2. child 加入 listener 的 Accept 队列
3. listener 的 sk_ack_backlog 增加
4. 内核触发 listener 的可读事件
5. 唤醒 listener->sk_wq 上的回调
6. epoll callback 把 listener 对应 epitem 放入 rdllist
7. 唤醒 eventpoll->wq 上的 epoll_wait 线程
8. epoll_wait 返回 EPOLLIN
9. JDK 转换为 SelectionKey.OP_ACCEPT
10. Java 调用 ServerSocketChannel.accept()
结构变化:
text
握手前
listener
├── Accept 队列 = empty
└── epitem 不在 rdllist
握手完成
listener
├── Accept 队列
│ └── child TCP_ESTABLISHED
└── epitem 加入 eventpoll.rdllist
epoll_wait 返回后
Java Selector selectedKeys
└── listener SelectionKey,isAcceptable() = true
Java 必须继续调用:
java
SocketChannel channel = server.accept();
epoll 只通知,真正从 Accept 队列取连接的仍然是 accept4()。
11. TCP 数据如何通过 epoll 唤醒 Selector
假设连接 channel 注册了 OP_READ。
数据到达大致经过:
text
网卡/loopback 收包
→ IP 层
→ TCP 层
→ 根据四元组在 established hash 找到 child tcp_sock
→ TCP 校验、排序、组装
→ 数据进入 socket 接收队列
→ 触发 sk_data_ready
→ 唤醒 child->sk_wq 上的 epoll callback
→ epitem 加入 eventpoll.rdllist
→ 唤醒 epoll_wait 线程
→ epoll_wait 返回 EPOLLIN
→ Java SelectionKey.isReadable() == true
Java 随后执行:
java
int n = channel.read(buffer);
这一步才真正把数据从内核 socket 接收缓冲区复制到 Java 的 ByteBuffer。
因此:
text
EPOLLIN
不表示数据已经进入 Java ByteBuffer
只表示 read() 现在大概率不会阻塞
在并发竞争、事件已被其他线程消费等情况下,即使收到就绪通知,非阻塞 read() 仍可能返回 0/EAGAIN,所以应用必须正确处理返回值。
12. 非阻塞 connect 如何通过 epoll 完成
客户端:
java
SocketChannel client = SocketChannel.open();
client.configureBlocking(false);
if (!client.connect(address)) {
client.register(selector, SelectionKey.OP_CONNECT);
}
状态变化:
text
socket()
sk_state = TCP_CLOSE
connect()
自动绑定本地临时端口
设置远端地址
sk_state = TCP_SYN_SENT
返回 EINPROGRESS
三次握手完成
sk_state = TCP_ESTABLISHED
fd 表现为 EPOLLOUT/连接完成
事件循环收到 OP_CONNECT 后:
java
if (key.isConnectable()) {
SocketChannel channel = (SocketChannel) key.channel();
if (channel.finishConnect()) {
key.interestOps(SelectionKey.OP_READ);
}
}
必须调用 finishConnect(),因为"可连接事件"既可能表示成功,也可能表示连接失败。最终错误需要通过 socket 错误状态确认。
13. OP_WRITE 为什么容易造成空转
TCP socket 的发送缓冲区通常大部分时间都有空间,因此 EPOLLOUT 往往长期处于就绪状态。
如果一直注册:
java
key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE);
那么 selector.select() 可能持续立即返回,形成高 CPU 空转。
正确模式通常是:
text
平时只关注 OP_READ
出现待发送数据,并且一次 write() 没写完
→ 打开 OP_WRITE
待发送队列全部写完
→ 移除 OP_WRITE
非阻塞 write() 返回值:
text
n > 0 → 写入了一部分或全部数据
n == 0 → 当前发送缓冲区空间不足,等待下一次 OP_WRITE
14. LT 与 ET
epoll 有两种典型通知模式。
14.1 LT:水平触发
只要就绪条件仍然成立,每次 epoll_wait() 都可以继续返回该事件:
text
接收队列还有数据
→ EPOLLIN
→ 只读取一部分
→ 接收队列仍有数据
→ 下次 epoll_wait 继续返回 EPOLLIN
Java 标准 NIO Selector 在 Linux 上通常使用接近 LT 的行为。
14.2 ET:边缘触发
主要在状态从"不就绪"变为"就绪"时通知:
text
接收队列从空变为非空
→ 通知一次 EPOLLIN
ET 下必须循环读到 EAGAIN:
c
for (;;) {
n = read(fd, buf, sizeof(buf));
if (n > 0) {
consume(buf, n);
continue;
}
if (n == -1 && errno == EAGAIN) {
break;
}
handle_close_or_error();
break;
}
类似地,监听 fd 在 ET 模式下一般要循环 accept() 到 EAGAIN,否则 Accept 队列中可能残留连接而暂时得不到新的边缘通知。
Netty 的 NioEventLoopGroup 使用 Java NIO Selector;Netty 的 native epoll transport 则可以直接使用 Linux epoll,并根据实现采用更贴近 native 的事件模式。两者不要仅凭名称混为一谈。
15. 完整 NIO + Selector 服务端示例
java
package com.lgw.nio;
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;
public final class SelectorServer {
public static void main(String[] args) throws IOException {
try (Selector selector = Selector.open();
ServerSocketChannel server = ServerSocketChannel.open()) {
server.configureBlocking(false);
server.bind(new InetSocketAddress(8080));
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()) {
acceptAll((ServerSocketChannel) key.channel(), selector);
}
if (key.isReadable()) {
read((SocketChannel) key.channel(), key);
}
}
}
}
}
private static void acceptAll(ServerSocketChannel server,
Selector selector) throws IOException {
SocketChannel client;
// 即使标准 Selector 通常是 LT,也使用循环 accept 的稳健写法。
while ((client = server.accept()) != null) {
client.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocateDirect(4096);
client.register(selector, SelectionKey.OP_READ, buffer);
}
}
private static void read(SocketChannel client,
SelectionKey key) throws IOException {
ByteBuffer buffer = (ByteBuffer) key.attachment();
int n = client.read(buffer);
if (n > 0) {
buffer.flip();
while (buffer.hasRemaining()) {
// 示例只消费数据;真实协议需要处理拆包、粘包和半包。
buffer.get();
}
buffer.clear();
return;
}
if (n == -1) {
key.cancel();
client.close();
}
}
}
这段代码对应的内核行为:
text
Selector.open()
→ epoll_create1()
ServerSocketChannel.open()
→ socket()
configureBlocking(false)
→ fcntl(O_NONBLOCK)
bind()
→ bind()
→ listen()
register(OP_ACCEPT)
→ epoll_ctl(ADD/MOD, listenerFd, EPOLLIN)
selector.select()
→ epoll_wait()
server.accept()
→ accept4()
client.register(OP_READ)
→ epoll_ctl(ADD/MOD, clientFd, EPOLLIN)
client.read()
→ read()/recvmsg()
JDK 可能把注册变更先放入更新队列,在下一次 select() 前统一执行 epoll_ctl(),所以 Java 的 register() 与系统调用不一定严格一一同步发生。
16. 完整非阻塞客户端示例
java
package com.lgw.nio;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
public final class SelectorClient {
public static void main(String[] args) throws IOException {
try (Selector selector = Selector.open();
SocketChannel client = SocketChannel.open()) {
client.configureBlocking(false);
boolean connected = client.connect(
new InetSocketAddress("localhost", 8080));
if (connected) {
client.register(selector, SelectionKey.OP_READ);
} else {
client.register(selector, SelectionKey.OP_CONNECT);
}
while (client.isOpen()) {
selector.select();
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) {
continue;
}
if (key.isConnectable()) {
SocketChannel channel = (SocketChannel) key.channel();
if (channel.finishConnect()) {
key.interestOps(SelectionKey.OP_READ);
}
}
if (key.isReadable()) {
// 按协议读取响应;示例省略。
}
}
}
}
}
}
17. BIO、NIO 和 epoll 阻塞点对比
17.1 BIO accept
text
线程
→ accept4(listenerFd)
→ Accept 队列为空
→ 关联 listener->sk_wq
→ TASK_INTERRUPTIBLE
→ schedule()
17.2 BIO read
text
线程
→ read(connectionFd)
→ sk_receive_queue 为空
→ 关联 connection->sk_wq
→ TASK_INTERRUPTIBLE
→ schedule()
17.3 NIO 非阻塞调用
text
accept4/read/connect
→ 当前不能完成
→ 立即返回 EAGAIN/EINPROGRESS
→ 不在这个系统调用里睡眠
17.4 Selector/epoll
text
一个事件循环线程
→ epoll_wait(epfd)
→ eventpoll.rdllist 为空
→ 加入 eventpoll->wq
→ TASK_INTERRUPTIBLE
→ schedule()
因此 NIO 并不是"完全不阻塞"。通常是:
text
业务 socket 操作不阻塞
事件循环线程集中阻塞在一次 epoll_wait()
18. socket 和 epoll 结构的完整关系
注册三个 fd 后,可以概括为:
text
Java Selector
│
▼
epfd=6
→ struct file
→ struct eventpoll
├── 关注集合
│ ├── epitem(fd=3 listener)
│ ├── epitem(fd=4 connection A)
│ └── epitem(fd=5 connection B)
│
├── 就绪链表 rdllist
│ └── 已就绪 epitem
│
└── epoll_wait 等待队列 wq
└── EventLoop 线程
fd=3 listener
→ struct file
→ struct socket
→ listener struct tcp_sock
├── sk_state = TCP_LISTEN
├── icsk_accept_queue
└── sk_wq ──epoll callback──> epitem(fd=3)
fd=4 connection A
→ struct file
→ struct socket
→ child struct tcp_sock
├── sk_state = TCP_ESTABLISHED
├── sk_receive_queue
└── sk_wq ──epoll callback──> epitem(fd=4)
fd=5 connection B
→ struct file
→ struct socket
→ child struct tcp_sock
├── sk_state = TCP_ESTABLISHED
├── sk_receive_queue
└── sk_wq ──epoll callback──> epitem(fd=5)
核心不是 epoll 把所有 socket 数据搬到自己内部,而是:
text
每个 socket 仍拥有自己的协议状态和数据队列
epoll 只维护关注关系和就绪引用
19. 服务端与客户端 socket 的结构变化时间线
19.1 服务端监听 socket
text
socket()
fd=3
sk_state = TCP_CLOSE
local_port = 0
bind(:8080)
sk_state = TCP_CLOSE
local = 0.0.0.0:8080
加入 bind hash
listen(50)
sk_state = TCP_LISTEN
初始化 Accept 队列
sk_max_ack_backlog = 50
加入 listen hash
epoll_ctl(ADD, fd=3, EPOLLIN)
创建 epitem
epoll callback 关联 listener->sk_wq
TCP 状态不变
19.2 客户端连接 socket
text
socket()
fd=4
sk_state = TCP_CLOSE
connect(server:8080)
自动绑定 local:临时端口
设置 remote:8080
sk_state = TCP_SYN_SENT
握手完成
sk_state = TCP_ESTABLISHED
非阻塞 connect fd 就绪
epoll_ctl(ADD, fd=4, EPOLLIN)
创建 epitem
callback 关联 client->sk_wq
19.3 服务端 child socket
text
客户端 SYN
→ listener 创建 request_sock
三次握手完成
→ 创建 child struct tcp_sock
→ child.sk_state = TCP_ESTABLISHED
→ child 加入 listener Accept 队列
→ listener 产生 EPOLLIN
应用 accept4()
→ child 从 Accept 队列移除
→ 创建新 struct socket/struct file/fd
epoll_ctl(ADD, childFd, EPOLLIN)
→ child 开始由 Selector 监控读事件
20. 一次新连接和一次读事件的完整时序
21. 常见误区
误区一:NIO 使用不同类型的内核 socket
错误。BIO 和 NIO 最终都使用 struct socket + struct tcp_sock。主要差异是 fd 是否设置 O_NONBLOCK,以及应用如何等待就绪事件。
误区二:epoll 帮应用执行 read
错误。epoll 只返回事件,应用仍必须调用 read()。
误区三:EPOLLIN 一定表示有普通业务数据
错误。它还可能表示:
- 监听 socket 有连接可以
accept(); - 对端发送 FIN,读取将得到 EOF;
- socket 出现错误;
- 接收队列有数据。
误区四:NIO 没有任何线程阻塞
错误。事件循环线程通常阻塞在 epoll_wait(),只是没有为每个连接分别阻塞一个线程。
误区五:accept() 才创建服务端 TCP 连接
不准确。TCP 三次握手完成时,内核通常已经创建 child struct sock 并放入 Accept 队列;accept() 主要负责取出它并包装成新的 fd。
误区六:连接建立就代表服务端业务代码已经处理
错误。客户端 connect() 成功只说明 TCP 握手完成。服务端连接可能还在 Accept 队列,甚至尚未被 Java accept() 取出。
误区七:注册 OP_WRITE 越多越好
错误。发送缓冲区通常可写,长期注册 OP_WRITE 很容易让事件循环持续空转。
22. 如何在 Linux 上观察
22.1 查看监听和连接
bash
ss -lntp 'sport = :8080'
bash
ss -ntp '( sport = :8080 or dport = :8080 )'
常见状态:
text
LISTEN → TCP_LISTEN
SYN-SENT → TCP_SYN_SENT
SYN-RECV → TCP_SYN_RECV
ESTAB → TCP_ESTABLISHED
22.2 查看进程 fd
bash
ls -l /proc/<pid>/fd
可能看到:
text
3 -> socket:[123456]
4 -> socket:[123457]
6 -> anon_inode:[eventpoll]
7 -> anon_inode:[eventfd]
anon_inode:[eventpoll] 通常就是 Selector 使用的 epoll fd。JDK 还可能创建 eventfd,用于从其他线程唤醒阻塞的 Selector。
22.3 用 strace 观察 BIO
bash
strace -ff \
-e trace=socket,bind,listen,accept,accept4,connect,read,write,close \
java BioServer
可能看到:
text
socket(AF_INET6, SOCK_STREAM, ...) = 3
bind(3, ..., 8080) = 0
listen(3, 50) = 0
accept4(3, ...) = 4
read(4, ...) = ...
22.4 用 strace 观察 NIO/epoll
bash
strace -ff \
-e trace=socket,bind,listen,accept4,connect,read,write,fcntl,epoll_create1,epoll_ctl,epoll_wait,eventfd2 \
java SelectorServer
典型模式:
text
epoll_create1(...) = 6
socket(...) = 3
fcntl(3, F_SETFL, O_NONBLOCK) = 0
bind(3, ...) = 0
listen(3, ...) = 0
epoll_ctl(6, EPOLL_CTL_ADD, 3, ...) = 0
epoll_wait(6, ...) = 1
accept4(3, ..., SOCK_NONBLOCK) = 4
epoll_ctl(6, EPOLL_CTL_ADD, 4, ...) = 0
epoll_wait(6, ...) = 1
read(4, ...) = ...
JDK 的实际系统调用顺序可能因版本、IPv4/IPv6、Selector 唤醒实现和超时设置不同而变化。
23. TCP 建连与关闭状态机
TCP 的状态保存在:
c
sk->sk_state
常见状态如下。问题中的 SYN_RECD 正确名称是 SYN_RECV:
| Linux 状态 | /proc/net/tcp |
含义 |
|---|---|---|
TCP_ESTABLISHED |
01 |
三次握手完成,可以双向传输数据 |
TCP_SYN_SENT |
02 |
主动连接方已经发送 SYN,等待 SYN-ACK |
TCP_SYN_RECV |
03 |
已收到 SYN 并回复 SYN-ACK,等待最后 ACK |
TCP_FIN_WAIT1 |
04 |
主动关闭方已经发送 FIN,等待 ACK 或对端 FIN |
TCP_FIN_WAIT2 |
05 |
自己的 FIN 已被确认,等待对端发送 FIN |
TCP_TIME_WAIT |
06 |
已确认对端 FIN,等待旧报文过期并处理 FIN 重传 |
TCP_CLOSE |
07 |
未建立连接或连接已经彻底结束 |
TCP_CLOSE_WAIT |
08 |
收到对端 FIN,等待本地应用关闭 |
TCP_LAST_ACK |
09 |
被动关闭方已发送自己的 FIN,等待最后 ACK |
TCP_LISTEN |
0A |
监听端口,等待连接请求 |
TCP_CLOSING |
0B |
双方几乎同时发送 FIN,等待自己的 FIN 被确认 |
TCP_NEW_SYN_RECV |
0C |
Linux 内部表示尚未完成握手的请求状态之一 |
使用 ss 时通常显示为:
text
LISTEN
SYN-SENT
SYN-RECV
ESTAB
FIN-WAIT-1
FIN-WAIT-2
CLOSE-WAIT
LAST-ACK
CLOSING
TIME-WAIT
CLOSE
23.1 完整状态机概览
这是一张正常路径图。RST、超时、应用异常退出和 SO_LINGER=0 可能让连接跳过部分状态,直接进入 TCP_CLOSE。
23.2 CLOSE
TCP_CLOSE 有两层含义:
socket()刚创建、还没有连接时的初始 TCP 状态;- 连接已经结束时的最终协议状态。
刚创建客户端 socket:
text
fd=4
→ struct socket
→ struct tcp_sock
sk_state = TCP_CLOSE
local_port = 0
remote = 未设置
调用 close(fd) 后,不能简单理解为"这个 tcp_sock 永远留在 CLOSE 状态"。如果没有其他引用,内核还会继续释放:
text
fdtable 中移除 fd
→ struct file 引用计数下降
→ struct socket 释放
→ struct sock/tcp_sock 释放或转换成 TIME_WAIT 对象
因此 CLOSE 往往是短暂的协议状态,最终对象本身可能已经不存在。
23.3 LISTEN
服务端调用:
text
socket()
→ bind(:8080)
→ listen()
之后:
text
listener struct tcp_sock
├── sk_state = TCP_LISTEN
├── local = 0.0.0.0:8080
├── icsk_accept_queue
├── sk_ack_backlog
└── sk_max_ack_backlog
监听 socket 不代表某一条连接,它代表一个本地监听端点。真正的连接由握手过程中创建的 child socket 承载。
如果关闭监听 socket:
java
serverSocket.close();
大致发生:
text
listener 从监听哈希表移除
→ 不再接受新 SYN
→ 唤醒阻塞的 accept/poll/epoll 等待者
→ 清理尚未被 accept 的连接和握手请求
→ listener 最终释放
已经被 accept() 返回的连接拥有独立 fd 和 tcp_sock,不会因为关闭 listener 而自动关闭:
text
关闭 fd=3 listener
不等于
关闭 fd=4、fd=5 已建立连接
23.4 SYN_SENT
客户端主动调用:
java
socket.connect(address);
发送 SYN 后:
text
client struct tcp_sock
├── sk_state = TCP_SYN_SENT
├── local = 本地IP:临时端口
├── remote = 服务端IP:8080
├── 初始发送序列号已经生成
└── SYN 重传定时器运行
阻塞式 connect() 会等待状态变化:
text
TCP_SYN_SENT
→ TCP_ESTABLISHED
或者
→ 连接错误/超时/TCP_CLOSE
非阻塞 connect() 通常返回 EINPROGRESS,由 epoll 通过可写或错误事件通知连接完成,再由 finishConnect() 检查最终结果。
如果目标端口没有监听 socket:
text
客户端 SYN
→ 对端返回 RST
→ 客户端记录 ECONNREFUSED
→ TCP_SYN_SENT → TCP_CLOSE
→ Java ConnectException
23.5 SYN_RECV 与 request_sock
服务端 listener 收到 SYN 后不会把 listener 自己变成 SYN_RECV:
text
listener 仍然是 TCP_LISTEN
内核为半连接创建较轻量的:
c
struct request_sock
概念结构:
text
listener struct tcp_sock
sk_state = TCP_LISTEN
│
└── request_sock
client = 客户端IP:临时端口
server = 服务端IP:8080
state = SYN_RECV/NEW_SYN_RECV
收到客户端最终 ACK 后,内核才创建完整 child struct tcp_sock:
text
request_sock
→ child struct tcp_sock
sk_state = TCP_ESTABLISHED
→ 加入 listener Accept 队列
因此 Linux 内核中可能看到 TCP_NEW_SYN_RECV,而用户工具通常把这一阶段显示为 SYN-RECV。
23.6 ESTABLISHED
连接两端都进入:
text
TCP_ESTABLISHED
客户端:
text
client tcp_sock
local = 192.168.1.20:52346
remote = 192.168.1.10:8080
服务端 child:
text
server child tcp_sock
local = 192.168.1.10:8080
remote = 192.168.1.20:52346
此时双方完全对等,都可以读写:
text
sk_receive_queue 接收数据
TCP 发送/重传队列 管理待发送与未确认数据
sk_wq 关联 read/write/poll/epoll 等等待者
"客户端"和"服务端"是连接建立方式上的角色。进入 ESTABLISHED 后,两个完整 tcp_sock 在协议能力上没有本质区别。
23.7 Java close 到 Linux tcp_close
Java:
java
socket.close();
大致链路:
text
Socket.close()
→ SocketImpl.close()
→ close(fd)
→ struct file 的 release
→ sock_release()/inet_release()
→ tcp_close()
正常优雅关闭时,TCP 会尽量:
- 处理或发送尚未发送的数据;
- 发送 FIN;
- 进入 FIN 相关状态;
- 等待对端确认和关闭。
Java close() 返回不代表网络中的关闭握手已经全部结束。fd 可以已经从进程中消失,但内核仍可能保留 FIN_WAIT 或 TIME_WAIT 状态。
23.8 主动关闭:FIN_WAIT1、FIN_WAIT2、TIME_WAIT
假设客户端主动关闭,服务端稍后关闭。
初始状态:
text
客户端 ESTABLISHED
服务端 ESTABLISHED
客户端调用 close() 或 shutdownOutput():
text
客户端发送 FIN
客户端 ESTABLISHED → FIN_WAIT1
结构仍然是完整 tcp_sock:
text
client struct tcp_sock
├── sk_state = TCP_FIN_WAIT1
├── 本端 FIN 已占用一个 TCP 序列号
├── 等待 FIN 被 ACK
└── 仍可能接收对端数据
服务端收到 FIN 并回复 ACK 后:
text
客户端收到 ACK
FIN_WAIT1 → FIN_WAIT2
FIN_WAIT2 表示:
text
本端已经不能再发送普通数据
本端 FIN 已被确认
仍在等待对端发送 FIN
仍可以接收对端尚未发送完的数据
服务端稍后发送 FIN,客户端回复最终 ACK:
text
客户端 FIN_WAIT2 → TIME_WAIT
完整主动关闭路径:
text
ESTABLISHED
→ FIN_WAIT1
→ FIN_WAIT2
→ TIME_WAIT
→ CLOSE
23.9 被动关闭:CLOSE_WAIT 与 LAST_ACK
服务端收到客户端 FIN 后:
text
服务端回复 ACK
ESTABLISHED → CLOSE_WAIT
CLOSE_WAIT 的含义不是"内核还没处理完",而是:
text
对端已经不再发送数据
内核已经通知本地应用 EOF
正在等待本地应用调用 close() 或 shutdownOutput()
如果接收队列中还有数据,Java read() 会先读完缓冲数据,再返回:
java
-1
对端 FIN 表示对端关闭了发送方向,并不自动禁止本端继续发送。因此处于 CLOSE_WAIT 的一端仍可把剩余响应发送出去。
本地应用调用 close() 后:
text
发送本端 FIN
CLOSE_WAIT → LAST_ACK
收到对端最终 ACK:
text
LAST_ACK → CLOSE
完整被动关闭路径:
text
ESTABLISHED
→ CLOSE_WAIT
→ LAST_ACK
→ CLOSE
服务器上大量长期 CLOSE-WAIT 通常意味着:
text
对端已经关闭
但是本地应用没有及时 close 对应 Socket/Channel
这通常是应用资源泄漏或异常路径遗漏关闭,而不是调整内核参数可以根治的问题。
23.10 TIME_WAIT 的内核结构变化
TIME_WAIT 一般由主动完成关闭的一方进入。它有两个核心目的:
- 对端如果没有收到最后 ACK、重传 FIN,本端仍能再次回复 ACK;
- 等待网络中属于旧连接的重复报文过期,避免污染复用相同四元组的新连接。
在 Linux 中,进入 TIME_WAIT 后通常没有必要继续保留完整且较大的 struct tcp_sock。内核会使用更轻量的时间等待对象:
c
struct inet_timewait_sock
TCP 还可能使用扩展的:
c
struct tcp_timewait_sock
结构变化可以理解为:
text
FIN_WAIT2 阶段
完整 struct tcp_sock
├── 发送/接收状态
├── 拥塞控制状态
├── 重传信息
└── 四元组
进入 TIME_WAIT
释放完整 tcp_sock 的大量资源
→ 创建/保留更轻量的 inet_timewait_sock
├── 必要的四元组
├── TCP 时间等待状态
├── 最后序列号等必要信息
└── TIME_WAIT 定时器
TIME_WAIT 对象仍需参与连接哈希查找,以处理旧连接报文和端口/四元组复用判断。
通常把 TIME_WAIT 持续时间描述为 2MSL。Linux 的实际定时和实现由内核版本决定,常见观察值大约为 60 秒,不能只把概念公式当成所有系统都完全相同的固定配置。
TIME_WAIT 到期后:
text
从相关哈希表移除
→ 释放 inet_timewait_sock
→ TCP_CLOSE/对象消失
23.11 CLOSING:双方同时关闭
如果双方几乎同时调用 close():
text
A ESTABLISHED → 发送 FIN → FIN_WAIT1
B ESTABLISHED → 发送 FIN → FIN_WAIT1
A 收到 B 的 FIN,但 A 自己的 FIN 尚未收到 ACK:
text
A FIN_WAIT1 → CLOSING
B 同理:
text
B FIN_WAIT1 → CLOSING
之后收到自己的 FIN 对应 ACK:
text
CLOSING → TIME_WAIT
典型路径:
text
ESTABLISHED
→ FIN_WAIT1
→ CLOSING
→ TIME_WAIT
→ CLOSE
CLOSING 较少出现,因为常见业务协议通常由一方先发起关闭,但在双方同时退出或异常关闭协调时可以观察到。
23.12 FIN_WAIT1 直接进入 TIME_WAIT
有时对端的报文同时满足:
text
确认了本端 FIN
并携带对端 FIN
那么本端可能不经过可观察的 FIN_WAIT2,直接:
text
FIN_WAIT1 → TIME_WAIT
TCP 状态不是每个阶段都一定能被 ss 捕捉到,短暂状态可能在两次采样之间已经完成转换。
23.13 四次挥手的双方状态时间线
假设客户端主动关闭:
| 网络事件 | 客户端状态 | 服务端状态 |
|---|---|---|
| 连接正常 | ESTABLISHED |
ESTABLISHED |
| 客户端发送 FIN | FIN_WAIT1 |
ESTABLISHED |
| 服务端收到 FIN 并回复 ACK | FIN_WAIT1 |
CLOSE_WAIT |
| 客户端收到 ACK | FIN_WAIT2 |
CLOSE_WAIT |
| 服务端应用调用 close 并发送 FIN | FIN_WAIT2 |
LAST_ACK |
| 客户端收到 FIN 并回复 ACK | TIME_WAIT |
LAST_ACK |
| 服务端收到最终 ACK | TIME_WAIT |
CLOSE |
| TIME_WAIT 到期 | CLOSE |
CLOSE |
23.14 半关闭:shutdownOutput 与 shutdownInput
TCP 是全双工协议,两个传输方向可以分别关闭。
Java:
java
socket.shutdownOutput();
表示本端不会再发送数据,内核通常在已排队数据发送完成后发送 FIN:
text
本端发送方向关闭
→ 进入 FIN_WAIT1/FIN_WAIT2 等状态
→ 本端仍可以继续 read 对端数据
这适合"请求发送完毕,但还要继续读取响应"的协议。
java
socket.shutdownInput();
表示本地应用不再接收数据。它和发送 FIN 不是同一件事:TCP 的 FIN 主要表达"我不会再向你发送数据",所以关闭输出方向才直接对应 TCP FIN 语义。
socket.close() 通常关闭两个应用方向并释放 fd;shutdownOutput() 可以保留 fd 和读取方向。
23.15 read、write 在关闭状态下的表现
收到 FIN
对端 FIN 到达后:
text
ESTABLISHED → CLOSE_WAIT
如果接收队列还有数据:
text
read() 先返回剩余数据
数据耗尽后:
text
Linux read() 返回 0
Java InputStream.read() 返回 -1
Java SocketChannel.read() 返回 -1
CLOSE_WAIT 仍可能 write
收到对端 FIN 只代表对端不再发送。处于 CLOSE_WAIT 时,本端发送方向仍可能正常,因此可以继续写完响应,再主动关闭本端输出。
已发送 FIN 后继续写
本端发送 FIN 后再写通常会失败,最终表现为:
text
EPIPE
SIGPIPE(native 程序如果未忽略)
Java SocketException: Broken pipe 等
具体异常还取决于是否先收到了 RST、JDK 实现和写入时机。
23.16 BIO 阻塞调用如何被关闭事件唤醒
read 阻塞
线程原本等待:
text
connection->sk_receive_queue 有数据
收到 FIN、RST 或错误后,内核触发 socket 状态/数据回调并唤醒等待队列:
text
FIN/RST 到达
→ 更新 sk_state/sk_err
→ 唤醒 connection->sk_wq
→ read 线程恢复
→ 返回 EOF 或错误
accept 阻塞
如果另一个线程关闭 listener:
text
listener close
→ TCP_LISTEN 结束
→ 唤醒 listener->sk_wq
→ accept 重新检查状态
→ 返回错误
→ Java 通常抛出 SocketException
23.17 epoll/Selector 如何观察关闭
对已连接 socket,epoll 可能报告:
| epoll 事件 | 常见含义 |
|---|---|
EPOLLIN |
有数据可读,或者读方向已经到 EOF |
EPOLLRDHUP |
对端关闭写方向,收到 FIN |
EPOLLHUP |
socket 挂断 |
EPOLLERR |
存在待处理 socket 错误 |
Java 标准 Selector 通常把对端关闭表现成读就绪:
text
SelectionKey.isReadable() == true
→ SocketChannel.read(buffer)
→ 返回 -1
→ 应用 cancel key 并 close channel
不能只在"有普通数据"时处理 OP_READ,还必须处理 read() == -1。
EPOLLERR 和 EPOLLHUP 即使没有显式加入 interest mask,Linux epoll 也会按其语义报告相关状态。应用仍应通过 read/write/getsockopt(SO_ERROR) 等操作确认最终结果。
Channel 关闭后,JDK 会取消 SelectionKey,并在 Selector 更新阶段从 epoll 关注集合删除对应项。Linux 在目标 struct file 最后一个引用关闭时也会清理与 epoll 的关联。
23.18 RST:非优雅关闭
FIN 表示优雅关闭一个发送方向;RST 表示连接被立即复位。
常见 RST 场景:
- 连接一个没有监听的端口;
- 对端进程异常退出或协议状态不匹配;
- 对端关闭后仍收到无法归属的报文;
- 设置
SO_LINGER=0后关闭; - 应用未读取完接收数据就采用某些中止关闭路径。
收到 RST 后通常不会走完整四次挥手:
text
ESTABLISHED/其他状态
→ 记录 ECONNRESET 等错误
→ 唤醒 read/write/poll/epoll 等待者
→ TCP_CLOSE
Java 可能看到:
text
Connection reset
Broken pipe
Connection refused
RST 路径通常不会产生正常主动关闭路径的 TIME_WAIT,但具体行为仍取决于哪一端、哪一状态发送或收到 RST。
23.19 SO_LINGER 对 close 的影响
默认情况下,close() 通常把剩余关闭过程交给内核异步完成。
Java 可以设置:
java
socket.setSoLinger(true, seconds);
正超时时间可能让 close() 等待一段时间,以尝试发送缓冲数据和完成关闭。
特殊情况:
text
SO_LINGER 开启且 timeout=0
通常表示中止式关闭:
text
丢弃尚未发送的数据
→ 发送 RST
→ 跳过正常 FIN 四次挥手
它不应被当成普通性能优化,因为对端会观察到连接重置而不是正常 EOF。
23.20 FIN_WAIT2 和 TIME_WAIT 堆积怎么看
大量 FIN-WAIT-2:
text
本端已经主动关闭发送方向
对端已经确认
但是对端长期不发送自己的 FIN
对已经脱离应用 fd 管理的 orphan FIN_WAIT2,Linux 会使用 tcp_fin_timeout 等机制回收。仍由应用持有的半关闭连接可能有不同生命周期。
大量 TIME-WAIT:
text
本机频繁作为主动关闭方
连接创建/关闭频率很高
TIME_WAIT 本身是 TCP 正确性机制,不应一看到数量多就直接修改内核参数。首先应判断:
- 是否应该使用长连接或连接池;
- 谁应该承担主动关闭;
- 是否存在连接复用不足;
- 是否真的发生端口耗尽或资源问题。
23.21 用 ss 观察关闭状态
查看 8080 相关连接:
bash
ss -ant '( sport = :8080 or dport = :8080 )'
只看 TIME_WAIT:
bash
ss -ant state time-wait
只看 CLOSE_WAIT:
bash
ss -antp state close-wait
统计各 TCP 状态:
bash
ss -ant | awk 'NR > 1 { count[$1]++ } END { for (state in count) print state, count[state] }'
监听关闭过程时,状态可能非常短暂,建议连续采样:
bash
watch -n 0.2 "ss -ant '( sport = :8080 or dport = :8080 )'"
23.22 建连和关闭时的结构生命周期总结
text
服务端监听
完整 listener tcp_sock
TCP_LISTEN
│
│ 收到 SYN
▼
轻量 request_sock
SYN_RECV/NEW_SYN_RECV
│
│ 握手完成
▼
完整 child tcp_sock
TCP_ESTABLISHED
│
│ 主动或被动关闭
▼
完整 child tcp_sock
FIN_WAIT/CLOSE_WAIT/LAST_ACK/CLOSING
│
│ 主动完成关闭
▼
轻量 inet_timewait_sock
TCP_TIME_WAIT
│
│ 定时器到期
▼
对象释放
并不是每个连接都经过 request_sock → tcp_sock → timewait_sock 的全部阶段:
- 客户端主动连接通常直接创建完整
tcp_sock,不会使用服务端的request_sock路径; - 被动关闭方走到
LAST_ACK → CLOSE后通常不进入 TIME_WAIT; - RST 可能直接中止连接;
- 同时关闭可能让双方进入 CLOSING 和 TIME_WAIT。
24. 源码阅读索引
如果准备结合源码继续深入,可以按下面的顺序查找。不同版本目录和函数可能略有调整。
24.1 OpenJDK Java 层
text
src/java.base/share/classes/java/net/Socket.java
src/java.base/share/classes/java/net/ServerSocket.java
src/java.base/share/classes/java/net/SocketImpl.java
src/java.base/share/classes/java/nio/channels/SocketChannel.java
src/java.base/share/classes/java/nio/channels/ServerSocketChannel.java
src/java.base/share/classes/java/nio/channels/Selector.java
24.2 OpenJDK NIO 实现层
text
src/java.base/share/classes/sun/nio/ch/SocketChannelImpl.java
src/java.base/share/classes/sun/nio/ch/ServerSocketChannelImpl.java
src/java.base/share/classes/sun/nio/ch/SelectorImpl.java
src/java.base/linux/classes/sun/nio/ch/EPollSelectorImpl.java
src/java.base/linux/classes/sun/nio/ch/EPoll.java
src/java.base/share/classes/sun/nio/ch/Net.java
native 方法通常可以继续在 src/java.base/unix/native/libnio/ch/ 等目录中寻找。
24.3 Linux fd 和 socket 层
text
include/linux/fs.h struct file
include/linux/net.h struct socket、proto_ops
include/net/sock.h struct sock、socket_wq
net/socket.c socket 系统调用入口
24.4 Linux IPv4/TCP 层
text
include/net/inet_sock.h inet_sock、inet_connection_sock
include/linux/tcp.h tcp_sock
net/ipv4/af_inet.c inet bind/listen/connect/accept 分发
net/ipv4/inet_connection_sock.c 监听、Accept 队列、连接等待
net/ipv4/tcp.c TCP read/write 等通用逻辑
net/ipv4/tcp_input.c TCP 输入状态机
net/ipv4/tcp_ipv4.c IPv4 TCP 收包、连接建立
24.5 Linux epoll 层
text
fs/eventpoll.c
重点搜索:
text
eventpoll
epitem
epoll_create1
epoll_ctl
epoll_wait
ep_poll_callback
推荐阅读顺序:
text
Java ServerSocketChannel/Selector
→ OpenJDK EPollSelectorImpl
→ Linux epoll 系统调用
→ fs/eventpoll.c
→ socket poll 回调
→ struct sock 的 sk_wq
→ TCP 接收、连接建立和唤醒路径
25. 最终总结
BIO:
text
一个 socket 操作没有完成
→ 当前调用线程睡到该 socket 的等待机制上
NIO:
text
业务 fd 设置 O_NONBLOCK
→ accept/read/connect 不能完成时立即返回
epoll:
text
每个 socket 的等待队列注册 epoll callback
→ socket 就绪时把对应 epitem 放入 epoll 就绪链表
→ 一个事件循环线程集中阻塞在 epoll_wait()
→ epoll_wait 返回后,Java 再执行 accept/read/write
最核心的结构关系:
text
BIO
thread ──wait──> 某一个 socket.sk_wq
NIO + epoll
socket A.sk_wq ──callback──┐
socket B.sk_wq ──callback──┼──> eventpoll.rdllist
socket C.sk_wq ──callback──┘ │
▼
一个 epoll_wait 线程
无论使用 BIO、NIO 还是 epoll,TCP 连接最终仍由相同的 Linux 内核对象承载:
text
fd
→ struct file
→ struct socket
→ struct tcp_sock
服务端连接从建立到关闭还可能经历结构压缩:
text
request_sock(握手中)
→ tcp_sock(ESTABLISHED 和挥手状态)
→ inet_timewait_sock(TIME_WAIT)
→ 对象释放
变化的是线程等待方式和事件分发方式,不是 TCP 协议本身。