和大多数人一样,NIO在课本里面看到过,但是基本没有用过。毕竟应该很少有程序员自己去在java里面实现NIO,大多都基于Netty框架来实现。我之前开发过一个基于websocket的项目,websocket是基于tomcat实现的,但是根据线上的效果来看,websocket连接传输的性能并不好,所以准备着手优化。
目前主流的websocket实现框架有tomcat、netty-socketIO和netty,并且网友一边倒的倾向于netty相关的实现。鄙视tomcat的理由是: 依赖于容器,性能较差;基于BIO,当并发量高的时候会有资源瓶颈。
但是经过我的实际测试,我当前使用的内嵌tomcat9已经默认实现NIO了。只能说tomcat实现websocket性能较差的原因,在于它基于容器的websocket实现不够完善,NIO的实现也不如netty成熟。
当然,我最终选择了用netty来重构当前的websocket框架。不过,我对于NIO的实现过程也产生了兴趣,连tomcat也在向它倾斜。
Java 中的 BIO、NIO和 AIO 理解为是 Java 语言对操作系统的各种 IO 模型的封装。程序员在使用这些 API 的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码。只需要使用Java的API就可以了。
在讲 BIO,NIO,AIO 之前先来回顾一下这样几个概念:同步与异步,阻塞与非阻塞。
同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。 异步 就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。
同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。我们可以用打电话和发短信来很好的比喻同步与异步操作。
阻塞与非阻塞主要是从 CPU 的消耗上来说的,阻塞就是 CPU 停下来等待一个慢的操作完成 CPU 才接着完成其它的事。非阻塞就是在这个慢的操作在执行时 CPU 去干其它别的事,等这个慢的操作完成时,CPU 再接着完成后续的操作。
虽然表面上看非阻塞的方式可以明显的提高 CPU 的利用率,但是也带了另外一种后果就是系统的线程切换增加。增加的 CPU 使用时间能不能补偿系统的切换成本需要好好评估。
那么 同步阻塞 、 同步非阻塞 和 异步非阻塞 又代表什么意思呢?
我在网上看到一个很好的例子:你妈妈让你烧水,小时候你比较笨啊,在哪里傻等着水开(同步阻塞)。等你稍微再长大一点,你知道每次烧水的空隙可以去干点其他事,然后只需要时不时来看看水开了没有(同步非阻塞)。后来,你们家用上了水开了会发出声音的壶,这样你就只需要听到响声后就知道水开了,在这期间你可以随便干自己的事情,你需要去倒水了(异步非阻塞)。
NIO是一种新的IO模型(Recator模型),新主要体现在多路复用,事件驱动上
1、多路复用,一个线程可以处理多个socket请求,通过多个socket注册在一个select上面,然后不断调用select来获取被激活的socket,即达到在一个线程中,处理多个socket请求目的,而在传统(同步阻塞)IO模型中,需要通过多线程的方式才能达到此目的,传统的IO模型由于使用多线程,就会有线程数量以及线程上下文切换等限制。
2、事件驱动(其实就是观察者模式),模型图如下
如图所示,EventHandler为IO的事件处理器(观察者),Reactor为管理EventHandler类,事件的注册,删除等(被观察者),reactor的handle_event函数会不断循环调用内核的selec()函数(同步事件多路分离器(一般是内核)的多路分离函数),只要某个文件句柄被激活(可读写),select()函数就返回,handle_event会调用相关的事件处理函数EventHandler上的handle_event()函数。
时序图如上图所示,使用reactor模型之后,用户线程注册事件之后,可以去执行其他事情(异步),等相关读写工作就绪之后,Reactor会通知用户线程进行读写。用户IO线程轮询是否读写好等工作由Reactor上的handle_events处理,Reactor会调用内核select函数检查socket的状态。当socket被激活的时候,通知用户线程(或调用户线程的回掉函数)。执行EventHandler的hand_event()函数。由于select函数是阻塞的,所以多了复用模型被叫做异步阻塞模型,注意,这里所说的阻塞并不是socket上read等操作的阻塞,socket上这些操作时非阻塞的(事件模型)。
NIO有3个实体:Buffer(缓冲区),Channel(通道),Selector(多路复用器)。
Buffer是客户端存放服务端信息的一个容器,服务端如果把数据准备好了,就会通过Channel往Buffer里面传。Buffer有7个类型:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer。
Channel是客户端与服务端之间的双工连接通道。所以在请求的过程中,客户端与服务端中间的Channel就在不停的执行“连接、询问、断开”的过程。直到数据准备好,再通过Channel传回来。Channel主要有4个类型:FileChannel(从文件读取数据)、DatagramChannel(读写UDP网络协议数据)、SocketChannel(读写TCP网络协议数据)、ServerSocketChannel(可以监听TCP连接)
Selector是服务端选择Channel的一个复用器。Seletor有两个核心任务:监控数据是否准备好,应答Channel。具体说来,多个Channel反复轮询时,Selector就看该Channel所需的数据是否准备好了;如果准备好了,则将数据通过Channel返回给该客户端的Buffer,该客户端再进行后续其他操作;如果没准备好,则告诉Channel还需要继续轮询;多个Channel反复询问Selector,Selector为这些Channel一一解答。
旦有请求到来(不管是几个同时到还是只有一个到),都会调用对应IO处理函数处理,所以:
(1)NIO适合处理连接数目特别多,但是连接比较短(轻操作)的场景,Jetty,Mina,ZooKeeper等都是基于java nio实现。
(2)BIO方式适用于连接数目比较小且固定的场景,这种方式对服务器资源要求比较高,并发局限于应用中。
数据需要从磁盘拷贝到内核空间,再从内核空间拷到用户空间(JVM)。
程序可能进行数据修改等操作
再将数据拷贝到内核空间,内核空间再拷贝到网卡内存,通过网络发送出去(或拷贝到磁盘)。
即数据的读写(这里用户空间发到网络也算作写),都至少需要两次拷贝。
当然磁盘到内核空间属于DMA拷贝(DMA即直接内存存取,原理是外部设备不通过CPU而直接与系统内存交换数据)。而内核空间到用户空间则需要CPU的参与进行拷贝,既然需要CPU参与,也就涉及到了内核态和用户态的相互切换
改进的地方:
但这还没有达到我们零拷贝的目标。如果底层NIC(网络接口卡)支持gather操作,我们能进一步减少内核中的数据拷贝。在Linux 2.4以及更高版本的内核中,socket缓冲区描述符已被修改用来适应这个需求。这种方式不但减少多次的上下文切换,同时消除了需要CPU参与的重复的数据拷贝。用户这边的使用方式不变,而内部已经有了质的改变。
NIO的零拷贝由transferTo()方法实现。transferTo()方法将数据从FileChannel对象传送到可写的字节通道(如Socket Channel等)。在内部实现中,由native方法transferTo0()来实现,它依赖底层操作系统的支持。在UNIX和Linux系统中,调用这个方法将会引起sendfile()系统调用。
首先,它的作用位置处于传统IO(BIO)与零拷贝之间,为何这么说?
传统IO,可以把磁盘的文件经过内核空间,读到JVM空间,然后进行各种操作,最后再写到磁盘或是发送到网络,效率较慢但支持数据文件操作。
零拷贝则是直接在内核空间完成文件读取并转到磁盘(或发送到网络)。由于它没有读取文件数据到JVM这一环,因此程序无法操作该文件数据,尽管效率很高!
而直接内存则介于两者之间,效率一般且可操作文件数据。直接内存(mmap技术)将文件直接映射到内核空间的内存,返回一个操作地址(address),它解决了文件数据需要拷贝到JVM才能进行操作的窘境。而是直接在内核空间直接进行操作,省去了内核空间拷贝到用户空间这一步操作。
NIO的直接内存是由MappedByteBuffer实现的。核心即是map()方法,该方法把文件映射到内存中,获得内存地址addr,然后通过这个addr构造MappedByteBuffer类,以暴露各种文件操作API。
由于MappedByteBuffer申请的是堆外内存,因此不受Minor GC控制,只能在发生Full GC时才能被回收。而DirectByteBuffer改善了这一情况,它是MappedByteBuffer类的子类,同时它实现了DirectBuffer接口,维护一个Cleaner对象来完成内存回收。因此它既可以通过Full GC来回收内存,也可以调用clean()方法来进行回收。
另外,直接内存的大小可通过jvm参数来设置:-XX:MaxDirectMemorySize。
NIO的MappedByteBuffer还有一个兄弟叫做HeapByteBuffer。顾名思义,它用来在堆中申请内存,本质是一个数组。由于它位于堆中,因此可受GC管控,易于回收。
还是那个websocket的项目,之前说过,网上很多人都认为tomcat默认是实现BIO的。但我在运行springboot项目后,无意中看到控制台的日志中有个"nio-exec-"前缀的线程。我以为是因为引入了netty,但是当时那个接口是http的,和netty没关系,最终查阅资料后了解到tomcat不同版本也在做改变。
1、BIO:阻塞式I/O操作即使用的是传统 I/O操作,Tomcat7以下版本默认情况下是以BIO模式运行的,由于每个请求都要创建一个线程来处理,线程开销较大,不能处理高并发的场景,在三种模式中性能也最低。启动tomcat后,日志中会有 http-bio-端口
的内容。
2、NIO是Java 1.4 及后续版本提供的一种新的I/O操作方式,是一个基于缓冲区、并能提供非阻塞I/O操作的Java API,它拥有比传统I/O操作(BIO)更好的并发运行性能。tomcat 8版本及以上默认就是在NIO模式下允许。启动tomcat后,日志中会有 http-nio-端口
的内容。
3、APR(Apache Portable Runtime/Apache可移植运行时),是Apache HTTP服务器的支持库。你可以简单地理解为,Tomcat将以JNI的形式调用Apache HTTP服务器的核心动态链接库来处理文件读取或网络传输操作,从而大大地提高Tomcat对静态文件的处理性能。 Tomcat apr也是在Tomcat上运行高并发应用的首选模式。启动tomcat后,日志中会有 http-apr-端口
的内容。
当然,tomcat的版本也不一定绝对匹配到,如果你想看你的tomcat是什么版本的,还是要看日志。如果你是启动springboot运行的,内嵌tomcat的日志可能不够完整,可以通过在配置文件中加上以下的属性来开启完整日志:
logging.level.org.apache.tomcat=debug logging.level.org.apache.catalina=debug