在NIO也即是新IO也即是非阻塞IO中,新增了通道Channel和多路复选器selector的概念,通道表示到能够执行 I/O 操作的实体(如文件和套接字)的连接;多路复选器用于多路复用、非阻塞 I/O 操作。同时还新增了数据容器类Buffer;(例如ByteBuffer),通道只能从Buffer类中读取数据,也只能从该类中获取数据,使用数据容器类,可以一次传输多个字节数据而不是像OIO中一样一个字节一个字节的传输,可以更充分的利用带宽。
本文主要介绍NIO中比较难理解的多路复选器与选择键相关知识,并发使用案例实践说明。
先引入服务端demo,该demo向客户端输出ASCII的可打印字符,客户端以每行72个循环打印输出。
package javanio.nionet;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class NioServerTest2 {
public static void main(String[] args) {
Selector selector;
ServerSocketChannel serverSocketChannel;
byte[] rotation=new byte[95*2];
for(byte i=' ';i<='~';i++) {
rotation[i-' ']=i;
rotation[i+95-' ']=i;
}
try {
//1、获取通道
serverSocketChannel=ServerSocketChannel.open();
//2、将该通道与对应的端口绑定
serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 5557));
selector=Selector.open();
//3、注意通道默认的是阻塞模式,我们要实现非阻塞模式,客户端没有连接时也会及时返回;需要调用以下方法:
serverSocketChannel.configureBlocking(false);
//4、向选择器中注册服务器端通道,OP_ACCEPT表示是否可连接
SelectionKey key=serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
} catch (IOException e) {
System.out.println("无法获取到默认的选择器!");
return;
}
/*
* 保证服务端始终运行的循环
*/
while(true) {
try{//5、使用多路复选器的方法判断是否有连接请求
//该方法是一个阻塞方法,仅在至少选择一个通道、调用此选择器的 wakeup() 方法,
//或者当前的线程已中断(以先到者为准)后此方法才返回。
selector.select();
}catch (IOException e) {
e.printStackTrace();
System.out.println("此刻没有连接已经准备好!");
break;
}
//6、获取到选择键的集合
Set<SelectionKey>readKeys=selector.selectedKeys();
Iterator<SelectionKey>iterator=readKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = (SelectionKey) iterator.next();
iterator.remove();
try {//7、如果获取到的是可连接状态
if (key.isAcceptable()) {
ServerSocketChannel server=(ServerSocketChannel) key.channel();
//8、在前面3步骤中已经将服务端socket通道设置为非阻塞模式,在没有连接时会直接返回null;
SocketChannel client=server.accept();
//9、设置对等客户端socket为非阻塞模式
client.configureBlocking(false);
//10、将会客户端注册到多路复选器中
SelectionKey key2=client.register(selector, SelectionKey.OP_WRITE);
//11、定义缓冲字节存储数据
ByteBuffer buffer=ByteBuffer.allocate(74);
buffer.put(rotation,0,72);
buffer.put((byte)'\r');
buffer.put((byte)'\n');
buffer.flip();
//12、以附件的形式将数据绑定到客户端
key2.attach(buffer);
}else if (key.isWritable()) {
//13、如果获取到的键是可写状态的,那么就可读取到键的附件
SocketChannel client=(SocketChannel) key.channel();
ByteBuffer buffer=(ByteBuffer) key.attachment();
if (!buffer.hasRemaining()) {
//将缓冲区的位置position置为0;
buffer.rewind();
int first=buffer.get();
buffer.rewind();
int position=first-' '+1;
buffer.put(rotation, position, 72);
buffer.put((byte)'\r');
buffer.put((byte)'\n');
buffer.flip();
}
System.out.println("1");
client.write(buffer);
}
} catch (IOException e) {
key.cancel();
try {
key.channel().close();
} catch (IOException e2) {
}
}
}
}
}
}
相应的客户端不断输出服务端发送来的数据,代码如下:
package javanio.nionet;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.net.UnknownHostException;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.SocketChannel;
import java.nio.channels.WritableByteChannel;
public class NioClientTest2 {
public static void main(String[] args) {
try {
//建立Socket的方式会有一些不同,你需要掌握他们之间的区分!
//socket = new Socket();
SocketAddress address=new InetSocketAddress("127.0.0.1", 5557);
//获取客户端套接字(tcp连接);默认是阻塞模式
SocketChannel client=SocketChannel.open(address);
//新建缓冲区
ByteBuffer buffer=ByteBuffer.allocate(74);
//如何将从服务器获取到的数据写入到缓冲区?
WritableByteChannel out=Channels.newChannel(System.out);
//
while (client.read(buffer)!=-1) {
//需要做的是将缓冲区进行回绕再将其排出
buffer.flip();
//利用可写字节通道将服务器端送达的数据打印到控制台
out.write(buffer);
//缓冲区排干之后必须将其清理干净,以被下一次使用;
buffer.clear();
}
return;
} catch (UnknownHostException e) {
System.out.println("无法获取的主机!");
} catch (IOException e) {
System.out.println("无法与指定端口进行连接!");
}
}
}
通过启动客户端与服务端demo进行本文主要知识点的学习探讨。
多路复选器Selector类
Selector类提供open()方法创建选择器,该方法将使用系统的默认选择器提供者创建新的选择器。也可通过调用自定义选择器提供者的openSelector())方法来创建选择器。通过选择器的close()方法关闭选择器之前,它一直保持打开状态。
获取选择器:Selector selector=Selector.open()
该类的结构如图:
本篇我们不深究源码,更关注使用。得到选择器后,客户端通道或是服务端通道都可以通过其注册方法register()在该选择器中注册,每次向选择器注册通道时就会创建一个选择键对象,通过选择键来表示可选择通道到选择器的注册,每个选择键对象还包含一个标识表示对应通道所支持的操作。
SelectionKey key=serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
选择器维护了三种选择键集合:
-
键集 包含的键表示当前所有通道到此选择器的注册。此集合由
keys()
方法返回。 -
已选择键集 是指拥有就绪事件(可读,可写等)的通道对应键的集合。此集合由
selectedKeys()
方法返回。已选择键集始终是键集的一个子集。 - 已取消键集 是指已被取消但其通道尚未注销的键的集合,在选择器的下一次select()操作后才会将已经取消的键移除。不可直接访问此集合。已取消键集始终是键集的一个子集。
通过多路复选器,在处理并发客户端请求时,可以同时接收多个请求,请求无需排队等候处理,实现单个线程同时处理多个请求。
这里存在这样一个问题,已选择键集(selectedKeys()返回的)只会在select()操作时添加键,不能自动删除键,因此选择器在轮询时,每次处理完一个键对应的事件时,必须将此键从已选择键集中删除,如果不删除,第二次轮询时,通道对应事件可能已经失效,但是选择器依然轮询到该事件并做相关处理,显然不符合我们的期望。
//6、获取到选择键的集合
Set<SelectionKey>readKeys=selector.selectedKeys();
Iterator<SelectionKey>iterator=readKeys.iterator();
while (iterator.hasNext()) {
SelectionKey key = (SelectionKey) iterator.next();
//6.1 remove后,对应的键不会再出现在ready键集中
iterator.remove();
******************************
}
SelectionKey类
此类定义了所有已知的操作集位 (operation-set bit),但是给定的通道具体支持哪些位则取决于该通道的类型。[SelectableChannel
]的每个子类都定义了validOps()) 方法,该方法返回的集合恰好标识该通道支持的操作。试图设置或测试某个键的通道所不支持的操作集位将导致抛出相应的运行时异常。以ServerSocketChannel为例,其类结构图如下:
通过查阅其validOps()方法可见,其仅支持OP_ACCEPT标识;而与其相对的SocketChannel通道支持三种操作符:
SelectionKey类,选择键包含两个表示为整数值的操作集,操作集合中的每一位都表示该键所对应通道所支持的一类可选择操作。
-
interest 集合 兴趣集是指通过选择器监听通道时,其感兴趣的的事件集合。创建该键时使用给定的值初始化 interest 集合;之后可通过
interestOps(int)
方法对其进行更改。 - ready 集合 就续集是指选择器监听通道的已经就绪的事件的集合,该集合数小于等于兴趣集大小,该集合只能通过选择器selector的select()操作向其中添加键,不能直接更新,创建该键时 ready 集合被初始化为零;可以在之后的选择操作中通过选择器对其进行更新,但不能直接更新它。
由上图中SocketChannel支持三种操作,validOps()方法返回13;各操作表示的意义如下表:
运行demo,开启一个客户端,debug逐步调试结果如下:
选择器select()操作。。。。。。
已选择键就绪操作位:16
选择键对应的操作:16
服务端可连接操作。。。。。。
选择器select()操作。。。。。。
已选择键就绪操作位:4
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
选择器select()操作。。。。。。
已选择键就绪操作位:4
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
选择器select()操作。。。。。。
已选择键就绪操作位:4
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
在关闭了客户端后,向客户端写入数据会报输入输出流异常,而我们在异常处理中的处理方法是:
catch (IOException e) {
//14 请求取消此键的通道到其选择器的注册,在下一次select()操作时将其移除;
key.cancel();
try {
//15 关闭键对应的通道
key.channel().close();
} catch (IOException e2) {
}
}
通过这种处理之后,选择器将不会轮询到该通道对应的键集,否则,就算将客户端关闭,服务端轮询时依旧认为通道连接正常,相应的键也可以被选择器重新选择,再次轮询时也是可以进入else if (key.isWritable()) {}分支,也就是说服务端无法主动识别客户端是否正常连接,需要程序员通过程序手动控制。
当我将以下代码注释掉,无数据输出确保不会发生输入输出错误时,关闭客户端连接,服务端依旧反复输出以下结果:
/*if (!buffer.hasRemaining()) {
//将缓冲区的位置position置为0;
buffer.rewind();
int first=buffer.get();
buffer.rewind();
int position=first-' '+1;
buffer.put(rotation, position, 72);
buffer.put((byte)'\r');
buffer.put((byte)'\n');
buffer.flip();
}*/
选择器select()操作。。。。。。
已选择键就绪操作位:4
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
如果细心的朋友可能会注意到“服务端可连接操作”仅在初次有连接时输出一次,在同一连接的多次轮询(例如本例中不断的向客户端输出数据)中,只输出一次,也即是不会在每次的select()操作中选中IS_ACCEPTED键 ,只有新的连接进来时才会被重新选中,可见该事件底层处理与其它事件的选择逻辑(READ等事件)是不同的,remove()该键后对同一连接不会再次被select()。这可以通过在断开一个客户端连接,服务端阻塞之后,再开启客户端得到验证:
//1
选择器select()操作。。。。。。
已选择键就绪操作位:4
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
//2
选择器select()操作。。。。。。
已选择键就绪操作位:4
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
//3
选择器select()操作。。。。。。
已选择键就绪操作位:16
已选择键就绪操作位:4
选择键对应的操作:16
服务端可连接操作。。。。。。
选择键对应的操作:4
客户端可写操作开始。。。。。。。
结束~
。。。。
在2处断开连接之后,服务端处于阻塞状态,当再次启动客户端时,如3所示,重新输出了“服务端可连接操作。。。。。。”,已选择键集中会存在两个键(一个表示服务器端一个表示客户端),也即是说明有新连接时,服务端键IS_ACCEPTED才会被重新选中。