物联网接入层技术剖析(三):epoll在JVM中的映射

Netty与高性能网络服务、Linux高并发网络编程实战、从epoll到Netty:物联网接入层技术剖析、深入理解I/O多路复用、服务端网络编程进阶指南

Java NIO Selector:epoll在JVM中的映射

0 写在前面

前两篇文章我们把 epoll 从概念到内核源码捋了一遍。但说实话,大部分做 Java 后端的人,日常工作中并不会直接写 C 代码调 epoll 的 API。我们打交道更多的是 Java NIO 里的 Selector、Channel、ByteBuffer 这些东西。

那么问题来了:Java NIO 的 Selector 和 Linux 的 epoll 到底是什么关系?JVM 是怎么把 Selector 映射到 epoll 上的?中间有没有什么坑?

这篇文章就来回答这些问题。我会先讲 Selector 的使用方法,然后揭开它和 epoll 之间的映射关系,最后结合物联网平台的场景聊一些实战经验。

1 Selector是什么

Selector 是 Java NIO 提供的多路复用器。用一句话概括它的作用:让一个线程同时监控多个 Channel 上的 I/O 事件

如果你读过前两篇文章,这句话应该很眼熟------这不就是 epoll 干的事吗?没错,在 Linux 上,Selector 的底层实现就是 epoll。JVM 做了一层抽象,让你不用关心底层是 epoll、kqueue(macOS/BSD)还是其他什么机制。

2 基本用法

Selector 的 API 设计得很简洁,核心就几步:

第一步,创建 Selector。

java 复制代码
Selector selector = Selector.open();

这一行代码背后,JVM 会调用 epoll_create 创建一个 epoll 实例(在 Linux 上)。

第二步,把 Channel 注册到 Selector 上。

java 复制代码
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);

这里有两个要点。首先,注册之前必须把 Channel 设为非阻塞模式。这也是为什么 FileChannel 不能用 Selector------因为它不支持非阻塞模式。其次,注册时需要指定你关心的事件类型。

Java NIO 定义了四种事件:

  • SelectionKey.OP_CONNECT:客户端连接成功
  • SelectionKey.OP_ACCEPT:服务端接受新连接
  • SelectionKey.OP_READ:数据可读
  • SelectionKey.OP_WRITE:数据可写

如果你同时关心多个事件,可以用位运算组合:SelectionKey.OP_READ | SelectionKey.OP_WRITE

第三步,循环 select。

java 复制代码
while (true) {
    int readyCount = selector.select();
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> iter = selectedKeys.iterator();
    while (iter.hasNext()) {
        SelectionKey key = iter.next();
        if (key.isAcceptable()) {
            // 处理新连接
        }
        if (key.isReadable()) {
            // 读取数据
        }
        iter.remove();
    }
}

selector.select() 会阻塞,直到至少有一个 Channel 就绪。返回值是就绪的 Channel 数量。然后你遍历就绪的 SelectionKey,根据事件类型做相应处理。

注意 iter.remove() 这一行。处理完一个 key 之后必须手动移除,否则下次 select 还会返回它。这是 Java NIO 的一个经典坑,很多人第一次用的时候都会忘记。

3 SelectionKey:连接的身份证

每次注册 Channel 时返回的 SelectionKey 对象,可以理解为这个 Channel 在 Selector 里的"身份证"。它携带了几个重要信息:

兴趣集合(Interest Set):你告诉 Selector 你关心哪些事件。

java 复制代码
int interestSet = key.interestOps();
boolean isInterestedInRead = (interestSet & SelectionKey.OP_READ) != 0;

就绪集合(Ready Set):Channel 当前哪些事件已经就绪。

java 复制代码
int readySet = key.readyOps();
// 或者用更方便的方法
key.isReadable();
key.isWritable();
key.isAcceptable();
key.isConnectable();

关联对象(Attachment):你可以往 SelectionKey 上绑一个任意对象,方便在事件触发时获取上下文信息。

java 复制代码
key.attach(myObject);
Object obj = key.attachment();

这个 attach 机制在实战中非常好用。比如在物联网平台中,你可以在设备连接建立时把设备的 Session 信息 attach 到 SelectionKey 上,等数据到达时直接取出来用,不用再查一遍 Map。

4 Selector和epoll的映射关系

现在来看最有趣的部分:Java 的 Selector 是怎么映射到 epoll 的。

当你调用 Selector.open() 时,JVM 内部(具体是 sun.nio.ch.EPollSelectorImpl 这个类,在 Linux 上)会:

  1. 调用 epoll_create 创建一个 epoll 文件描述符
  2. 初始化一对 pipe(管道),用于 wakeup 机制

当你调用 channel.register(selector, ops) 时,JVM 会:

  1. 调用 epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event) 把 Channel 对应的 fd 注册到 epoll 中
  2. 创建一个 SelectionKeyImpl 对象来保存注册信息

当你调用 selector.select() 时,JVM 会:

  1. 调用 epoll_wait(epfd, events, maxevents, timeout) 阻塞等待
  2. 返回后,把就绪的 fd 转换成 SelectionKey 对象放入 selectedKeys 集合

当你调用 selector.wakeup() 时,JVM 会向之前创建的 pipe 中写入一个字节,这会触发 pipe 的读端就绪,从而唤醒 epoll_wait

所以整个映射关系就是:

Java NIO Linux epoll
Selector.open() epoll_create()
channel.register() epoll_ctl(EPOLL_CTL_ADD)
selector.select() epoll_wait()
selector.wakeup() 向 pipe 写数据唤醒 epoll_wait
SelectionKey epitem + epoll_event

知道了这个映射关系,很多 Java NIO 的行为就很好理解了。比如为什么 Selector 只能用于 SelectableChannel(因为只有 socket 这类 fd 才能注册到 epoll),为什么 FileChannel 不行(文件 fd 不支持非阻塞 I/O,也就不能被 epoll 监控)。

5 一个完整的Echo Server示例

光看 API 说明太抽象了,来看一个完整的服务端示例。这个例子实现了一个简单的 Echo Server------客户端发什么,服务端就回什么:

java 复制代码
public class EchoServer {
    public static void main(String[] args) throws IOException {
        Selector selector = Selector.open();
        ServerSocketChannel serverChannel = ServerSocketChannel.open();
        serverChannel.bind(new InetSocketAddress(8080));
        serverChannel.configureBlocking(false);
        serverChannel.register(selector, SelectionKey.OP_ACCEPT);

        ByteBuffer buffer = ByteBuffer.allocate(1024);

        while (true) {
            selector.select();
            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> iter = selectedKeys.iterator();

            while (iter.hasNext()) {
                SelectionKey key = iter.next();
                iter.remove();

                if (key.isAcceptable()) {
                    // 接受新连接
                    SocketChannel client = serverChannel.accept();
                    client.configureBlocking(false);
                    client.register(selector, SelectionKey.OP_READ);
                    System.out.println("新连接: " + client.getRemoteAddress());
                }

                if (key.isReadable()) {
                    // 读取数据并回写
                    SocketChannel client = (SocketChannel) key.channel();
                    buffer.clear();
                    int bytesRead = client.read(buffer);
                    if (bytesRead == -1) {
                        client.close();
                        System.out.println("连接断开");
                        continue;
                    }
                    buffer.flip();
                    client.write(buffer);
                }
            }
        }
    }
}

这个例子虽然简单,但已经包含了 Selector 编程的核心模式:一个无限循环,每次 select 拿到就绪的 key,根据事件类型分发处理。真正的物联网协议解析器(比如 MQTT Broker)也是这个框架,只不过在 read 的分支里加上了协议解码逻辑。

6 ByteBuffer:绕不开的缓冲区

Java NIO 使用 ByteBuffer 来进行数据读写,这和传统 IO 的 Stream 模型有很大区别。

ByteBuffer 有几个关键概念需要理解:

capacity:缓冲区的总容量,创建时指定,不可改变。

position:当前读写位置。读模式下表示读到的位置,写模式下表示写入的位置。

limit:读模式下表示可读数据的上界,写模式下等于 capacity。

flip():切换到读模式。把 limit 设为当前 position,position 设为 0。这是从缓冲区读数据之前必须调用的方法。

java 复制代码
buffer.put("hello".getBytes()); // 写入数据
buffer.flip();                   // 切换到读模式
byte[] data = new byte[buffer.remaining()];
buffer.get(data);                // 读出数据
buffer.clear();                  // 清空,准备下次写入

在物联网平台中,设备上报的数据通常是二进制的协议帧(比如 MQTT 的固定头 + 可变头 + 负载),你需要从 ByteBuffer 中逐字节解析。这时候 ByteBuffer 的 get()getShort()getInt() 等方法就派上用场了。不过说实话,Java NIO 的 ByteBuffer 用起来确实不太方便,这也是为什么后来出现了 Netty 的 ByteBuf------功能更强大,API 也更友好。

7 实战中的几个坑

在物联网平台中使用 Java NIO Selector,有几个坑是我踩过的,分享出来供参考。

第一个坑:忘记 remove SelectionKey。 前面提到过,处理完 selectedKeys 中的 key 之后必须调用 iter.remove()。忘了的话,下次 select 还会返回已经处理过的 key,导致重复处理。这个问题在开发阶段不容易发现,因为很多时候重复处理一次也不会立刻报错,但会导致一些诡异的 bug。

第二个坑:write 不是总能写完。 很多人以为 channel.write(buffer) 会把 buffer 里的数据全部写出去,其实不是。write 返回的是实际写入的字节数,可能小于 buffer 的剩余量(比如发送缓冲区满了)。你需要循环调用 write,或者注册 OP_WRITE 事件,等缓冲区有空间了再继续写。

第三个坑:select 返回 0 不代表没有事件。 select() 返回 0 通常是因为超时了,没有就绪的事件。但在某些 JDK 版本中,由于实现上的 bug,偶尔会出现 select 返回 0 但实际上有事件就绪的情况。所以不要用返回值 0 来做严格的逻辑判断。

第四个坑:CPU 100%。 如果你忘了调用 select()(或者用了 selectNow() 但没有 sleep),循环会变成纯 CPU 空转,瞬间把一个核打满。这个问题在压力测试时特别容易暴露。

8 为什么我们最终选择了Netty

写到这里你可能会想:Java NIO Selector 虽然功能强大,但用起来确实不太友好,坑也不少。这也是为什么在实际项目中,我们几乎不会直接使用原生 Selector,而是选择 Netty 这样的框架。

Netty 在 Selector 之上做了大量的封装和优化:

  • 把事件循环抽象成 EventLoop,自动处理线程模型
  • 用 ByteBuf 替代 ByteBuffer,API 更好用,还支持池化、引用计数
  • 内置了各种编解码器,包括 MQTT 协议的支持
  • 处理了各种 JDK 的 bug 和平台差异
  • 提供了优雅的空闲检测、流量整形等高级功能

下一篇文章,我会详细聊聊 Netty 是如何封装 epoll 的,以及在物联网平台中如何基于 Netty 构建高性能的设备接入层。

9 参考资料

相关推荐
小二·43 分钟前
LangGraph 多智能体实战:从零搭建 Multi-Agent 协作系统
java·开发语言·数据库
Rocktech_ruixun43 分钟前
从场景落地到技术迭代:服务机器人迈入规模化商用爆发期
大数据·人工智能
互联圈运营观察1 小时前
布局先行、技术深耕:国内端侧AI企业抢滩机器人与具身智能赛道
人工智能·microsoft·机器人
97zz1 小时前
Claude+deepseek-v4pro+cc switch+VSCode AI编程配置教程(Java开发专属)
java·vscode·ai编程
菜菜小狗的学习笔记1 小时前
八股(九)杂七杂八
java·后端·spring
逍遥德1 小时前
Java编程高频的“技术点”-01:自定义全局异常处理器
java·开发语言·spring boot·后端
Want5951 小时前
Rokid AI眼镜实战:打造上海垃圾分类智能助手
人工智能
threelab1 小时前
Three.js 3D 地图可视化 | 三维可视化 / AI 提示词
前端·javascript·人工智能·3d·着色器
Soari1 小时前
oh-my-pi:一款面向终端的 AI Coding Agent,集成 LSP、Debugger、Subagents 与多模型路由
人工智能·里氏替换原则