我准备战斗到最后,不是因为我勇敢,是我想见证一切。 --双雪涛《猎人》
我准备战斗到最后,不是因为我勇敢,是我想见证一切。 --双雪涛《猎人》
传统BIO
NIO
跟踪源码查看NIO在底层如何实现的
Epoll函数详解
❝❞
一个技术,为什么要用它,解决了那些问题? 如果不用会怎么样,有没有其它的解决方法? 对比其它的解决方案,为什么最终选择了这种,都有何利弊? 你觉得项目中还有那些地方可以用到,如果用了会带来那些问题? 这些问题你又如何去解决的呢?
「本文基于Netty 4.1.45.Final-SNAPSHOT」
这里不赘述java的各种IO模型。
传统BIO
缺点就是 一连接一响应,非常的浪费资源,就算使用线程池的方式进行伪异步的优化,也可能带来C10k的问题。 IO代码里read操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,浪费资源

NIO
在jdk1.4之后引入的全新的IO模型,它的设计理念就是为了解决传统BIO中的阻塞问题,实现了一个线程可以处理多个请求,客户端所有的请求都会注册到多路复用器selector上,多路复用器轮询到连接有io请求就进行处理。
「应用场景:」
NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂
// 保存客户端连接
static List<SocketChannel> channelList = new ArrayList<>();
public static void main(String[] args) throws IOException {
// 创建NIO ServerSocketChannel,与BIO的serverSocket类似
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
System.out.println("服务启动成功");
while (true) {
// 非阻塞模式accept方法不会阻塞,否则会阻塞
// NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
SocketChannel socketChannel = serverSocket.accept();
if (socketChannel != null) { // 如果有客户端进行连接
System.out.println("连接成功");
// 设置SocketChannel为非阻塞
socketChannel.configureBlocking(false);
// 保存客户端连接在List中
channelList.add(socketChannel);
}
// 遍历连接进行数据读取
Iterator<SocketChannel> iterator = channelList.iterator();
while (iterator.hasNext()) {
SocketChannel sc = iterator.next();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
// 非阻塞模式read方法不会阻塞,否则会阻塞
int len = sc.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开,把socket从集合中去掉
iterator.remove();
System.out.println("客户端断开连接");
}
}
}
}
❝主要来看一下,nio中最简单的一种实现。(没有引用io多路复用器)。存在的问题就是:
❞
缺点:
如果此时有大量的连接注册上了,但是实际活跃的确实小部分 这时候每次轮询时,会做大量的无用功,使得效率低下。
NIO 引入selector多路复用器
// 创建NIO ServerSocketChannel
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
serverSocket.configureBlocking(false);
// 打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
SelectionKey selectionKey = serverSocket.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务启动成功");
while (true) {
// 阻塞等待需要处理的事件发生
selector.select();
// 获取selector中注册的全部事件的 SelectionKey 实例
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历SelectionKey对事件进行处理
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);
// 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
SelectionKey selKey = socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("客户端连接成功");
} else if (key.isReadable()) { // 如果是OP_READ事件,则进行读取和打印
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(128);
int len = socketChannel.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开连接,关闭Socket
System.out.println("客户端断开连接");
socketChannel.close();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
❝selector 是nio高性能的最终杀器,其中实现都是依赖于底层的epoll来实现的。
提到NIO中的selector,就不得不提一嘴,在JavaNIO中反人类的API三大组件「Channel(通道), Buffer(缓冲区),Selector(多路复用器)」
其中最反人类的就是buffer,由于其双向写入写出的特性,导致读写都需要进行翻转,业务复杂时,自己都直接晕掉!
1、channel 类似于流,每个 channel 对应一个 buffer缓冲区,buffer 底层就是个数组
2、channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理
3、NIO 的 Buffer 和 channel 都是既可以读也可以写
❞

❝NIO在1.4时,底层的轮询方案与上面最简单的nio代码一样,每次都会轮询所有的sockchannel,查看那个channel上出发了监听事件,这就是所谓的select()和poll()机制。在1.5之后,开始引入Epoll基于事件响应机制来优化NIO。
举个例子:
在jdk1.4时,在一个吧台上,做了很多顾客,这个时候调酒师,每次都要挨个的询问他们,要喝酒吗?好兄弟,要喝酒吗?好兄弟?(上班一整天下来,累没累坏,直接变哑巴了!!!而且并不是所有的顾客都要喝酒) 在jdk1.5之后,所有坐在吧台前面的顾客,自己想喝酒的时候,跟调酒师要就好了。大家都轻松。 这种机制是如何实现的呢?
❞
在NIO多路复用里面最重要的三个步骤
Selector.open() //创建多路复用器
socketChannel.register(selector, SelectionKey.OP_READ) //将channel注册到多路复用器上
selector.select() //阻塞等待需要处理的事件发生
跟踪源码查看NIO在底层如何实现的
这里整个文档都是基于openJDK8。

配合上面的流程图,给出几个比较关键的类:
EPollSelectorImpl(SelectorProvider sp) throws IOException {
super(sp);
long pipeFds = IOUtil.makePipe(false);
fd0 = (int) (pipeFds >>> 32);
fd1 = (int) pipeFds;
pollWrapper = new EPollArrayWrapper();
pollWrapper.initInterrupt(fd0, fd1); // 这里是调用底层进行中断
fdToKey = new HashMap<>();
}
❝在
EPollSelectorImpl
构造函数中,会初始化一个poll object ->EPollArrayWrapper
。该类是实现Epoll 功能的核心类其中有三个针对epoll 非常重要的底层方法
image-20210421204820718 这三个方法在上图都有详细的说明
❞
「总结」
在整个NIO中,核心就是底层的socket,并且在linux中万物皆是文本。所以在操作系统的内核函数创建好Socket之后,获取到Socket的文件描述符,再创建一个Selector对象,对应操作系统的Epoll描述符,将获取到的Socket连接的文件描述符的事件绑定到Selector对应的Epoll文件描述符上,进行事件的异步通知,这样就实现了使用一条线程,并且不需要太多的无效的遍历,将事件处理交给操作系统内核(操作系统中断程序实现,在接受到事件之后,中断程序会将高socket的描述符添加到rdlist中),大大提高了效率。
Epoll函数详解
JNIEXPORT jint JNICALL
Java_sun_nio_ch_EPollArrayWrapper_epollCreate(JNIEnv *env, jobject this)
{
/*
* epoll_create expects a size as a hint to the kernel about how to
* dimension internal structures. We can't predict the size in advance.
*/
int epfd = epoll_create(256);
if (epfd < 0) {
JNU_ThrowIOExceptionWithLastError(env, "epoll_create failed");
}
return epfd;
}
创建一个epoll实例,并返回一个非负数作为文件描述符,用于对epoll接口的所有后续调用。入参表示可能会容纳多少个描述符。但size不是一个最大值,只是提示操作系统它的数量级,现在这个参数基本上已经弃用了。
JNIEXPORT void JNICALL
Java_sun_nio_ch_EPollArrayWrapper_epollCtl(JNIEnv *env, jobject this, jint epfd,
jint opcode, jint fd, jint events)
{
struct epoll_event event;
int res;
event.events = events;
event.data.fd = fd;
RESTARTABLE(epoll_ctl(epfd, (int)opcode, (int)fd, &event), res);
/*
* A channel may be registered with several Selectors. When each Selector
* is polled a EPOLL_CTL_DEL op will be inserted into its pending update
* list to remove the file descriptor from epoll. The "last" Selector will
* close the file descriptor which automatically unregisters it from each
* epoll descriptor. To avoid costly synchronization between Selectors we
* allow pending updates to be processed, ignoring errors. The errors are
* harmless as the last update for the file descriptor is guaranteed to
* be EPOLL_CTL_DEL.
*/
if (res < 0 && errno != EBADF && errno != ENOENT && errno != EPERM) {
JNU_ThrowIOExceptionWithLastError(env, "epoll_ctl failed");
}
}
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
该函数就是将创建的epoll文件描述符与关注的事件绑定到一起。当事件产生时,中断程序会根据事件的类型,到rdlist中回调等待此事件的socket 文件描述符,进行回调。上述代码很清楚的可以看到,event绑定了一个事件列表,并且与当前的epoll文件描述符关联
使用文件描述符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是一个结构体
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
events有很多可选值,这里只举例最常见的几个:
EPOLLIN :表示对应的文件描述符是可读的;
EPOLLOUT:表示对应的文件描述符是可写的;
EPOLLERR:表示对应的文件描述符发生了错误;
成功则返回0,失败返回-1
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
等待文件描述符epfd上的事件。
epfd是Epoll对应的文件描述符,events表示调用者所有可用事件的集合,maxevents表示最多等到多少个事件就返回,timeout是超时时间。
I/O多路复用底层主要用的Linux 内核·函数(select,poll,epoll)来实现,windows不支持epoll实现,windows底层是基于winsock2的select函数实现的(不开源)
| 「select」 | 「poll」 | 「epoll(jdk 1.5及以上)」 | |
|---|---|---|---|
| 「操作方式」 | 遍历 | 遍历 | 回调 |
| 「底层实现」 | 数组 | 链表 | 哈希表 |
| 「IO效率」 | 每次调用都进行线性遍历,时间复杂度为O(n) | 每次调用都进行线性遍历,时间复杂度为O(n) | 事件通知方式,每当有IO事件就绪,系统注册的回调函数就会被调用,时间复杂度O(1) |
| 「最大连接」 | 有上限 | 无上限 | 无上限 |
❝本文仅供笔者本人学习,有错误的地方还望指出,一起进步!望海涵!
「转载请注明出处!」
欢迎关注我的公共号,无广告,不打扰。不定时更新Java后端知识,我们一起超神。
——努力努力再努力xLg
加油!
❞






