JAVA 之 NIO
由于最近需要实战到 NIO 的有关代码,而自己之前所学的东西也差不多忘光了,因此重新捡起了 NIO,复习的同时也将其写成博客,促进自己对其的理解。
NIO 是个什么东西?为什么 IO 会比 NIO 快?
答:IO 靠字符和字节传输,速度慢。NIO 靠 Buffer 一块一块传输,速度快!与此同时,NIO 还加入了多线程控制机制,即:一个 NIO 流可以同时传输多个块,实现异步传输。
一、核心组成
- Channels
- Buffers
- Selectors
Channel 和 Buffer
基本上,所有的 IO 在NIO 中都从一个Channel 开始。Channel 有点象流。 数据可以从Channel读到Buffer中,也可以从Buffer 写到Channel中。
Selector
Selector允许单线程处理多个 Channel。要使用Selector,得向Selector注册Channel,然后调用它的select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件,事件的例子有如新连接进来,数据接收等。
下面,我们来认识一下这三个核心的组件。
二、Channel
Channel 是什么
一Java NIO的 Channel 类类似流,但又比流高级:
- Channel 可以异步地读写
- 从 Channel 中读写取数据时,总要先读或写到一个 Buffer ,然后再进行各自操作
Channel 的实现
一部分重要的通道实现:
- FileChannel:重文件中读取数据
- DatagramChannel:能通过UDP读写网络中的数据
- SocketChannel:能通过TCP读写网络中的数据
- ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel
简单调用示例:
RandomAccessFile tFile = new RandomAccessFile("example","rw"); FileChannel fileChannel = tFile.getChannel(); ByteBuffer buf = ByteBuffer.allocate(48); int bytesRead = fileChannel.read(buf);//注意,此处是 int 型数字,指读到的数据大小 while (bytesRead != -1) { System.out.println("Read " + bytesRead); ...//其余部分将在 Buffer 中讲解 }
三、Buffer
Buffer 是什么
一通俗地理解,Buffer 就是一个存放数据的内存块,NIO 提供了一些访问这块内存块的方法,以此来对数据进行传输操作。Buffer 的类型
- ByteBuffer
- MappedByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
Buffer 的基本用法
Buffer 读写数据一般遵循以下四个步骤:
- Buffer 的分配
- 写入数据到Buffer
- 调用
flip()
方法 - 从Buffer中读取数据
- 调用
clear()
方法或者compact()
方法
我们分别以这五个操作来介绍 Buffer(但是 Buffer 并不只提供这几个方法,还有 mark() 和 reset() 等,由于不是主要操作,所以我们不细讲)
Buffer 的分配
XXXBuffer buf = XXXBuffer.allocate(48);
向 Buffer 中写数据
写数据到Buffer有两种方式:
从 Channel 写到 Buffer
int bytesRead = inChannel.read(buf); //注意!方法名是 read。是读 inChannel 中的数据
通过 Buffe r的 put() 方法写到 Buffer 里
buf.put(127);//put方法还有很多版本,以不同的方式把数据写入到Buffer中。例如,写到一个指定的位置,或者把一个字节数组写入到Buffer
调用 filp() 方法
flip 方法将 Buffer 从写模式切换到读模式(将访问 Buffer 中数据的指针移动到最初写进 Buffer 中的数据的位置)
从 Buffer 中读取数据
与从 Buffer 中写数据一样,读数据也有两种方式:
从 Buffer 读取数据到 Channel
int bytesWritten = inChannel.write(buf);//注意!方法名是 write,buffer 是主语
使用 get() 方法从 Buffer 中读取数据
byte aByte = buf.get();//get方法也有很多版本,允许你以不同的方式从Buffer中读取数据。例如,从指定position读取,或者从Buffer中读取数据到字节数组
调用
clear()
方法或者compact()
方法 操作 Buffer 快其实就像在操作定长链表,而这个两个方法就像是对链表中指针的操作。
clear() 方法是将访问 Buffer 块的指针指向了 0 号位置,为下一次写操作做准备。我们会以为这个操作会将 Buffer 块中的数据清空,等待新数据赋值进来,其实并没有,当再次写入时只不过是重新赋值罢了。
而在上一步的读取数据操作时,我们可能会漏访问掉一些元素,那么这个时候 compact() 就派上用场了,compact()方法将所有未读的数据拷贝到Buffer起始处。然后将访问指针设到最后一个未读元素正后面,为下次写入操作做准备。
注意点
在执行 5 和 7 操作之前,均必须执行 filp() 或 clear() 和 compact() 方法!
四、Selector
由于单独讲解 Selector 不能很好地讲清它的作用,因此我们将结合 ServerSocketChannel(服务端) 和 SocketChannel (客户端)一起来讲解它的作用
Selector 是什么
Selector 翻译过来是:选择器,很好理解,是一个用来选择 Channel 的组件。在 Java NIO 中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备。它就是你注册对各种 I/O 事件感兴趣的地方,而且当这些事件发生时,它会告诉你所发生的事件。通过 Selector,一个单独的线程可以管理多个channel,从而管理多个网络连接。
创建一个 Selector 是第一件事:
Selector selector = Selector.open();
如何向 Selector 注册通道呢?
答案是:对不同的通道对象调用
register()
方法,以便注册我们对这些对象中发生的 I/O 事件的兴趣。 首先,我们创建一个 ServerSocketChannel 对象,用于向 Selector 注册通道
ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking( false );//将 ServerSocketChannel 设置为 非阻塞的 。否则异步 I/O 就不能工作 //将 ssc 绑定到给定的端口 ServerSocket ss = ssc.socket(); InetSocketAddress address = new InetSocketAddress( ports[i] ); ss.bind( address );
对象创建好了,我们调用
register
方法将其注册给 Selector。//监听 accept 事件,也就是在新的连接建立时所发生的事件。 //SelectionKey 代表这个通道在此 Selector 上的这个注册信息, Selector 通过该 //SelectionKey 来通知你某个传入事件。关于 SelectionKey 将在下面细讲。 SelectionKey key = ssc.register( selector, SelectionKey.OP_ACCEPT );
注意:
OP_ACCEPT
(接收就绪)适用于ServerSocketChannel
(服务端)的唯一事件类型! 对于其他对象,如果你不止对一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,
如:
SelectionKey key = channel.register(selector,Selectionkey.OP_READ|SelectionKey.OP_WRITE);
穿插点:SelectionKey 知识
SelectionKey 中的属性:
interest集合
//可以通过该值判断有无感兴趣的操作 int interestSet = selectionKey.interestOps(); boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT; //其他值也是类似的
ready集合
int readySet = selectionKey.readyOps();//readySet是Selection.xxx 的几个常量的值
Channel
Selector
附加的对象(可选)
可以在用register()方法向Selector注册Channel的时候附加对象。如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
注册完之后如何使用 Selector 来接收信息呢?
答案是:内部循环。等待注册事件的发生。
使用
Selectors
的几乎每个程序都像下面这样使用内部循环:while (true) { try{ //这个方法会阻塞,直到至少有一个已注册的事件发生。当一个或者更多的事件发生时, select() //方法将返回所发生的事件的数量。 int num = selector.select(); //它返回发生了事件的 SelectionKey 对象的一个集合 Set selectedKeys = selector.selectedKeys(); Iterator it = selectedKeys.iterator(); //通过迭代 SelectionKeys 并依次处理每个 SelectionKey 来处理事件 while (it.hasNext()) { SelectionKey key = (SelectionKey)it.next(); switch (key.readyOps()) { case SelectionKey.OP_ACCEPT: ... break; case SelectionKey.OP_READ: ... break; default: ... break; } it.remove(); } }catch(){ } }
接收完信息之后服务端如何处理信息呢?
以上的操作都是服务端的操作,接下来,该排到客户端出马了!
执行第三步的方法之后,我们知道这个服务器套接字上有一个传入连接在等待,所以可以安全地接受它(不用担心
accept()
操作会阻塞):ServerSocketChannel ssc = (ServerSocketChannel)key.channel(); SocketChannel sc = ssc.accept();//客户端的连接请求,服务端将其接收下来
下一步是将新连接的
SocketChannel
配置为非阻塞的。而且由于接受这个连接的目的是为了读取来自套接字的数据,所以我们还必须将SocketChannel
注册到Selector
上,如下所示:sc.configureBlocking( false ); //将客户端的读取操作(对象)也注册到 Selector 上,这样 Selector 就同时监听两个对象了 SelectionKey newKey = sc.register( selector, SelectionKey.OP_READ );
接收完信息之后就可以溜之大吉了吗?
回答是:否!
我们必须首先将处理过的
SelectionKey
从选定的键集合中删除。如果我们没有删除处理过的键,那么它仍然会在主集合中以一个激活的键出现,这会导致我们尝试再次处理它。我们调用迭代器的remove()
方法来删除处理过的SelectionKey
:it.remove(); //之后方可跳出第三步的代码中的主循环
当客户端发出连接操作时,我们又回到了第三步的主循环中,对读操作进行处理。
总结
本篇内容对 JAVA NIO 的一些常用工具组件进行了讲解,也对各自的操作进行了简要的介绍,并在最后一小节,Selector 的介绍中实战了服务端与客户端的部分代码,希望对你有所帮助。如果博客中有所缺漏或者错误,欢迎骚扰指正。
> 注:Java 的 NIO 不只有上面提到的那些组件,还有 datagram 和 pipe 等。由于不常用,所以没有在这里进行讲解,《疯狂 Java 讲义》一书中也有对这些组件的介绍,感兴趣的童鞋可以自己去查阅。