介绍 Java NIO Selector
文本我们探讨java NIO引入Selector组件。
selector提供了监控多个NIO channel机制,并识别何时有一个或多个channel可用来传输数据。通过这种方式,单个线程可以用于管理多个channel,从而管理多个网络连接。
为什么使用Selector?
使用selector,让一个线程可以管理多个channel,而不是多个线程。多个线程的上下文切换对操作系统来说是昂贵的,另外每个线程也会占用内存。
因此,使用线程越少就越好。然而重要的是,现代操作系统和cpu在处理多任务上越来越好,因此多线程耗费时间会越来越少。
这里我们会演示如何通过selector使用单线程处理多个channel。还要注意,选择器不仅帮助您读取数据;它们还可以侦听传入的网络连接,并跨慢速通道写入数据。
开发说明
为了使用selector,无需任何特殊的依赖。我们需要所有类都在java.nio包中,仅需要导入相应的类。
然后,我们能使用selector注册多个channel。当任何channel上有I/O活动发生时,selector会通知我们。这就是我们如何从一个线程读取多个数据源的方法。
任何使用selector注册的channel必须是SelectableChannel类的子类。这些特殊类型channel能设置非阻塞模式。
创建Selector
可以通过Selector类的静态方法创建selector对象,其会使用系统缺省selector提供程序创建新的selector对象:
Selector selector = Selector.open();
注册SelectableChannel
为了让selector监控channel,必须使用selector注册channel。可以通过执行channel的register方法实现。但是channel被注册之前,必须被设置为非阻塞模式:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
这意味着不能使用FileChannel注册selector,因为其不能被切换至非阻塞模式,如socket channel的方式。
第一个参数是我们之前创建的selector,第二个参数定义感兴趣活动集,即通过selector监听channel感兴趣的事件。可以监听四种不同类型的事件,每种通过SelectionKey类的一个常量表示:
Connect – 当客户端尝试连接到服务器,使用SelectionKey.OP_CONNECT表示。
Accept – 当服务器端接收客户端连接,使用SelectionKey.OP_ACCEPT表示。
Read – 当服务器端准备从channel读数据,使用SelectionKey.OP_READ表示。
Write – 当服务器端准备些数据至channel,使用SelectionKey.OP_WRITE表示。
返回对象SelectionKey表示使用selector注册的channel,下面我们会涉及。
SelectionKey对象
前面我们看到,当使用selector注册channel是,会获得SelectionKey对象,其包含的数据表示通道注册的数据。一些重要属性,我们必须要很好地理解,以便能够在channel上使用selector。下面依次说明这些属性。
兴趣集
兴趣集定义了我们希望selector在channel上监控的事件集。它是一个整数值;我们可以用下面的方法得到这个信息。
首先,我们有SelectionKey的interestOps方法返回信息集。然后我们在SelectionKey中有事件常量,我们之前看过。当我们使用and与两个值时,会获得boolean值,其表示事件是否被监控:
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = interestSet & SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;
就绪集
就绪集定义channel准备好处理的事件集。它也是一个整数值;我们可以用下面的方法得到该信息。
我们已经通过SelectionKey的readyOps方法获得就绪集。与前面一样,把该值使用AND与事件常量,会获得boolean值表示channel是否准备就绪。另外一种简化方法,直接使用SelectionKey的方法实现相同目的:
selectionKey.isAcceptable();
selectionKey.isConnectable();
selectionKey.isReadable();
selectionKey.isWriteable();
获得Channel
通过SelectionKey对象访问channel很简单,直接调用channel()方法:
Channel channel = key.channel();
获取Selector
和获得channel一样,通过SelectionKey对象获得Selector对象也很容易:
Selector selector = key.selector();
附件其他对象
我们可以给SelectionKey附加其他对象。有时我们可能希望给channel一个自定义ID或附加用于跟踪的任何类型的java对象。附加对象方法很简单。下面示例展示如何附加对象,并从SelectionKey获取:
key.attach(Object);
Object object = key.attachment();
另外,我们也可以在channel注册时附加对象。仅需要把对象作为register方法的第三个参数:
SelectionKey key = channel.register(
selector, SelectionKey.OP_ACCEPT, object);
channel 键选择
到目前为止,我们已经看了如何创建Selector,把其注册至channel并检测其返回SelectionKey对象。这仅是一般过程,现在我们完成后续过程,选择前面提到的就续集,使用select方法:
int channels = selector.select();
该方法会阻塞,直到至少一个channel就绪进行操作。返回的integer表示channel就绪进行操作的键的数量。接下来,我们通常返回一组选择键进行处理:
Set<SelectionKey> selectedKeys = selector.selectedKeys();
获得集合是SelectionKey对象,每个键代表已注册channel,其准备进行操作。之后,通常迭代该集合的每个键,获得channel并执行任何感兴趣的操作。
在channel的生命周期中,当键出现在不同事件的就续集中时,可以多次选择它。这就是为什么我们必须有一个连续的循环来捕获和处理channel发生的事件。
完整示例
为了巩固我们前面所学的知识,下面搭建一个完整的客户端-服务器程序示例。
为了更容易测试我们的代码,示例程序包括echo服务器和echo客户端。客户端连接至服务器,然后启动发送消息。服务器返回消息给相应发送消息的客户端。
当服务器接收到特定消息时,如end,作为结束通讯标志,关闭与客户端的连接。
服务器端
这里是EchoServer.java代码:
public class EchoServer {
private static final String POISON_PILL = "POISON_PILL";
public static void main(String[] args) throws IOException {
Selector selector = Selector.open();
ServerSocketChannel serverSocket = ServerSocketChannel.open();
serverSocket.bind(new InetSocketAddress("localhost", 5454));
serverSocket.configureBlocking(false);
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer buffer = ByteBuffer.allocate(256);
while (true) {
selector.select();
Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> iter = selectedKeys.iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isAcceptable()) {
register(selector, serverSocket);
}
if (key.isReadable()) {
answerWithEcho(buffer, key);
}
iter.remove();
}
}
}
private static void answerWithEcho(ByteBuffer buffer, SelectionKey key)
throws IOException {
SocketChannel client = (SocketChannel) key.channel();
client.read(buffer);
if (new String(buffer.array()).trim().equals(POISON_PILL)) {
client.close();
System.out.println("Not accepting client messages anymore");
}
buffer.flip();
client.write(buffer);
buffer.clear();
}
private static void register(Selector selector, ServerSocketChannel serverSocket)
throws IOException {
SocketChannel client = serverSocket.accept();
client.configureBlocking(false);
client.register(selector, SelectionKey.OP_READ);
}
public static Process start() throws IOException, InterruptedException {
String javaHome = System.getProperty("java.home");
String javaBin = javaHome + File.separator + "bin" + File.separator + "java";
String classpath = System.getProperty("java.class.path");
String className = EchoServer.class.getCanonicalName();
ProcessBuilder builder = new ProcessBuilder(javaBin, "-cp", classpath, className);
return builder.start();
}
}
通过调用静态open方法创建Selector对象。然后通过静态open方法创建channel,ServerSocketChannel的实例。这是因为ServerSocketChannel是可选择的,适合面向流的侦听套接字。
然后绑定至一个端口,还记得之前说的,注册可选择的channel至Selector,必须先设置为非阻塞模式。所以接下来设置并注册channel至Selector。
在这个阶段,我们不需要channel的SelectionKey实例,所以我们先不用提及。
java nio使用面向缓存模式,而不是面向流模式。所以socket通信通常通过读写buffer来进行。因此,我们创建一个新的ByteBuffer,服务器端利用其进行读写。其初始化为256字节,当然可以是任意值,这依赖我们打算传输数据的大小。
最后,我们执行选择过程。我们准备就绪channel,返回其选择键,迭代所有的键并执行每个就绪channel的操作。
我们把这个过程放在无限循环中,因为无论是否有活动事件都需要保持服务器端处于运行状态。ServerSocketChannel仅能处理的操作是ACCEPT操作。当我们接受从客户端的连接时,则会获得SocketChannel对象,并在其上进行读写操作。我们设置其为非阻塞模式,并将其注册至Selector监控READ操作。
在随后的选择中,这个新channel将变为就绪读状态。我们检索它并将其内容读入缓冲区。作为echo服务器,我们必须将这些内容写回客户端。
当我们想要写入处于读取状态的缓冲区时,必须调用flip()方法。最后,通过flip方法设置缓冲区至写模式。
这里定义的start方法,是为了服务器端程序在单元测试中可以作为独立运行。
客户端程序
请看EchoClient.java代码:
public class EchoClient {
private static SocketChannel client;
private static ByteBuffer buffer;
private static EchoClient instance;
public static EchoClient start() {
if (instance == null)
instance = new EchoClient();
return instance;
}
public static void stop() throws IOException {
client.close();
buffer = null;
}
private EchoClient() {
try {
client = SocketChannel.open(new InetSocketAddress("localhost", 5454));
buffer = ByteBuffer.allocate(256);
} catch (IOException e) {
e.printStackTrace();
}
}
public String sendMessage(String msg) {
buffer = ByteBuffer.wrap(msg.getBytes());
String response = null;
try {
client.write(buffer);
buffer.clear();
client.read(buffer);
response = new String(buffer.array()).trim();
System.out.println("response=" + response);
buffer.clear();
} catch (IOException e) {
e.printStackTrace();
}
return response;
}
}
客户端程序比服务器端简单。这里使用单例模式,通过静态start方法内实例化,其内部调用私有的构造函数。
在私有构造函数中,在服务器端相同的主机和端口上打开连接。
下面创建缓冲区进行读写。我们有sendMessage方法,它读取我们传递给它的任何字符串,并将其封装到字节缓冲区中,字节缓冲区通过通道传输到服务器。最后我们从客户端channel读取以获得服务器发送的消息。我们返回这个消息作为echo。
单元测试
我们定义测试类EchoTest,我们打算创建一个测试用例,启动服务器,非服务器发送消息,客户端接收并显示消息。最后停止服务器。
public class EchoTest {
Process server;
EchoClient client;
@Before
public void setup() throws IOException, InterruptedException {
server = EchoServer.start();
client = EchoClient.start();
}
@Test
public void givenServerClient_whenServerEchosMessage_thenCorrect() {
String resp1 = client.sendMessage("hello");
String resp2 = client.sendMessage("world");
assertEquals("hello", resp1);
assertEquals("world", resp2);
}
@After
public void teardown() throws IOException {
server.destroy();
EchoClient.stop();
}
}
总结
本文我们讨论了java nio selector组件。