由表及里:JDK的I/O多路复用及其配套API的源码分析(上)
本篇文章大概3500字,阅读时间大约10分钟
本文主要是重点review了JDK的Selector的源码实现的细节,版本为JDK8,以期待能更好的理解Netty的线程图像。
主要拆解了:
1、I/O多路复用器跨平台的机制
2、SPI插件化开发的应用
3、Selector的register过程的本质
4、Selector的wakeup的原理
5、Windows下对Selector的select方法的优化工作,避免采坑
6、Linux下对Selector的select方法的优化工作,知根知底
废话不多说,下面我从一个标准的,最简单的NIO的服务端代码编写流程开始,分析它的底层实现细节,看看它一步步到底是怎么回事儿,怎么就实现了我们所看到的那个功能,顺便验证梳理之前的一些模糊的地方。
不论怎么秀,你都得打开一个Selector,如下:
它内部封装了一句话;
所以,像Netty这样的框架,就直接用的上面的方式打开一个I/O多路复用器,如下单独封装一个openXX方法,目的是可以更灵活的选择到底使用哪个版本的I/O多路复用器,甚至对其做一些优化,Netty就是这么做的:
继续看JDK源码,先看这个SelectorProvider类的静态provider()方法都做了什么:
它最终会返回一个provider——系统内默认的I/O多路复用器的提供者对象,个人理解它是一个脚手架,系统指的是当前JVM所在的操作系统,且全局只会生成一个provider,如果你重复调用这个方法,那么只起一次作用,所以可以认为它是单例的。
具体加载策略为:
1、先loadProviderFromProperty(),尝试从系统参数里加载配置,本质是通过反射类的全限定名来获取实现类:
2、如果没找到,那么通过SPI(Service Provider Interface)机制,加载插件,即调用loadProviderAsService()。很多框架都有这种用法,能实现服务的功能单元可插拔,解耦合的架构,简单说就是可以让用户在不修改源码的前提下,切换配置来实现切换接口的具体实现类,这就是所谓的插件化开发,比如elasticsearch的插件。实际中,虽然有框架会使用SPI的工具包实现这种机制,而不单独使用JDK的SPI,但是思想是换汤不换药的。
loadProviderAsService()如下,JDK获取Selector的策略之一,是通过SPI,它结合配置文件可以动态的获取SelectorProvider的一个实现类:
如下是第一行代码:
ServiceLoader<SelectorProvider> sl =
ServiceLoader.load(SelectorProvider.class,
ClassLoader.getSystemClassLoader());
ServiceLoader这个java.util包里的工具类,会使用类加载器获取指定目录下的文件,读取该文件内容并解析,解析到所有全限定类名(就是带着完整包名的类名),然后加载对应的class文件,最后实例化对应的对象,并放入一个存储区,提供给使用者。用户需要提前在自己的插件里配置对应的目录和文件,其实就是一个jar包的META-INF/services/文件夹里创建一个以服务接口全限定名命名的配置文件,文件里写你具体实现类的全限定名。
当调用ServiceLoader.load(xxx,xxx);时,如果你提供的插件在指定的类加载器里,那么就能扫描到META-INF/services/,从而获取到你的配置里的实现类。具体细节不多说,知道插件开发这种东西是怎么回事儿就行,啥时候需要直接能用,有问题了在深入细节。
3、如果还是没找到,那么就进入兜底策略:
provider = sun.nio.ch.DefaultSelectorProvider.create();
这是JDK的I/O多路复用器跨平台的一个关键方法,即不同的操作系统create的I/O多路复用器provider的实例不一样,我的demo在win10下运行,下面看它的实现,会new一个WindowsSelectorProvider的实例:
进入这个WindowsSelectorProvider类构造器,最终返回如下的provider:
类图如下:
即在Windows平台下,会给用户返回一个WindowsSelectorProvider对象。对应的,在Linux平台下,会给用户返回一个EpollSelectorProvider对象。
回到demo,继续看打开I/O多路复用器的方法:
public static Selector open() throws IOException {
return SelectorProvider.provider().openSelector();
}
最终,调用的是返回的WindowsSelectorProvider对象的openSelector方法,这个open方法内部会实例化一个WindowsSelectorImpl对象,类图如下,即服务提供者和服务本身是分开设计的:
WindowsSelectorImpl类是rt.jar里的,该jar包含的是运行时(runtime)的核心的Java的class文件,所以这块儿源码idea只能反编译,但是看不到原始的文件。为了源码可读性,我参考的是openjdk jdk8u版本的源码。
看WindowsSelectorImpl的构造器,它传入了I/O多路复用器provider:
140行:调用父类SelectorImpl,这里没啥特殊的,是一些版本的判断,关键是看后面的代码
141行:创建了一个操作系统的poll的数组,保存Selector上注册的文件描述符(fd)以及I/O事件的标记位,初始大小INIT_CAP=8,所以在Windows上,并不是用的epoll机制:
下面简单看看这个数组是什么东西,如下是它的实现类源码:
最后这个new AllocatedNativeObject,会调用到如下的顶级父类——NativeObject:
显然,JDK使用Unsafe组件申请了一块堆外内存,来实现这个数组,这个操作很危险,这相当于C的指针,使用这种方式创建的内存,必须手动回收,否则很容易OOM,所以普通用户很难直接使用这个Unsafe,注意它和Netty的Unsafe不是一回事儿,只是命名理念一致。可以参考:
至于这块儿内存的回收,如下:
142行:创建一个pipe对象,目的是为了实现Selector的唤醒操作。看下这个Pipe类,它很老,在1.4就有了,可见它是伴随着JDK NIO一起出现的,它是一个单向的管道数据结构,关联了一对儿Channel,一个是可写Channel,JDK叫它sinkChannel(sink),一个是可读Channel,JDK叫它sourceChannel(source)。
PS.很多流计算组件,比如Flink,也有这样的组件命名——sink和source。
具体的,当sinkChannel里有字节写入,sourceChannel就能立即按照写的顺序从sinkChannel读这些字节。知道了这个背景,继续看pipe的构建过程,如下仍然是依赖I/O多路复用器的provider创建:
最终会实例化一个PipeImpl对象:
进入PipeImpl,如下实现:
到这里,就该调用native方法了,即42行的IOUtil.makepipe(true);这个方法是用C/C++语言实现,是一个原生函数,如下它位于sun/nio/ch/IOUtil.java:
它的核心作用是为管道的sink和source关联操作系统的文件描述符(fd),并且该方法被编译成了DLL,JDK的源代码中并不包含。知道它的作用即可,即搞一个long变量(8个字节64位),在其高32位存储source fd,在低32位存储sink fd,最后返回这个long型变量pipeFds。所以在43行,会对pipeFds使用无符号右移32位,就取到了source fd,在44行,截断为int后就是sink fd,很巧妙的编码。之后经过一系列设置就准备好了pipe结构。
下面,回到WindowsSelectorImpl.java源码:
继续,有了前面第142行的铺垫,143行直接就能依据pipe获取source fd。在146行直接获取其sinkChannel,目的是为了关闭该管道的sinkChannel的TCP协议的nagle算法,注释也写了,目的是让wakeup动作能尽快被Selector响应,前面也简单提了一下,这个pipe的设计主要是为了实现Selector的唤醒机制。
关键是第150行,将source fd,注册的感兴趣的I/O事件加入到前面已经创建的poll数组,后续就能实现Selector的wakeup方法了。因为此时注册了source fd,前面总结了,一旦sinkChannel端写入字节,sourceChannel就能立即读取,如果sourceChannel和Selector关联,就能实现类似有I/O事件就绪的效果,就能立即唤醒阻塞的Selector,原理就是这么简单。
以上,发现操作系统层面的I/O多路复用器,不是在打开一个Java的Selector对象的时候创建和生效的,它仅仅是做了:
1、准备一个pipe,方便后续实现Selector唤醒操作
2、准备堆外内存数组,容纳pipe的fd,和后续可能有的fd,以及I/O事件标记
那么真正的I/O多路复用器是在哪里开始创建的呢?
这就要继续看NIO的ServerSocketChannel的创建过程,编写一个NIO的Java服务端网络程序,一定是要创建ServerSocketChannel的,如下:
如下,进入open发现,这里果然能联系到一起,ServerSocketChannel的创建依赖了前面的SelectorProvider脚手架——WindowsSelectorImpl:
继续看open内部的openServerSocketChannel方法:
内部仍然是创建了Windows平台下的一个实现,这个this参数就是前面的WindowsSelectorImpl对象,进入ServerSocketChannelImpl构造器,仍然是看openjdk的源码,参数sp就是这个WindowsSelectorImpl对象:
关键是super的调用,看看服务端Channel是如何关联的SelectorProvider,很简单,如下,最终调用到ServerSocketChannel的抽象父类,简单赋值就完事了,而且也能看到默认的Channel都是阻塞模式:
该构造器ServerSocketChannelImpl剩下的逻辑就是在创建操作系统的监听套接字fd而已,不多说。
前面,在打开一个Selector,打开一个服务端的ServerSocketChannel后,下一步就是为ServerSocketChannel配置非阻塞模式,然后才能使用register方法为服务端Channel注册Selector以及I/O事件等,一般流程如下:
这里简单看下配置阻塞Channel的过程,最终它调用的也是native方法,这个IOUtil类前面见过很多次了:
看openjdk的Windows平台源码:
第141行注释清晰的写明:阻塞模式的fd,也就是Channel,无法使用register实现事件驱动。更底层的不用纠结,知道这个结论即可。比如142行的WSAEventSelect,是Windows的一个Socket的异步I/O模型,一般很少关注Windows服务器。而我的demo肯定会执行138行的分支,最终执行144行的ioctlsocket(xxx),这是一个c++函数,用来控制Socket的I/O模式,这里是设置监听套接字为非阻塞模式。
不用纠结这么底层,继续看Java实现,见下篇。
END
点亮在看,你最好看
~
点阅读原文,获得更多精彩内容