Java NIO是一个用来替代标准Java IO API的新型数据传递方式,像现在分布式架构中会经常存在他的身影。其比传统的IO更加高效,非阻塞,异步,双向
NIO主体结构
Java NIO的主要构成核心就是Buffer、Channel和Selector这三个
对于Channel我想要提醒的是,Channel中的数据总是要先读到一个Buffer,或者总是要从一个Buffer中写入
使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件
Channel
所有的 IO 在NIO 中都从一个Channel 开始。Channel 有点象流
Channel的实现
- FileChannel:从文件中读写数据
- DatagramChannel:通过UDP读写网络中的数据
- SocketChannel:通过TCP读写网络中的数据
- ServerSocketChannel:监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel
Scatter/Gather
- 分散(scatter)从Channel中读取是指在读操作时将读取的数据写入多个buffer中。因此,Channel将从Channel中读取的数据“分散(scatter)”到多个Buffer中
- 聚集(gather)写入Channel是指在写操作时将多个buffer的数据写入同一个Channel,因此,Channel 将多个Buffer中的数据“聚集(gather)”后发送到Channel
通过这样的方式可以方便数据的读取,当你想要获取整个数据的一部分的时候,通过这种方式可以很快的获取数据
1 | ByteBuffer header = ByteBuffer.allocate(128); |
read()方法按照buffer在数组中的顺序将从channel中读取的数据写入到buffer,当一个buffer被写满后,channel紧接着向另一个buffer中写
transferFrom、transferTo
实现两个Channel之间相互连接,数据传递
1 | public static void trainforNio() { |
transferFrom、transferTo作用是一样的,只是一个是tochannal调用,一个是fromchannnal调用
在实际的运用中可能存在源通道的剩余空间小于 count 个字节,则所传输的字节数要小于请求的字节数
在SoketChannel的实现中,SocketChannel只会传输此刻准备好的数据(可能不足count字节)。因此,SocketChannel可能不会将请求的所有数据(count个字节)全部传输到FileChannel中
看官一定要仔细看我栗子中的注释
Buffer
Buffer是一个缓存区,其会将Channel中的数据存储起来
Buffer的实现
- ByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
- MappedByteBuffer
capacity,position,limit
在讲解该主题之前,首先要明白读模式和写模式,无论是Channel还是Buffer都存在这两种模式,要理解这两种模式,第一步要明确主题是哪一个,是Channel还是Buffer。举个栗子,主角是Channel,读模式的含义就是从Buffer中获取数据,写模式就是将数据写入Buffer,对于Buffer则是相反。搞清楚这一点,理解下面的就要相对清楚一点
- capacity:作为一个内存块,其就代表了当前Buffer能最多暂存多少数据量,存储的数据类型则是根据上面的Buffer对象类型,一旦Buffer满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据
- position:代表当前数据读或写处于那个位置。读模式:被重置从0开始,最大值可能为capacity-1或者limit-1,写模式:被重置从0开始,最大值为limit-1
- limit:最多能往Buffer里写多少数据,limit大小跟数据量大小和capacity有关,读模式:数据量>capacity时,limit=capacity,数据量=capacity时,limit=capacity,数据量<capacity时,limit<capacity,写模式:limit<=capacity
1 | import java.io.IOException; |
Buffer读写数据步骤
- 写入数据到Buffer(fileChannel.read(buf))
- 调用flip()方法(buf.flip())
- 从Buffer中读取数据(buf.get())
- 调用clear()方法或者compact()方法(buf.compact())
Buffer方法
flip():将Buffer读模式切换到写模式,并且将position制为0
clear():清空整个缓冲区
compact():只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面
allocate(1024):初始化Buffer,设定的值就决定capacity值的大小
rewind():将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素(byte、char等)
mark()与reset():通过调用Buffer.mark()方法,可以标记Buffer中的一个特定position。之后可以通过调用Buffer.reset()方法恢复到这个position
equals():当满足下面三个条件时,两个Buffer才是相等
- 有相同的类型(byte、char、int等)
- Buffer中剩余的byte、char等的个数相等
- Buffer中所有剩余的byte、char等都相同
只比较的是剩余的数据
compareTo():满足下列条件,则认为一个Buffer“小于”另一个Buffer
- 第一个不相等的元素小于另一个Buffer中对应的元素
- 所有元素都相等,但第一个Buffer比另一个先耗尽(第一个Buffer的元素个数比另一个少)
Selector
Selector允许单线程处理多个 Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便
大致流程
当您调用一个选择器对象的 select( )方法时,相关的键会被更新,用来检查所有被注册到该选择器的通道。您可以获取一个键的集合,从而找到当时已经就绪的通道。通过遍历这些键,您可以选择出每个从上次您调用 select( )开始直到现在,已经就绪的通道
选择器(Selector)的特点
1 | public abstract class Selector |
- 选择器类管理着一个被注册的通道集合的信息和它们的就绪状态。通道是和选择器一起被注册
的,并且使用选择器来更新通道的就绪状态。当这么做的时候,可以选择将被激发的线程挂起,直
到有就绪的的通道 - 不能注册已经关闭的selectableChannel
- 通过调用一个自定义的 SelectorProvider对象的 openSelector( )方法来创建一个 Selector 实例也是可行的。您可以通过调用 provider( )方法来决定由哪个 SelectorProvider 对象来创建给定的 Selector 实例
通道(Channel)的特点
1 | public abstract class SelectableChannel |
- 继承SelectableChannel
- 一个channel可以注册到多个selector中
- 一个selector中同一个channel只能有一个
- 通道被注册前,要非阻塞模式
- 支持Connect、Accept、Read、Write四种可选择操作事件,但并不是所有的SelectableChannel都存在以上四类,可以通过validOps()获取可以使用的操作事件集合
- 如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来
- 任何一个通道和选择器的注册关系都被封装在一个 SelectionKey 对象中。 keyFor( )方法将
返回与该通道和指定的选择器相关的键。如果通道被注册到指定的选择器上,那么相关的键将被返
回。如果它们之间没有注册关系,那么将返回 null
选择键(SelectionKey)的特点
1 | package java.nio.channels; |
- 封装了特定的通道与特定的选择器的注册关系
- 一个 SelectionKey 对象包含两个以整数形式进行编码的byte掩码:一个用于指示那些通道/
选择器组合体所关心的操作(instrest 集合),另一个表示通道准备好要执行的操作( ready 集合)
当终结注册关系时
当应该终结这种关系的时候,可以调用 SelectionKey对象的 cancel( )方法。可以通过调用 isValid( )方法来检查它是否仍然表示一种有效的关系。当键被取消时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效。当再次调用 select( )方法时(或者一个正在进行的 select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。通道会被注销,而新的SelectionKey 将被返回
当通道关闭时
当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。当
选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取
消)。一旦键被无效化,调用它的与选择相关的方法就将抛出 CancelledKeyException
interest 集合
当前的 interest 集合可以通过调用键对象的 interestOps( )方法来获取
最初,这应该是通道被注册时传进来的值。这个 interset 集合永远不会被选择器改变,但您可以通过调用 interestOps( )方法并传入一个新的byte掩码参数来改变它。 interest 集合也可以通过将通道注册到选择器上来改变(实际上使用一种迂回的方式调用 interestOps( )),就像 4.1.2 小节中描的那样。当相关的 Selector 上的 select( )操作正在进行时改变键的 interest 集合,不会影响那个正在进行的选择操作。所有更改将会在 select( )的下一个调用中体现出来
ready集合
可以通过调用键的 readyOps( )方法来获取相关的通道的已经就绪的操作。 ready 集合是 interest
集合的子集,并且表示了 interest 集合中从上次调用 select( )以来已经就绪的那些操作
SelectionKey 类定义了四个便于使用的布尔方法来为您测试这些byte值: isReadable( ), isWritable( ), isConnectable( ), 和 isAcceptable( )
SelectionKey 对象包含的 ready 集合与最近一次选择器对所注册的通道所作的检查相同。而每个单独的通道的就绪状态会同时改变
附加的对象
可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:
1 | selectionKey.attach(theObject); |
还可以在用register()方法向Selector注册Channel的时候附加对象。如:
1 | SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject); |
如果选择键的存续时间很长,但您附加的对象不应该存在那么长时间,请记得在完成后清理附件。否则,您附加的对象将不能被垃圾回收,您将会面临内存泄漏问题
总体上说, SelectionKey 对象是线程安全的,但知道修改 interest 集合的操作是通过 Selector 对象进行同步的是很重要的。这可能会导致 interestOps( )方法的调用会阻塞不确定长的一段时间。选择器所使用的锁策略(例如是否在整个选择过程中保持这些锁)是依赖于具体实现的。幸好,这种多元处理能力被特别地设计为可以使用单线程来管理多个通道。被多个线程使用的选择器也只会在系统特别复杂时产生问题。
选择过程
1 | public abstract class Selector |
已注册的键的集合
与选择器关联的已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过
keys( )方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话
将引 java.lang.UnsupportedOperationException。
已选择的键的集合
已注册的键的集合的子集。这个集合的每个成员都是相关的通道被选择器(在前一个选择操作
中)判断为已经准备好的,并且包含于键的 interest 集合中的操作。这个集合通过 selectedKeys( )方
法返回(并有可能是空的)
不要将已选择的键的集合与 ready 集合弄混了。这是一个键的集合,每个键都关联一个已经准
备好至少一种操作的通道。每个键都有一个内嵌的 ready 集合,指示了所关联的通道已经准备好的
操作
键可以直接从这个集合中移除,但不能添加
已取消的键的集合
已注册的键的集合的子集,这个集合包含了 cancel( )方法被调用过的键(这个键已经被无效
化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问
在一个刚初始化的 Selector 对象中,这三个集合都是空的。
执行步骤
已取消的键的集合将会被检查。如果它是非空的,每个已取消的键的集合中的键将从另外两
个集合中移除,并且相关的通道将被注销。这个步骤结束后,已取消的键的集合将是空的。已注册的键的集合中的键的 interest 集合将被检查。在这个步骤中的检查执行过后,对
interest 集合的改动不会影响剩余的检查过程。a.如果通道的键还没有处于已选择的键的集合中,那么键的 ready 集合将被清空,然后表示操
作系统发现的当前通道已经准备好的操作的比特掩码将被设置。b.否则,也就是键在已选择的键的集合中。键的 ready 集合将被表示操作系统发现的当前已经
准备好的操作的比特掩码更新。所有之前的已经不再是就绪状态的操作不会被清除。事实上,所有的比特位都不会被清理。由操作系统决定的 ready 集合是与之前的 ready 集合按位分离的,一旦键被放置于选择器的已选择的键的集合中,它的 ready 集合将是累积的。比特位只会被设置,不会被清理。步骤 2 可能会花费很长时间,特别是所激发的线程处于休眠状态时。与该选择器相关的键可
能会同时被取消。当步骤 2 结束时,步骤 1 将重新执行,以完成任意一个在选择进行的过程中,键
已经被取消的通道的注销。select 操作返回的值是 ready 集合在步骤 2 中被修改的键的数量,而不是已选择的键的集合中
的通道的总数。返回值不是已准备好的通道的总数,而是从上一个 select( )调用之后进入就绪状态
的通道的数量。之前的调用中就绪的,并且在本次调用中仍然就绪的通道不会被计入,而那些在前
一次调用中已经就绪但已经不再处于就绪状态的通道也不会被计入。这些通道可能仍然在已选择的
键的集合中,但不会被计入返回值中。返回值可能是 0。
为什么延迟注销
使用内部的已取消的键的集合来延迟注销,是一种防止线程在取消键时阻塞,并防止与正在进
行的选择操作冲突的优化。注销通道是一个潜在的代价很高的操作,这可能需要重新分配资源(请
记住,键是与通道相关的,并且可能与它们相关的通道对象之间有复杂的交互)。
三种select()方法
仅仅在它们在所注册的通道当前都没有就绪时,是否阻塞的方面有所不同。
- select():在没有通道就绪时将无限阻塞。一旦至少有一个已注册的通道就绪,选择器的选择键
就会被更新,并且每个就绪的通道的 ready 集合也将被更新。返回值将会是已经确定就绪的通道的
数目。正常情况下, 这些方法将返回一个零的值,因为直到一个通道就绪前它都会阻塞。 - select(long timeout):如果在您提供的超时时间(以毫秒计算)内没有通道就绪时,它将返回 0。如果一个或者多个通道在时间限制终止前就绪,键的状态将会被更新,并且方法会在那时立即返回。将超时参数指定为 0 表示将无限期等待,那么它就在各个方面都等同于使用select()
- selectNow():执行就绪检查过程,但不阻塞。如果当前没有通道就绪,它将立即返回 0
停止选择过程
wakeUp()
某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。
如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。
close()
用完Selector后调用其close()方法会关闭该Selector,且使注册到该Selector上的所有SelectionKey实例无效。通道本身并不会关闭。
interrupt()
如果睡眠中的线程的 interrupt( )方法被调用,它的返回状态将被设置。如果被唤醒的线程之后
将试图在通道上执行 I/O 操作,通道将立即关闭,然后线程将捕捉到一个异常。
例子
服务端
1 | import java.net.InetSocketAddress; |
客户端
1 | import java.io.IOException; |