【NIO】Selector 详解
前言:
在本人之前的博文中,本人讲解了 NIO
的 三大核心知识点 之二 的 Channel
和 Buffer
相较于 BIO
,Channel 和 Buffer,主要是体现在 数据传输的方面
但是,NIO
的 非阻塞 特性,还有一个非常总要得到 组成部分 —— Selector
那么,时隔一年多,在掌握了很多的知识之后,本篇博文中,本人将继续讲解 NIO
的核心知识点 —— Selector
:
在讲解其原理之前,本人先来讲解下 Selector
这个组件的 作用:
作用:
Selector
可以检测 多个注册的通道,是否 有事件发生
如果 有事件发生,就 获取事件,然后针对每一个事件,进行相应的处理
这样,就可以只用 单线程、非阻塞式 地去 管理多个通道 的 连接和请求
总结下上面的描述,可以用一个词来概括 —— 多路复用
如下图所示:
那么,看了上述的 作用 描述,我们也能总结出来,Selector
概念的提出,使得 NIO
具备了如下 优点:
优点:
- 单线程 监听事件,避免了 多线程 上下文切换导致 的 开销
- 只有在 有事件发生 时,才会进行 读写,
不用为了 每个连接 都创建 一个线程 去 处理读写
大大 减少了系统开销
其实,总结来总结去,就是 多路复用
的优点
那么,讲了 Selector
的作用,并在 分析了其优点 之后,本人来讲解下 API 的使用:
API 使用:
常用API:
方法 | 描述 |
---|---|
static Selector open() | 获取 一个 Selector对象 |
Set |
返回 此选择器 所注册 的 键集 |
Set |
返回 此选择器 所注册的、并且有事件发生 的 键集 |
boolean isOpen() | 判断 此选择器 是否已打开 |
那么,现在本人就来通过上述API,以及之前几篇博文的内容,来编写一个 NIO模式 的 Server端:
使用示例:
package edu.youzg.demo;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Iterator;
import java.util.Set;
/**
* @Author: Youzg
* @CreateTime: 2021-04-30 17:45
* @Description: 带你深究Java的本质!
*/
public class SelectorDemo {
public static void main(String[] args) throws IOException {
// 创建 serverSocket
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 将 serverSocket 设置为 非阻塞
serverSocket.configureBlocking(false);
// 创建 selector
Selector selector = Selector.open();
// 为 selector 注册 监听serverSocket的“连接事件”
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器启动成功!开始监听客户端连接...");
while (true) {
// 阻塞监听 注册的事件发生
selector.select();
// 获取 注册过的、并且发生的事件 的键集
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectedKeys.iterator();
while (iterator.hasNext()) {
SelectionKey selectedKey = iterator.next();
if (selectedKey.isAcceptable()) { // 处理 “连接事件”
// 获取 触发“连接事件”的 serverSocketChannel
// (触发“连接事件”,一定是 ServerSocketChannel)
ServerSocketChannel server = (ServerSocketChannel) selectedKey.channel();
// 接收当前客户端的连接,并创建一个 socketChannel对象 与其通信
SocketChannel socketChannel = server.accept();
// 将 socketChannel 设置为 非阻塞
socketChannel.configureBlocking(false);
// 为 selector 注册 监听当前连接的socketChannel的“read事件”
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("客户端[" + socketChannel.getRemoteAddress() + "]连接成功!");
} else if (selectedKey.isReadable()) { // 处理 “read事件”
// 获取 触发“read事件”的 socketChannel
// (触发“read事件”,一定是 socketChannel)
SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
// 创建一个用于接收 客户端消息的 buffer
ByteBuffer buffer = ByteBuffer.allocate(128);
int msgLen = socketChannel.read(buffer);
if (msgLen > 0) { // 如果 客户端消息有数据,则打印出来
System.out.println("客户端[" + socketChannel.getRemoteAddress() + "]消息:" + new String(buffer.array()));
} else if (msgLen == -1) { // 如果 客户端请求 “断开连接”,则关闭当前socketChannel
System.out.println("客户端[" + socketChannel.getRemoteAddress() + "]断开连接");
socketChannel.close();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
那么,我们来运行下上述代码,来看看基本功能是否编写成功:
运行展示:
首先,我们在 cmd 中,通过 telnet 的方式,连接上文所写的服务器:
telnet 127.0.0.1 9000
可以看到:
客户端连接事件,被监听并处理了!
接着,我们切换到 telnet命令界面:
Ctrl + ]
接着,我们来发送几条消息:
send Hello,Youzg.
send Youzg is a good man!
可以看到:
客户端消息,处理成功了!
最后,我们来测试下 断开连接事件,是否也会被 监听并处理:
quit
可以看到:
客户端 断开连接事件,被监听并处理了!
但是,上文所讲内容,是非常基础的
如果本文在一年前进行发布,上文可以作为全部内容
但是,在历经了一年后,阅读了很多源码,了解了更多的底层知识,甚至接手了一些项目之后,我们就要来了解下其 多路复用
特性,是如何实现的:
核心源码:
在上文中,我们也能发现:
我们使用
Selector
,主要是调用了其如下几个方法:
Selector.open() //创建多路复用器
socketChannel.register(selector, SelectionKey.OP_READ) //将channel和对应事件 注册到多路复用器上
selector.select() // 阻塞等待 注册的事件发生
那么,本人就来讲解下上述三个方法的底层源码实现:
Selector.open:
分析流程:
我们跟进去 provider()方法,看看是如何执行的:
那么,我们继续跟进去,看看是如何 获取selector提供器 的:
我们可以看到:
创建了一个 Windows系统的selector提供器
那么,有 Windows版本的提供器,就一定有 Linux版本的提供器
但是,如果我们在IDEA这种Java编译器中跟踪源码,只能跟踪到上图那些 Windows系统的JDK源码
(因为在我们下载JDK时,就根据 系统版本,选择了 不同的JDK)
那么,本人就用其它编译器,打开下载好的 OpenJDK源码,看看 Linux版本 下,是如何获取 selector提供器 的:
我们可以看到:
如果是 Linux系统,创建的 selector提供器 的 类型 就会是
EPollSelectorProvider
由于 Windows操作系统 的源码,是 闭源 的,会阻碍之后内容的讲解
那么,现在本人就来通过Linux版本的实现,讲解下 Selector的 多路复用
功能 的 具体实现:
可以看到:
provider() 方法,在Linux操作系统上,创建了一个
EPollSelectorProvider
对象
而之后的 openServerSocketChannel()方法,在Linux操作系统上,则调用了
EPollSelectorProvider
对象 的 openSelector()方法,创建了一个 EPollSelectorImpl对象
那么, 我们再来看看 EPollSelectorImpl对象 的 单参构造 具体执行了什么:
可以看到:
单参构造,内部 创建了一个
EPollArrayWrapper对象
这也是Selector
实现 多路复用 功能的 核心
那么,我们继续跟进去,看看底层是如何实现的:
我们可以看到:
其内部调用了 一个
epollCreate()方法
而好巧不巧,epollCreate()方法
是一个 native的本地方法:
那么,为了秉持 一究到底 的态度,本人来展示下 这个本地方法 的 具体实现:
那么,我们来看看,epoll_create函数
的功能是什么:
int epoll_create(int size);
我们可以看到:
根据 Linux官方描述:
epoll_create函数
的作用是:
创建一个epoll对象,并且返回其 文件描述符(用于定位该对象)参数size 代表可能会容纳 size个描述符,但 size 不是 一个 最大值,只是提示操作系统它的 数量级,现在这个参数基本上已经弃用了
到这里,就基本上到了最底层 —— Linux内核函数
了
总结:
Selector.open()方法
,其本质上,是创建了一个 epoll对象
那么,我们再来看看 socketChannel.register(arg1, arg2) 方法,其底层是如何实现的:
socketChannel.register:
分析流程:
我们可以看到:
这是一个 包装方法,我们继续跟进
我们继续跟进 212行代码:
那么,我们来看看,是如何进行 注册 的:
我们可以看到:
调用了 Windows系统版本的选择器实现类 的 implRegister()方法
与之前分析同理,我们来看看在 Linux操作系统 下,JDK 是如何进行实现的:
从上图中,我们可以看出:
implRegister()方法 的 本质,是:
向Selector.open
这一步时创建的 pollWrapper对象 的 内部维护的集合 中,存放 目标socketChannel对象
总结:
socketChannel.register
方法,其本质上,是 向 Selector.open
这一步时创建的 pollWrapper对象 的 内部维护的集合 中,存放 目标socketChannel对象
那么,最后就是Selector最最最重要的 selector.select方法:
selector.select:
分析流程:
我们继续跟进:
我们可以看到:
其内部调用了一个方法,阻塞式监听 注册的事件
我们可以看到:
其内部通过加锁方式,保证了 同步
我们继续跟进去,看看是如何进行 事件监听 的:
我们可以看到:
调用了 Windows系统版本的selector实现类 的 doSelected()方法
那么,我们还是来看看 Linux版本 下,是如何实现 事件监听 的:
我们可以看到:
又调用了 pollWrapper对象 的 poll()方法,实现了 监听事件 的功能
那么,我们来看看,poll()方法 底层是如何实现的:
我们首先来看看 更新注册事件 是如何实现的:
我们跟进 299行 代码:
可以看到:
又是调用了 本地方法
那我们来 Linux系统 上,看看其是如何介绍的:
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
我们可以看到:
根据 Linux官方描述:
epoll_ctl函数
的作用是:
使用 文件描述符epfd 引用的 epoll实例,对 目标文件描述符fd 执行 op操作
- 参数epfd 表示 epoll对应的 文件描述符
- 参数fd 表示 socket对应的文件描述符
- 参数op 有以下几个值:
EPOLL_CTL_ADD
:注册 新的fd到epfd中,并关联事件event;
EPOLL_CTL_MOD
:修改 已经注册的fd的监听事件;
EPOLL_CTL_DEL
:从epfd中 移除fd,并且忽略掉绑定的event,这时event可以为null;- 参数event 是一个 结构体
简而言之,就是 真正地 将事件注册到selector的结构体上
那么,我们再来看看之后的 epoll_wait()方法
在底层是如何实现的:
可以看到:
又是调用了 本地方法
那我们来 Linux系统 上,看看其是如何介绍的:
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
我们可以看到:
根据 Linux官方描述:
epoll_wait函数
的作用是:
等待 文件描述符epfd上的事件
- 参数epfd 表示 Epoll对应的文件描述符
- 参数events 表示 调用者所有可用事件的集合
- 参数maxevents 表示 最多等到多少个事件就返回
- 参数timeout 表示 超时时间
总结:
selector.select
方法,其本质上,是 阻塞并监听 在socketChannel.register
这一步 注册的事件,并将 监听到的事件 注册到 epoll对象 的 rdlist
中去
那么,本人最后,通过一张 流程图,来总结下上面三个方法的 底层执行逻辑:
流程图:
那么,至于为什么 epoll对象
能实现 多路复用
,就是 操作系统 的知识
本人在这里通过比较,来为同学们普及一下:
select、poll 与 epoll:
| | select | poll | epoll(jdk 1.5及以上) |
|--|--|--|--|--|
| 操作方式 | 遍历 | 遍历| 回调|
| 底层实现 | 数组 | 链表| 哈希表|
| IO效率 | 每次调用 都进行 线性遍历
时间复杂度 为 O(n) | 每次调用 都进行 线性遍历
时间复杂度为 O(n)| 事件通知 方式
每当有 IO事件就绪,系统注册的回调函数 就会被调用
时间复杂度 为 O(1) |
| 最大连接 |有上限 |无上限 | 无上限 |
那么,至此,相信同学们基本上能了解 NIO
的 Selector
的 作用以及实现原理 了!
(码到这里,也到了5.1的凌晨2:55了,睡了睡了😴)