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 上)会:
- 调用
epoll_create创建一个 epoll 文件描述符 - 初始化一对 pipe(管道),用于
wakeup机制
当你调用 channel.register(selector, ops) 时,JVM 会:
- 调用
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event)把 Channel 对应的 fd 注册到 epoll 中 - 创建一个
SelectionKeyImpl对象来保存注册信息
当你调用 selector.select() 时,JVM 会:
- 调用
epoll_wait(epfd, events, maxevents, timeout)阻塞等待 - 返回后,把就绪的 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 构建高性能的设备接入层。