作者: 一字马胡
转载标志 【2017-11-24】
更新日志
日期 | 更新内容 | 备注 |
---|---|---|
2017-11-24 | 新建文章 | 以前学习java NI/O的时候写的文章,复制过来的,格式改了不少,所以难免有错误,不断更新 |
一、Java OIO
Java OIO (Java Old I/O)代表着的是一种阻塞I/O,所谓阻塞I/O,就是函数调用之后会一直阻塞直到函数返回正确值或者出错或者被中断,而在函数返回之前,该调用之后的代码将不会被执行,也就是说,你必须要等到这个函数返回(无论多久),你才能继续做接下来的事情。有时候这样的编程模型是必须的,比如我们必须依赖从数据库中读取到的数据以作为依据去执行接下来的代码逻辑,这样的编程模型是在一个假设下成立的,这个假设就是:认为阻塞等待函数返回是值得的,后面的代码就好像被锁住了一样,需要获取到一把钥匙才能打开锁以继续执行,而获取这把锁的唯一方法就是从阻塞中返回一种结果,然后根据不同的结果来打开不同的锁。这种I/O编程模型是简单的,你不需要为如何编写代码而紧皱眉头,但是这种编程模型的缺陷也是很明显的,因为,很多情况下,我们并不需要等待结果立刻返回,我们更希望提前提交任务,然后去做一些其他的事情,然后在必须获取结果才能继续的时候才阻塞等待获取,而这个时候可能函数早已返回,已经不需要阻塞了,这样的编程模型使得我们的工作更加高效,这其实也是并发编程的模型,这样的模型确实可以提高我们的代码的效率,但是写代码的难度就上升了一些,可能我们需要非常小心的安排代码的顺序,并且在必要的时候释放一些资源等。但是为了提高效率解决编程的复杂性是值得的。有必要清晰一下下面的概念:
- 阻塞I/O
- 非阻塞I/O
- 同步I/O
- 异步I/O
每一次I/O操作都会涉及下面的两个过程:
- 数据被copy到操作系统内核的缓冲区中
- 数据从操作系统内核缓存区copy到用户进程空间中
而这两个过程分别对应着下面的两个过程:
- 内核等待IO数据准备完成
- 进程将数据从内核copy到自己的地址空间内
上面四个概念的区别,可以通过下面的准则区分:
- 调用函数之后如果函数立即返回无论数据准备完成与否,则为非阻塞IO,否则为阻塞IO(重点在于调用线程是否会被阻塞)
- 在做真正的IO操作的时候如果会阻塞调用线程,则为同步IO,否则为异步IO(重点在于真正执行IO操作的时候对调用线程是否可感知)
根据上面的判断准则,OIO是阻塞的同步IO,而NIO是非阻塞的同步IO,NIO依然不是异步的,因为真正执行IO操作(比如read)的时候调用线程依然会被阻塞以等待结果(当内核数据还没有准备好的时候,是不会阻塞线程的,但是当内核已经准备好数据之后,进程需要将数据从内核拷贝到自己的地址空间这个步骤是阻塞的),Netty框架则基于NIO使得IO操作变成了异步的,所以Netty是一个异步的IO框架。
二、I/O多路复用技术
说到IO多路复用,马上应该想select、poll、epoll等机制。多路复用技术说的是,一个线程可以监听多个文件描述符,如果那个准备好了就处理哪个,这和传统的线程模型是有显著的区别的。传统的IO处理做法是,使用一个线程监听端口,进来一个请求,则新建一个线程处理该请求。这样的线程模型非常简单,弊端也是非常明显的,比如一个流量非常大的服务使用这样的线程模型来承接请求,那么服务的可用性是非常差的,当然,有一个方案可能比这个好一些,那就是使用线程池,并且设置等待队列,这样的话,线程不需要频繁的被创建,当一个请求完成处理之后,线程就可以空闲出来接收新的请求,当线程池里的线程都被占用了之后,请求会被放到等待队列,等待线程来拉取,这样的解决方案貌似非常先进,确实,这样的方案比起一开始的方案好很多,对于业务非常简单的服务,使用这样的方案应该可以承接不小的流量,但是对于业务足够复杂的场景来说,这样的方案依然会有风险,因为线程池满了之后,请求会被缓存起来啊,那缓存就需要空间来存放啊,那么这个队列的大小就是有约束的啊,不可能无限大啊,那如果缓存队列被打满了呢?那么接下来的请求将会被丢弃,对于用户而言就是,我明明点击了屏幕,但是没有任何动静啊?!!这样的后果就是,用户会再次点击,再次点击,再次点击....这样的后果对于服务端来说就是请求越来越多,对于用户来说就是,“多么垃圾的app啊”。所以,这样的方案依然得慎用。对于业务足够复杂,流量足够大的场景来说,选择多路复用技术是必须的。
2.1 select
下面是select的处理流程,select的具体操作步骤:
- 1、拷贝nfds、readfds、writefds和exceptfds到内核(自己感兴趣的描述符)
- 2、遍历[0,nfds)范围内的每个流,调用流所对应的设备的驱动poll函数
- 3、检查是否有流发生,如果有发生,把流设置对应的类别,并执行4,如果没有流发生,执行5。或者timeout=0,执行4
- 4、select返回
- 5、select阻塞当前进程,等待被流对应的设备唤醒,当被唤醒时,执行2。或者timeout到期,执行4
select的缺陷:
- (1)每次调用select,都需要把fd集合从用户态拷贝到内核态
- (2)同时每次调用select都需要在内核遍历传递进来的所有fd
- (3)select支持的文件描述符数量很小,默认是1024
2.2 poll 和epoll
poll和select差不多,但是poll不再告诉内核文件描述符的范围,而是告诉内核自己感兴趣的文件描述符集合,这样的话就没必要去询问自己不感兴趣的文件描述符了。epoll既然是对select和poll的改进,就应该能避免上述的三个缺点。那epoll都是怎么解决的呢?在此之前,我们先看一下epoll和select和poll的调用接口上的不同,select和poll都只提供了一个函数——select或者poll函数。而epoll提供了三个函数,epoll_create,epoll_ctl和epoll_wait,epoll_create是创建一个epoll句柄;epoll_ctl是注册要监听的事件类型;epoll_wait则是等待事件的产生。那我们从select/poll的三个缺点的解决方案来看下epoll的实现:
- 缺点1:每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
epoll的解决方案:对于第一个缺点,epoll的解决方案在epoll_ctl函数中。每次注册新的事件到epoll句柄中时(在epoll_ctl中指定EPOLL_CTL_ADD),会把所有的fd拷贝进内核,而不是在epoll_wait的时候重复拷贝。epoll保证了每个fd在整个过程中只会拷贝一次。 - 缺点2:同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大
epoll的解决方案: 对于第二个缺点,epoll的解决方案不像select或poll一样每次都把current轮流加入fd对应的设备等待队列中,而只在epoll_ctl时把current挂一遍(这一遍必不可少)并为每个fd指定一个回调函数,当设备就绪,唤醒等待队列上的等待者时,就会调用这个回调函数,而这个回调函数会把就绪的fd加入一个就绪链表)。epoll_wait的工作实际上就是在这个就绪链表中查看有没有就绪的fd - 缺点3:select支持的文件描述符数量太小了,默认是1024
epoll的解决方案:epoll没有这个限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。
2.3 select、poll、epoll总结
概括:
type | desc |
---|---|
Select | select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:(1) 单个进程可监视的fd数量被限制 (2) 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大 (3) 对socket进行扫描时是线性扫描 |
Poll | poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。 |
Epoll | epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。在前面说到的复制问题上,epoll使用mmap减少复制开销。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知 |
注:水平触发(level-triggered)——只要满足条件,就触发一个事件(只要有数据没有被获取,内核就不断通知你);边缘触发(edge-triggered)——每当状态变化时,触发一个事件。
区别:
type | Select | Poll | EPoll |
---|---|---|---|
支持最大连接数 | 1024(x86) or 2048(x64) | 无上限 | 无上限 |
IO效率 | 每次调用进行线性遍历,时间复杂度为O(N) | 每次调用进行线性遍历,时间复杂度为O(N) | 使用“事件”通知方式,每当fd就绪,系统注册的回调函数就会被调用,将就绪fd放到rdllist里面,这样epoll_wait返回的时候我们就拿到了就绪的fd。时间发复杂度O(1) |
fd拷贝 | 每次select都拷贝 | 每次poll都拷贝 | 调用epoll_ctl时拷贝进内核并由内核保存,之后每次epoll_wait不拷贝 |
select,poll实现需要自己不断轮询所有fd集合,直到设备就绪,期间可能要睡眠和唤醒多次交替。而epoll其实也需要调用epoll_wait不断轮询就绪链表,期间也可能多次睡眠和唤醒交替,但是它是设备就绪时,调用回调函数,把就绪fd放入就绪链表中,并唤醒在epoll_wait中进入睡眠的进程。虽然都要睡眠和交替,但是select和poll在“醒着”的时候要遍历整个fd集合,而epoll在“醒着”的时候只要判断一下就绪链表是否为空就行了,这节省了大量的CPU时间。这就是回调机制带来的性能提升。
select,poll每次调用都要把fd集合从用户态往内核态拷贝一次,并且要把current往设备等待队列中挂一次,而epoll只要一次拷贝,而且把current往等待队列上挂也只挂一次(在epoll_wait的开始,注意这里的等待队列并不是设备等待队列,只是一个epoll内部定义的等待队列)。这也能节省不少的开销。
三、Channel
channel是什么?NIO的channel类似于一种流,可以从channel读取数据,也可以向channel写数据,Channel在NIO中扮演着传输数据的角色,而接下来介绍的Buffer则扮演着存储数据的角色。NIO提供了很多的channel。
- FileChannel:从文件中读写数据(阻塞)
- DatagramChannel:通过UDP读写网络中的数据
- SocketChannel:通过TCP读写网络中的数据
- ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel
需要特别注意的是,除了FileChannel之外,其他的Channel都可以设置为非阻塞模式,而FileChannel无法切换为非阻塞模式。
下面的代码展示了如何新建一个FileChannel:
RandomAccessFile rf = new RandomAccessFile(file, mode);
FileChannel inChannel = rf.getChannel();
获取到Channel之后,我们就可以在Channel上做IO操作了。
四、Buffer
Buffer是一个缓冲区,用于存储从Channel中读取到的数据,或者将buffer作为参数传递给Channel来将buffer中的数据写到Channel里面去。NIO提供了很多的Buffer:
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
- MappedByteBuffer
- DirectByteBuffer
为了理解Buffer的工作原理,需要熟悉它的三个属性:
- capacity
- position
- limit
capacity
作为一个内存块,Buffer有一个固定的大小值,也叫“capacity”.你只能往里写capacity个byte、long,char等类型。一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据。
position
当你写数据到Buffer中时,position表示当前的位置。初始的position值为0.当一个byte、long等数据写到Buffer后, position会向前移动到下一个可插入数据的Buffer单元。position最大可为capacity – 1。当读取数据时,也是从某个特定位置读。当将Buffer从写模式切换到读模式,position会被重置为0. 当从Buffer的position处读取数据时,position向前移动到下一个可读的位置。在写模式下,Buffer的limit表示你最多能往Buffer里写多少数据。 写模式下,limit等于Buffer的capacity。当切换Buffer到读模式时, limit表示你最多能读到多少数据。因此,当切换Buffer到读模式时,limit会被设置成写模式下的position值。换句话说,你能读到之前写入的所有数据(limit被设置成已写数据的数量,这个值在写模式下就是position)。
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用
flip()
方法 - 从Buffer中读取数据
- 调用
clear()
方法或者compact()
方法
当向buffer写入数据时,buffer会记录下写了多少数据。一旦要读取数据,需要通过flip()方法将Buffer从写模式切换到读模式。在读模式下,可以读取之前写入到buffer的所有数据。一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。clear()方法会清空整个缓冲区。compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面。以下是所有Buffer共有的方法概要,具体的Buffer提供的接口可能稍有不同,可以参考jdk文档来查看具体的操作。这里需要特别提到一下MappedByteBuffer和DirectByteBuffer,有什么特别的嘛?前者使用了一种类似于mmap(文件映射内存)的技术,而后者申请的内存是堆外内存,也就是申请的内存不是jvm管理的,这样的好处的明显的,前者可以将文件的部分或者全部内容映射到内存中,实现了读写文件就好像是读写内存一样高效, 后者实现了所谓的“零拷贝”。
“零拷贝”是指计算机操作的过程中,CPU不需要为数据在内存之间的拷贝消耗资源。而它通常是指计算机在网络上发送文件时,不需要将文件内容拷贝到用户空间(User Space)而直接在内核空间(Kernel Space)中传输到网络的方式。
Non-Zero Copy方式:
Zero Copy方式:
Zero Copy的模式中,避免了数据在用户空间和内存空间之间的拷贝,从而提高了系统的整体性能。Linux中的sendfile()以及Java NIO中的FileChannel.transferTo()方法都实现了零拷贝的功能。非直接内存方式,数据需要在如下空间进行复制:
JVM Heap <——> JVM用户空间 <——> OS内核空间 <——> 网卡驱动空间;
直接内存方式时,数据需要在如下空间进行复制:
JVM用户空间 <——> OS内核空间 <——> 网卡驱动空间
所以当进行大量网络通信时采用直接内存方式,将减少一次复制,以及在Heap上对象的创建,将提高系统性能DirectByteBuffer属于直接访问内存方式,其空间位于JVM用户空间,不能由GC回收。java基于Cleaner和PhantomReference进行存储空间回收,也可以手动调用Cleaner进行回收。
五、Selector
Selector(选择器)使得NIO中能够监听一到多个通道,并且知道这些通道是否为读写做好准备的组件,这样一个线程可以通过管理多个Channel,进而管理多个网络连接。使用一个线程管理多个网络连接的好处在于可以避免线程间切换的开销。下面示范如何以一个Selector管理Channel。
首先是Selector的建立
//通过静态的open()方法得到一个Selector
Selector selector = Selector.open();
然后是向Selector注册一个ServerSocketChannel并监听连接事件:
//对于监听的端口打开一个ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//注册到Selector的Channel必须设置为非阻塞模式,否则实现不了异步IO
serverSocketChannel.configureBlocking(false);
ServerSocket serverSocket = serverSocketChannel.socket();
InetSocketAddress address = new InetSocketAddress(8080);
serverSocket.bind(address);
//第二个参数是表明这个Channel感兴趣的事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
与Selector同时使用的Channel必须处于非阻塞模式,这意味着FileChannel不能用于Selector,因为它不能切换到非阻塞通道;而套接字通道都是可以的。register的第二个参数表明了该Channel感兴趣的事件,具体的事件分为四个类型:
1.Connect
2.Accept
3.Read
4.Write
具体来说某个channel成功连接到另一个服务器称为“连接就绪”。一个server socket channel准备好接收新进入的连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”。等待写数据的通道可以说是“写就绪”。这些事件可以用SelectionKey的四个常量来表示:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
上面的Channel只是注册了一个事件,但实际上是可以同时注册多个事件的,比如可以像下面这样同时注册"接收就绪"和"读就绪"两个事件:
//使用"|"连接同时注册多个事件
serverSocketChannel
.register(selector, SelectionKey.OPACCEPT|SelectionKey.OPREAD);
SelectionKey
上面向Selector注册Channel后返回了一个SelectionKey对象,这个对象包含了一些很有用的信息集:
- interest集合
- ready集合
- Channel
- Selector
interest集合即上面Channel注册时添加的感兴趣的事件集合,我们可以通过调用SelectionKey 的interestOps()方法得到一个int数字,然后通过“&”位操作来确定具体有哪些感兴趣的集合:
int interestSet = key.interestOps();
//是否包含ACCEPT事件
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
//是否包含CONNECT事件
boolean isInterestedInConnect = (interestSet & SelectionKey.OP_CONNECT) == SelectionKey.OP_CONNECT;
boolean isInterestedInRead = (interestSet & SelectionKey.OP_READ) == SelectionKey.OP_READ;
boolean isInterestedInWrite = (interestSet & SelectionKey.OP_WRITE) == SelectionKey.OP_WRITE;
ready集合表明该Selector上已经就绪的事件,可以通过key.readyOps()获得一个数字,然后通过上面同样的方式拿到就绪的集合;但是,也可以使用下面这些更加简洁的方法判断:
//四个返回boolean值的方法,可以用于判断目前Selector上有哪些事件已经就绪
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWritable();
可以很简单的拿到这个SelectinKey关联的Selector和Channel,如下所示:
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
监听Selector选择通道
当向Selector注册了几个Channel之后,就可以调用几个重载的select()方法来检测是否有通道已经就绪了。具体的来说,Selector的select()方法有以下三种形式:
int select()
int select(long timeout)
int selectNow()
第一个方法会阻塞直到至少有一个通道就绪然后返回;第二个方法和第一个方法类似但不会一直阻塞而是至多会阻塞timeout时间;第三个方法不会阻塞,无论有无就绪的通道都会立即返回,如果没有就绪的通道会返回0。这些方法返回的int值表明该Selector上就绪通道的数量,准确的来说是自上次调用select()方法后有多少通道变成就绪状态。如果调用select()方法,因为有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。如果调用select()方法表明至少有一个通道就绪了,那么就可以通过selector.selectedKeys()方法来获得具体就绪的通道,这个方法的返回值是Set<SelectionKey>。如上面所介绍的我们可以很方便的通过SelectionKey找到就绪的事件以及对应的Channel,下面的代码示例了如何遍历这个Set:
Set<SelectionKey> selectionKeySet = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeySet.iterator();
while (iterator.hasNext()){
SelectionKey selectionKey = iterator.next();
if(selectionKey.isAcceptable()){
// a connection was accepted by a ServerSocketChannel.
}else if(selectionKey.isConnectable()){
// a connection was established with a remote server.
}else if(selectionKey.isWritable()){
// a channel is ready for writing
}else if(selectionKey.isReadable()){
// a channel is ready for reading
}
iterator.remove();
}
注意末尾的remove()方法,当处理完一个SelectionKey之后,必须手动的将其从Set中移除,Selector本身不会进行这个工作,所以需要我们手动移除避免下一次重复处理。
ServerSocketChannel
其实从上面的代码中我们已经看到了,ServerSocketChannel和ServerSocket所起的作用是一致的,都是用来监听tcp连接的;值得注意的就是ServerSocketChannel是可以设置为非阻塞模式的,这时候它的accept()方法在没有连接进入的情况下总是返回null。下面的代码示例了ServerSocketChannel的基本用法:
//ServerSocketChannel对象通过静态方法获取
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//具体的端口绑定操作还是通过关联的ServerSocket实现
ServerSocket ss = serverSocketChannel.socket();
InetSocketAddress address = new InetSocketAddress(8080);
ss.bind(address);
//ServerSocketChannel可以被设置成非阻塞的模式,这是和Selector配合使用的基础
serverSocketChannel.configureBlocking(false);
while (true){
//accept()方法用于监听进来的连接,如果被设置为非阻塞模式,那么当没有连接时总是返回null
SocketChannel socketChannel = serverSocketChannel.accept();
if (socketChannel != null) {
//do something with socketChannel...
}
}
SocketChannel
Java NIO中的SocketChannel是一个连接到TCP网络套接字的通道,和Socket是类似的。可以通过以下2种方式创建SocketChannel:
1、 打开一个SocketChannel并连接到互联网上的某台服务器。
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost",80));
2 、一个新连接到达ServerSocketChannel时,会创建一个SocketChannel。如上面介绍ServerSocketChannel的代码所示SocketChannel的数据读写和FileChannel没有什么不同,都是需要借助Buffer;值得注意的是SocketChannel是可以工作在非阻塞模式下的,这时候的read()、write()方法都会直接返回,这种模式主要是为了配合Selector来实现异步非阻塞IO。