Epoll 空轮询 Bug 深度解析
一、问题描述
1.1 什么是 Epoll 空轮询 Bug?
这是 JDK NIO 在 Linux 平台上的一个严重 Bug,表现为:
Selector.select()方法应该阻塞等待事件,但却立即返回- 返回值为 0(没有任何事件就绪)
- 导致 while 循环不断空转,CPU 使用率飙升到 100%
- 系统完全不可用
官方 Bug 报告:
- Bug ID: 6403933
- Bug ID: 6670302
- 状态:至今未完全修复(JDK 17 仍存在)
1.2 为什么只在 Linux 下出现?
因为不同操作系统的 NIO 底层实现不同:
| 操作系统 | NIO 底层实现 | 是否有 Bug |
|---|---|---|
| Linux | epoll | ✓ 有 |
| Windows | IOCP | ✗ 无 |
| macOS | kqueue | ✗ 无 |
Linux 下 JDK 的 epoll 实现存在缺陷,导致了这个 Bug。
二、JDK NIO 的正常工作流程
2.1 正常的 NIO 服务器代码
// 正常的 NIO 服务器
public class NioServer {
public static void main(String[] args) throws IOException {
// 1. 创建 Selector
Selector selector = Selector.open();
// 2. 创建 ServerSocketChannel
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(8080));
// 3. 注册到 Selector
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器启动,等待连接...");
// 4. 事件循环
while (true) {
// 阻塞等待事件,正常情况下会一直阻塞
// 直到有事件发生或者超时
int readyChannels = selector.select(); // 关键点!
if (readyChannels == 0) {
// 正常情况下,只有超时或被 wakeup() 才会返回 0
continue;
}
// 处理事件
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isAcceptable()) {
// 处理连接事件
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel client = server.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
System.out.println("接受新连接: " + client.getRemoteAddress());
}
else if (key.isReadable()) {
// 处理读事件
SocketChannel client = (SocketChannel) key.channel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
int len = client.read(buffer);
if (len > 0) {
System.out.println("读取数据: " + new String(buffer.array(), 0, len));
}
}
}
}
}
}
2.2 正常情况下的执行流程
1. selector.select() 被调用
↓
2. 线程阻塞,等待事件
↓
3. 有事件发生(例如客户端连接)
↓
4. selector.select() 返回,返回值 > 0
↓
5. 处理事件
↓
6. 回到步骤 1
关键点:selector.select() 应该阻塞,直到有事件发生才返回。
三、Epoll Bug 的异常表现
3.1 Bug 触发时的代码执行
while (true) {
// Bug 触发:select() 立即返回,返回值为 0
int readyChannels = selector.select(); // 立即返回 0!
if (readyChannels == 0) {
System.err.println("空轮询!没有事件但 select() 返回了");
// 继续循环,又立即返回 0
// 无限循环,CPU 100%
continue;
}
// 这段代码永远不会执行,因为 readyChannels 一直是 0
Set<SelectionKey> selectedKeys = selector.selectedKeys();
// ...
}
3.2 Bug 触发时的执行流程
1. selector.select() 被调用
↓
2. 应该阻塞,但立即返回(Bug!)
↓
3. 返回值 = 0(没有任何事件)
↓
4. selectedKeys 为空,不处理任何事件
↓
5. 回到步骤 1(立即执行,不阻塞)
↓
6. 无限循环,CPU 100%
问题:selector.select() 没有阻塞,立即返回 0,导致 while 循环空转。
3.3 实际日志示例
服务器启动,等待连接...
空轮询!没有事件但 select() 返回了
空轮询!没有事件但 select() 返回了
空轮询!没有事件但 select() 返回了
空轮询!没有事件但 select() 返回了
... (无限重复,CPU 100%)
四、Bug 的根本原因
4.1 Linux Epoll 机制简介
在 Linux 下,JDK NIO 的 Selector 底层使用 epoll 系统调用实现。
Epoll 的三个核心函数:
// 1. 创建 epoll 实例
int epfd = epoll_create(int size);
// 2. 注册/修改/删除文件描述符
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 3. 等待事件(对应 Selector.select())
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
JDK NIO 与 Epoll 的对应关系:
Selector.open() → epoll_create()
channel.register() → epoll_ctl(EPOLL_CTL_ADD)
selector.select() → epoll_wait()
4.2 Bug 的触发条件
根据 JDK Bug 报告和社区分析,Bug 主要在以下情况触发:
情况 1:连接被远程关闭
1. 客户端连接到服务器
2. 服务器将 SocketChannel 注册到 Selector,监听 OP_READ
3. 客户端异常断开(发送 RST 或 FIN)
4. Linux 内核的 epoll 错误地认为该 fd 仍然有事件
5. epoll_wait() 立即返回,但实际上没有可读数据
6. JDK 的 Selector.select() 也立即返回 0
7. 无限循环
情况 2:文件描述符状态异常
1. 某个 Channel 的文件描述符进入异常状态
2. epoll 无法正确处理这个异常状态
3. epoll_wait() 认为有事件,但实际上没有
4. 导致空轮询
4.3 JDK 源码分析
JDK 的 EPollSelectorImpl.java(简化版):
// sun.nio.ch.EPollSelectorImpl
protected int doSelect(long timeout) throws IOException {
if (closed)
throw new ClosedSelectorException();
processDeregisterQueue();
try {
begin();
// 调用 native 方法,最终调用 epoll_wait()
pollWrapper.poll(timeout);
} finally {
end();
}
processDeregisterQueue();
// 更新 selectedKeys
int numKeysUpdated = updateSelectedKeys();
// 如果 pollWrapper.interrupted() 为 true,返回 0
if (pollWrapper.interrupted()) {
pollWrapper.putEventOps(pollWrapper.interruptedIndex(), 0);
synchronized (interruptLock) {
pollWrapper.clearInterrupted();
IOUtil.drain(fd0);
interruptTriggered = false;
}
return 0;
}
return numKeysUpdated;
}
Native 层的 epoll_wait 调用:
// EPollArrayWrapper.c
JNIEXPORT jint JNICALL
Java_sun_nio_ch_EPollArrayWrapper_epollWait(JNIEnv *env, jobject this,
jlong address, jint numfds,
jlong timeout)
{
struct epoll_event *events = jlong_to_ptr(address);
int res;
// 调用 Linux 的 epoll_wait 系统调用
if (timeout <= 0) {
res = epoll_wait(epfd, events, numfds, timeout);
} else {
res = epoll_wait(epfd, events, numfds, timeout);
}
if (res < 0) {
if (errno == EINTR) {
return IOS_INTERRUPTED;
} else {
JNU_ThrowIOExceptionWithLastError(env, "epoll_wait failed");
return IOS_THROWN;
}
}
return res; // 返回就绪的文件描述符数量
}
问题所在:
当 Linux 内核的 epoll 实现出现 Bug 时,epoll_wait() 会立即返回,但返回值可能是 0 或者返回的事件实际上无效。JDK 无法判断这是正常的超时返回还是 Bug 导致的异常返回,所以就出现了空轮询。
4.4 为什么 JDK 不修复?
- 这是 Linux 内核的 Bug:问题根源在 Linux 内核的 epoll 实现,不是 JDK 的问题
- 难以复现:Bug 触发条件复杂,不是每次都能复现
- 影响范围有限:只在特定的 Linux 内核版本和特定场景下触发
- 修复成本高:需要在 JDK 层面做大量的兼容性处理
五、Netty 的解决方案
Netty 采用了一种巧妙的方式来规避这个 Bug:检测空轮询次数,超过阈值就重建 Selector。
5.1 核心思路
1. 记录每次 select() 的返回情况
2. 如果连续多次空轮询(返回 0 且没有超时),计数器 +1
3. 如果计数器超过阈值(默认 512),认为触发了 Bug
4. 重建 Selector,将所有 Channel 重新注册
5. 关闭旧的 Selector
5.2 Netty 源码分析
NioEventLoop.java 的 select() 方法:
// io.netty.channel.nio.NioEventLoop
private void select(boolean oldWakenUp) throws IOException {
Selector selector = this.selector;
try {
int selectCnt = 0; // 空轮询计数器
long currentTimeNanos = System.nanoTime();
// 计算超时时间
long selectDeadLineNanos = currentTimeNanos + delayNanos(currentTimeNanos);
for (;;) {
// 计算本次 select 的超时时间
long timeoutMillis = (selectDeadLineNanos - currentTimeNanos + 500000L) / 1000000L;
if (timeoutMillis <= 0) {
// 已经超时,执行一次非阻塞的 selectNow()
if (selectCnt == 0) {
selector.selectNow();
selectCnt = 1;
}
break;
}
// 如果有任务要执行,也执行非阻塞的 selectNow()
if (hasTasks() && wakenUp.compareAndSet(false, true)) {
selector.selectNow();
selectCnt = 1;
break;
}
// 执行阻塞的 select(),这里可能触发 Bug
int selectedKeys = selector.select(timeoutMillis);
selectCnt++; // 每次 select 后计数器 +1
// 判断是否应该跳出循环
if (selectedKeys != 0 || // 有事件
oldWakenUp || // 被 wakeup
wakenUp.get() || // 被 wakeup
hasTasks() || // 有任务
hasScheduledTasks()) { // 有定时任务
// 正常情况,跳出循环
break;
}
// 如果线程被中断,跳出循环
if (Thread.interrupted()) {
if (logger.isDebugEnabled()) {
logger.debug("Selector.select() returned prematurely because " +
"Thread.currentThread().interrupt() was called. Use " +
"NioEventLoop.shutdownGracefully() to shutdown the NioEventLoop.");
}
selectCnt = 1;
break;
}
// 关键:检查是否触发了空轮询 Bug
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
// 说明确实超时了,这是正常的
// 重置计数器
selectCnt = 1;
} else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
// 空轮询次数超过阈值(默认 512)
// 认为触发了 epoll bug,重建 Selector
logger.warn(
"Selector.select() returned prematurely {} times in a row; rebuilding Selector {}.",
selectCnt, selector);
rebuildSelector(); // 重建 Selector
selector = this.selector;
// 重建后,重置计数器
selectCnt = 1;
break;
}
currentTimeNanos = time;
}
if (selectCnt > MIN_PREMATURE_SELECTOR_RETURNS) {
if (logger.isDebugEnabled()) {
logger.debug("Selector.select() returned prematurely {} times in a row for Selector {}.",
selectCnt - 1, selector);
}
}
} catch (CancelledKeyException e) {
if (logger.isDebugEnabled()) {
logger.debug(CancelledKeyException.class.getSimpleName() + " raised by a Selector {} - JDK bug?",
selector, e);
}
}
}
5.3 关键判断逻辑详解
判断是否是真正的超时:
long time = System.nanoTime();
if (time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos) {
// 说明确实超时了,这是正常的
selectCnt = 1;
}
逻辑解释:
假设:
- currentTimeNanos = 1000(select 开始时间)
- timeoutMillis = 100(超时时间 100ms)
- time = 1150(select 返回时间)
判断:
time - TimeUnit.MILLISECONDS.toNanos(timeoutMillis) >= currentTimeNanos
1150 - 100 >= 1000
1050 >= 1000 ✓
说明:确实等待了 100ms,这是正常的超时返回
判断是否触发了 Bug:
else if (SELECTOR_AUTO_REBUILD_THRESHOLD > 0 &&
selectCnt >= SELECTOR_AUTO_REBUILD_THRESHOLD) {
// 触发了 Bug,重建 Selector
rebuildSelector();
}
逻辑解释:
如果:
- 不是正常超时(上面的 if 不成立)
- 空轮询次数 >= 512
说明:
- select() 立即返回(没有等待超时时间)
- 连续发生了 512 次
- 很可能触发了 epoll bug
解决:
- 重建 Selector
5.4 重建 Selector 的详细过程
rebuildSelector() 方法:
// io.netty.channel.nio.NioEventLoop
public void rebuildSelector() {
if (!inEventLoop()) {
// 如果不在 EventLoop 线程中,提交任务
execute(new Runnable() {
@Override
public void run() {
rebuildSelector0();
}
});
return;
}
rebuildSelector0();
}
private void rebuildSelector0() {
final Selector oldSelector = selector;
final SelectorTuple newSelectorTuple;
if (oldSelector == null) {
return;
}
try {
// 1. 创建新的 Selector
newSelectorTuple = openSelector();
} catch (Exception e) {
logger.warn("Failed to create a new Selector.", e);
return;
}
// 2. 将所有 Channel 从旧 Selector 迁移到新 Selector
int nChannels = 0;
for (SelectionKey key: oldSelector.keys()) {
Object a = key.attachment();
try {
// 检查 key 是否有效
if (!key.isValid() || key.channel().keyFor(newSelectorTuple.unwrappedSelector) != null) {
continue;
}
// 获取感兴趣的事件
int interestOps = key.interestOps();
// 取消旧的注册
key.cancel();
// 注册到新的 Selector
SelectionKey newKey = key.channel().register(
newSelectorTuple.unwrappedSelector,
interestOps,
a
);
// 更新 Channel 中的 SelectionKey 引用
if (a instanceof AbstractNioChannel) {
((AbstractNioChannel) a).selectionKey = newKey;
}
nChannels++;
} catch (Exception e) {
logger.warn("Failed to re-register a Channel to the new Selector.", e);
if (a instanceof AbstractNioChannel) {
AbstractNioChannel ch = (AbstractNioChannel) a;
ch.unsafe().close(ch.unsafe().voidPromise());
} else {
@SuppressWarnings("unchecked")
NioTask<SelectableChannel> task = (NioTask<SelectableChannel>) a;
invokeChannelUnregistered(task, key, e);
}
}
}
// 3. 更新 Selector 引用
selector = newSelectorTuple.selector;
unwrappedSelector = newSelectorTuple.unwrappedSelector;
try {
// 4. 关闭旧的 Selector
oldSelector.close();
} catch (Throwable t) {
if (logger.isWarnEnabled()) {
logger.warn("Failed to close the old Selector.", t);
}
}
if (logger.isInfoEnabled()) {
logger.info("Migrated " + nChannels + " channel(s) to the new Selector.");
}
}
5.5 重建流程图解
触发 Bug 前:
┌─────────────────────────────────────┐
│ 旧 Selector │
│ ┌──────────┐ ┌──────────┐ │
│ │Channel 1 │ │Channel 2 │ ... │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────┘
↓ 触发 Bug(空轮询 512 次)
重建过程:
1. 创建新 Selector
┌─────────────────────────────────────┐
│ 新 Selector │
│ (空的) │
└─────────────────────────────────────┘
2. 迁移 Channel
┌─────────────────────────────────────┐
│ 旧 Selector │
│ ┌──────────┐ ┌──────────┐ │
│ │Channel 1 │ │Channel 2 │ │
│ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼───────────────┘
│ │
│ 取消注册 │ 取消注册
↓ ↓
┌─────────────────────────────────────┐
│ 新 Selector │
│ ┌──────────┐ ┌──────────┐ │
│ │Channel 1 │ │Channel 2 │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────┘
↑ 重新注册
3. 关闭旧 Selector
┌─────────────────────────────────────┐
│ 旧 Selector │
│ (已关闭) │
└─────────────────────────────────────┘
4. 使用新 Selector
┌─────────────────────────────────────┐
│ 新 Selector │
│ ┌──────────┐ ┌──────────┐ │
│ │Channel 1 │ │Channel 2 │ ... │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────┘
↓ 继续正常工作
六、为什么 Netty 的方案有效?
6.1 核心原理
Bug 的本质:旧的 Selector 内部状态异常,导致 epoll_wait() 行为异常。
Netty 的解决:创建一个全新的 Selector,新的 Selector 状态正常,不会触发 Bug。
6.2 为什么选择 512 这个阈值?
// io.netty.channel.nio.NioEventLoop
private static final int SELECTOR_AUTO_REBUILD_THRESHOLD =
SystemPropertyUtil.getInt("io.netty.selectorAutoRebuildThreshold", 512);
原因:
- 避免误判:偶尔的空轮询可能是正常的(例如被 wakeup),512 次足够排除误判
- 及时响应:512 次空轮询在现代 CPU 上只需要几毫秒,不会让 CPU 100% 持续太久
- 经验值:Netty 团队经过大量测试得出的经验值
可配置:可以通过系统属性修改:
// 启动时设置
System.setProperty("io.netty.selectorAutoRebuildThreshold", "256");
// 或者在启动参数中设置
java -Dio.netty.selectorAutoRebuildThreshold=256 YourApp
6.3 性能影响
重建 Selector 的成本:
1. 创建新 Selector:约 1ms
2. 迁移 Channel:每个 Channel 约 0.01ms
3. 关闭旧 Selector:约 0.5ms
总计:假设有 1000 个 Channel,约 1 + 10 + 0.5 = 11.5ms
对比 CPU 100% 的影响:
不重建:CPU 100%,系统完全不可用
重建: 短暂的 11.5ms 延迟,之后恢复正常
显然,重建的代价是完全可以接受的。
七、实战演示
7.1 模拟 Bug 触发(理论代码)
// 注意:这只是理论代码,实际很难稳定复现
public class EpollBugDemo {
public static void main(String[] args) throws Exception {
Selector selector = Selector.open();
ServerSocketChannel serverChannel = ServerSocketChannel.open();
serverChannel.configureBlocking(false);
serverChannel.socket().bind(new InetSocketAddress(8080));
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
int emptySelectCount = 0;
long startTime = System.currentTimeMillis();
while (true) {
int ready = selector.select(1000); // 1 秒超时
if (ready == 0) {
emptySelectCount++;
long elapsed = System.currentTimeMillis() - startTime;
if (elapsed < 100) { // 100ms 内返回 0,可能是 Bug
System.err.println("可能触发 Bug!空轮询次数: " + emptySelectCount);
if (emptySelectCount > 10) {
System.err.println("确认触发 Bug!CPU 即将 100%");
break;
}
} else {
// 正常超时
emptySelectCount = 0;
}
startTime = System.currentTimeMillis();
continue;
}
// 处理事件
emptySelectCount = 0;
startTime = System.currentTimeMillis();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
// 处理事件...
}
}
}
}
7.2 Netty 的日志输出
当 Netty 检测到 Bug 并重建 Selector 时,会输出日志:
WARN io.netty.channel.nio.NioEventLoop - Selector.select() returned prematurely 512 times in a row; rebuilding Selector io.netty.channel.nio.SelectedSelectionKeySetSelector@6edf74c3.
INFO io.netty.channel.nio.NioEventLoop - Migrated 15 channel(s) to the new Selector.
日志解读:
returned prematurely 512 times:连续 512 次空轮询rebuilding Selector:正在重建 SelectorMigrated 15 channel(s):成功迁移了 15 个 Channel
八、总结
8.1 Bug 的本质
| 方面 | 说明 |
|---|---|
| 根本原因 | Linux 内核 epoll 实现的 Bug |
| 表现 | selector.select() 立即返回 0,不阻塞 |
| 后果 | while 循环空转,CPU 100% |
| 影响范围 | 只在 Linux 平台,特定场景下触发 |
| JDK 状态 | 至今未修复(因为是内核 Bug) |
8.2 Netty 的解决方案
| 方面 | 说明 |
|---|---|
| 核心思路 | 检测空轮询次数,超过阈值重建 Selector |
| 阈值 | 默认 512 次(可配置) |
| 重建过程 | 创建新 Selector → 迁移 Channel → 关闭旧 Selector |
| 性能影响 | 约 10ms(取决于 Channel 数量) |
| 效果 | 完全规避 Bug,系统恢复正常 |
8.3 关键要点
- 这是 Linux 内核的 Bug,不是 JDK 或 Netty 的问题
- JDK 无法修复,因为问题在内核层面
- Netty 巧妙规避,通过重建 Selector 解决
- 代价很小,重建只需要几毫秒
- 效果显著,完全避免 CPU 100% 的问题
8.4 学习建议
- 理解 epoll 的工作原理
- 理解 Selector.select() 的阻塞机制
- 理解 Netty 的检测和重建逻辑
- 在生产环境中关注 Netty 的日志,如果频繁出现重建日志,说明系统可能有问题
参考资料
- JDK Bug 报告:
- https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6403933
- https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6670302
- Netty Issue:
- https://github.com/netty/netty/issues/327
- https://github.com/netty/netty/issues/8698
- Linux epoll 文档:
- man epoll
- https://man7.org/linux/man-pages/man7/epoll.7.html
6954



被折叠的 条评论
为什么被折叠?



