Java 7之异步I/O第4篇 - 异步I/O操作之选择器

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并判断是否为读写事件做好准备。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。仅用单个线程来处理多个Channels的好处是,只需要更少的线程来处理通道。事实上,可以只用一个线程处理所有的通道。对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。

在真正使用选择器时,涉及到的主要类有3个:

  • 选择器(Selector):选择器类管理着一个被注册通道集合的信息和它们的就绪状态。通道和选择器一起被注册的,并且使用选择器来更新通道的就绪状态。      
  • 可选择通道(SelectableChannel):它是所有支持就绪检查的通道类的父类。FileChannel 对象不是可选择的,因为它没有继承 SelectableChannel。所有 socket 通道都是可选择的,包括从管道(Pipe)对象的中获得的通道。只有 SelectableChannel 可以被注册到 Selector 对象上。一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次。       
  • 选择键(SelectionKey):选择键封装了特定的通道与特定的选择器的注册关系。选择键对象被 SelectableChannel.register() 返回并提供一个表示这种注册关系的标记。选择键包含了两个比特集(以整数的形式进行编码),指示了该注册关系所关心的通道操作,以及通道已经准备好的操作。
先来看一下SelectableChannel类的源代码,如下:

public abstract class SelectableChannel extends AbstractChannel implements Channel {
    public abstract SelectionKey register(Selector sel, int ops)    throws ClosedChannelException;
    public abstract SelectionKey register(Selector sel, int ops, Object att)    throws ClosedChannelException;
    public abstract boolean isRegistered();
    public abstract SelectionKey keyFor(Selector sel);
    public abstract int validOps();
    public abstract void configureBlocking(boolean block) throws IOException;
    public abstract boolean isBlocking();
    public abstract Object blockingLock();
}

调用register() 方法会将一个非阻塞通道注册到一个选择器上。如果试图注册一个处于阻塞状态的通道,register() 将抛出未检查的 IllegalBlockingModeException 异常。此外,通道一旦被注册,就不能回到阻塞状态。试图这么做的话,将在调用 configureBlocking() 方法时将抛出 IllegalBlockingModeException 异常。并且,理所当然地,试图注册一个已经关闭的 SelectableChannel 实例的话,也将抛出 ClosedChannelException 异常,就像方法原型指示的那样。通道在被注册到一个选择器上之前,必须先设置为非阻塞模式(通过调用configureBlocking(false))。如下:

channel.configureBlocking(false);// 设置为非阻塞模式
SelectionKey key = channel.register(selector,Selectionkey.OP_READ);// 调用方法进行

与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。

register()方法的第二个参数可以指定监听四种不同类型的事件,这四种事件用SelectionKey的四个常量来表示,如下:

    public static final int OP_READ;
    public static final int OP_WRITE;
    public static final int OP_CONNECT;
    public static final int OP_ACCEPT;
具体的解释如下:

  1. SelectionKey.OP_CONNECT     某个channel成功连接到另一个服务器称为“连接就绪”
  2. SelectionKey.OP_ACCEPT         一个server socket channel准备好接收新进入的连接称为“接收就绪”
  3. SelectionKey.OP_READ             一个有数据可读的通道可以说是“读就绪”
  4. SelectionKey.OP_WRITE     等待写数据的通道可以说是“写就绪”

通道触发了一个事件意思是该事件已经就绪。如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

下面来看一下SelectionKey抽象类中定义的方法,如下:

public abstract class SelectionKey {
    public abstract SelectableChannel channel();// 获取Channel对象
    public abstract Selector selector();  // 获取Selector对象
    public abstract void cancel();
    public abstract boolean isValid();
}

 一个键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系。前两个方法中反映了这种关系:channel() 方法返回与该键相关的 SelectableChannel 对象,而 selector() 则返回相关的 Selector 对象。

 键对象表示了一种特定的注册关系。当应该终结这种关系的时候,可以调用 SelectionKey 对象的 cancel() 方法。可以通过调用 isValid() 方法来检查它是否仍然表示一种有效的关系。当键被取消时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效。 

 当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。当选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取消)。一旦键被无效化,调用它的与选择相关的方法就将抛出 CancelledKeyException。

    public abstract int interestOps();
    public abstract void interestOps(int ops);
    public abstract int readyOps();   

一个 SelectionKey 对象包含两个以整数形式进行编码的比特掩码:一个用于指示那些通道/选择器组合体所关心的操作(instrest 集合),另一个表示通道准备好要执行的操作(ready 集合)。 
 当前的 interest 集合可以通过调用键对象的 interestOps() 方法来获取。最初,这应该是通道被注册时传进来的值。这个 interset 集合永远不会被选择器改变,但你可以通过调用 interestOps() 方法并传入一个新的比特掩码参数来改变它。 
 interest 集合也可以通过将通道注册到选择器上来改变(实际上使用一种迂回的方式调用 interestOps())。当相关的 Selector 上的 select() 操作正在进行时改变键的 interest 集合,不会影响那个正在进行的选择操作。所有更改将会在 select() 的下一个调用中体现出来。 interest集合是你所选择的感兴趣的事件集合。可以通过SelectionKey中的interestOps()方法读写interest集合,如下:

int interestSet = selectionKey.interestOps();

boolean isInterestedInAccept  = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead    = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite   = interestSet & SelectionKey.OP_WRITE;

可以看到,用“位与”操作interest 集合和给定的SelectionKey常量,可以确定某个确定的事件是否在interest 集合中。

 可以通过调用键的 readyOps() 方法来获取相关的通道的已经就绪的操作。ready 集合是 interest 集合的子集,并且表示了 interest 集合中从上次调用 select() 以来已经就绪的那些操作。 ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先调用readyOps()方法访问ready集合:

int readySet = selectionKey.readyOps();

 就像之前提到过的那样,有四个通道操作可以被用于测试就绪状态。你可以像上面的代码那样,通过测试比特掩码来检查这些状态,但 SelectionKey 类定义了四个便于使用的布尔方法来为你测试这些比特值。每一个方法都与使用特定掩码来测试 readyOps( )方法的结果的效果相同: 

 public final boolean isReadable();
 public final boolean isWritable();
 public final boolean isConnectable();
 public final boolean isAcceptable();


if (key.isWritable())

等价于:

if ((key.readyOps() & SelectionKey.OP_WRITE) != 0)
这四个方法在任意一个 SelectionKey 对象上都能安全地调用。不能在一个通道上注册一个它不支持的操作,这种操作也永远不会出现在 ready 集合中。调用一个不支持的操作将总是返回 false,因为这种操作在该通道上永远不会准备好。 
通过相关的选择键的 readyOps() 方法返回的就绪状态指示只是一个提示,不是保证。底层的通道在任何时候都会不断改变。其他线程可能在通道上执行操作并影响它的就绪状态。同时,操作系统的特点也总是需要考虑的。  


public final Object attach(Object ob);
public final Object attachment();

      这两  个方法允许你在键上放置一个“附件”,并在后面获取它。这是一种允许你将任意对象与键关联的便捷的方法。这个对象可以引用任何对你而言有意义的对象,例如业务对象、会话句柄、其他通道等等。这将允许你遍历与选择器相关的键,使用附加在上面的对象句柄作为引用来获取相关的上下文。 

attach() 方法将在键对象中保存所提供的对象的引用。SelectionKey 类除了保存它之外,不会将它用于任何其他用途。任何一个之前保存在键中的附件引用都会被替换。可以使用 null 值来清除附件。可以通过调用 attachment() 方法来获取与键关联的附件句柄。 

        如果选择键的存续时间很长,但你附加的对象不应该存在那么长时间,请记得在完成后清理附件。否则,你附加的对象将不能被垃圾回收,你将会面临内存泄漏问题。 

        回忆下 SelectableChannel 的 register() 方法,有个接受一个 Object 参数的重载方法如下: 

SelectionKey key = channel.register(selector, SelectionKey.OP_READ, myObject);

等价于:

SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
key.attach (myObject);

       关于 SelectionKey 的最后一件需要注意的事情是并发性。总体上说,SelectionKey 对象是线程安全的,但知道修改 interest 集合的操作是通过 Selector 对象进行同步的是很重要的。这可能会导致 interestOps() 方法的调用会阻塞不确定长的一段时间。选择器所使用的锁策略(例如是否在整个选择过程中保持这些锁)是依赖于具体实现的。幸好,这种多元处理能力被特别地设计为可以使用单线程来管理多个通道。被多个线程使用的选择器也只会在系统特别复杂时产生问题。


选择器维护着注册过的通道的集合,并且这些注册关系中的任意一个都是封装在 SelectionKey 对象中的每一个 Selector 对象维护三个键的集合: 

public abstract class Selectory {
    // This is a partial API listing
    public abstract Set keys();
    public abstract Set selectedKeys();
    public abstract int select() throws IOException;
    public abstract int select(long timeout) throws IOException;
    public abstract int selectNow() throws IOException;
    public abstract void wakeup();
}

  • 已注册的键的集合(Registered key set)        与选择器关联的已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引 java.lang.UnsupportedOperationException。        
  • 已选择的键的集合(Selected key set)        已注册的键的集合的子集。这个集合的每个成员都是相关的通道被选择器(在前一个选择操作中)判断为已经准备好的,并且包含于键的 interest 集合中的操作。这个集合通过 selectedKeys() 方法返回(并有可能是空的)。        
           不要将已选择的键的集合与 ready 集合弄混了。这是一个键的集合,每个键都关联一个已经准备好至少一种操作的通道。每个键都有一个内嵌的 ready 集合,指示了所关联的通道已经准备好的操作。    
            键可以直接从这个集合(已选择的键的集合)中移除,但不能添加。试图向已选择的键的集合中添加元素将抛出 java.lang.UnsupportedOperationException。       
  • 已取消的键的集合(Cancelled key set)       已注册的键的集合的子集,这个集合包含了 cancel() 方法(SelectionKey 类的方法)被调用过的键(这个键已经被无效化),但它们还没有被注销 。这个集合是选择器对象的私有成员,因而无法直接访问。在一个刚初始化的 Selector 对象中,以上提到的三个集合都是空的。 
    ◇ 选择过程 
        Selector 类的核心是选择过程。基本上来说,选择器是对 select()、poll() 等本地调用(native call)或者类似的操作系统特定的系统调用的一个包装。但是 Selector 所作的不仅仅是简单地向本地代码传送参数。它对每个选择操作应用了特定的过程。对这个过程的理解是合理地管理键和它们所表示的状态信息的基础。 
        Selector 类的 select() 方法有以下三种不同的形式: 
  • int select()        这种调用在没有通道就绪时将无限阻塞。一旦至少有一个已注册的通道就绪,选择器的选择键就会被更新,并且每个就绪的通道的 ready 集合也将被更新。返回值将会是已经确定就绪的通道的数目。正常情况下,这些方法将返回一个非零的值,因为直到一个通道就绪前它都会阻塞。        
  • int select(long timeout)        这种调用与之前的例子完全相同,除了如果在你提供的超时时间(以毫秒计算)内没有通道就绪时,它将返回 0。如果一个或者多个通道在时间限制终止前就绪,键的状态将会被更新,并且方法会在那时立即返回。将超时参数指定为 0 表示将无限期等待,那么它就在各个方面都等同于使用无参数版本的 select() 了。        
  • int selectNow()        这种种形式是完全非阻塞的。selectNow() 方法执行就绪检查过程,但不阻塞。如果当前没有通道就绪,它将立即返回 0。 
            选择操作是当三种形式的 select() 中的任意一种被调用时,由选择器执行的。不管是哪一种形式的调用,下面步骤将被执行: 
        1. 已取消的键的集合将会被检查。如果它是非空的,每个已取消的键的集合中的键将从另外两个集合中移除,并且相关的通道将被注销。这个步骤结束后,已取消的键的集合将是空的。 
        2. 已注册的键的集合中的键的 interest 集合将被检查。在这个步骤中的检查执行过后,对 interest 集合的改动不会影响剩余的检查过程。 
        一旦就绪条件被定下来,底层操作系统将会进行查询,以确定每个通道所关心的操作的真实就绪状态。依赖于特定的 select() 方法调用,如果没有通道已经准备好,线程可能会在这时阻塞,通常会有一个超时值。对于那些还没准备好的通道将不会执行任何的操作。对于那些操作系统指示至少已经准备好 interest 集合中的一种操作的通道,将执行以下两种操作中的一种: 

  • 如果通道的键还没有处于已选择的键的集合中,那么键的 ready 集合将被清空,然后表示操作系统发现的当前通道已经准备好的操作的比特掩码将被设置。
  • 否则,也就是键在已选择的键的集合中。键的 ready 集合将被表示操作系统发现的当前已经准备好的操作的比特掩码更新。所有之前的已经不再是就绪状态的操作不会被清除。事实上,所有的比特位都不会被清理。由操作系统决定的 ready 集合是与之前的 ready 集合按位分离的,一旦键被放置于选择器的已选择的键的集合中,它的 ready 集合将是累积的。比特位只会被设置,不会被清理。假设之前的 ready 集合为 100,此次 010 的操作已就绪,此时的 ready 集合为 110,而不是 010。这就是累积,不会被清理。
        3. 步骤 2 可能会花费很长时间,特别是所激发的线程处于休眠状态时。与该选择器相关的键可能会同时被取消。当步骤 2 结束时,步骤 1 将重新执行,以完成任意一个在选择进行的过程中,键已经被取消的通道的注销。 
        4. select 操作返回的值是 ready 集合在步骤 2 中被修改的键的数量,而不是已选择的键的集合中的通道的总数。返回值不是已准备好的通道的总数,而是从上一个 select() 调用之后进入就绪状态的通道的数量。之前的调用中就绪的,并且在本次调用中仍然就绪的通道不会被计入,而那些在前一次调用中已经就绪但已经不再处于就绪状态的通道也不会被计入。这些通道可能仍然在已选择的键的集合中,但不会被计入返回值中。返回值可能是 0。 

    ◇ 停止选择过程 
        Selector 的 API 中的最后一个方法,wakeup(),提供了使线程从被阻塞的 select() 方法中优雅地退出的能力: 
public abstract void wakeup();   
有三种方式可以唤醒在 select() 方法中睡眠的线程: 
  • 调用 wakeup()        调用 Selector 对象的 wakeup() 方法将使得选择器上的第一个还没有返回的选择操作立即返回。        
  • 调用 close()        如果选择器的 close() 方法被调用,那么任何一个在选择操作中阻塞的线程都将被唤醒,就像 wakeup() 方法被调用了一样。与选择器相关的通道将被注销,而键将被取消。        
  • 调用 interrupt()        如果睡眠中的线程的 interrupt() 方法被调用,它的返回状态将被设置。如果被唤醒的线程之后将试图在通道上执行 I/O 操作,通道将立即关闭,然后线程将捕捉到一个异常。使用 wakeup() 方法将会优雅地将一个在 select() 方法中睡眠的线程唤醒。如果你想让一个睡眠的线程在直接中断之后继续执行,需要执行一些步骤来清理中断状态(参见 Thread.interrupted() 的相关文档)。
        Selector 对象将捕捉 InterruptedException 异常并调用 wakeup() 方法。请注意这些方法中的任意一个都不会关闭任何一个相关的通道。中断一个选择器与中断一个通道是不一样的。选择器不会改变任意一个相关的通道,它只会检查它们的状态。当一个在 select() 方法中睡眠的线程中断时,对于通道的状态而言,是不会产生歧义的。 

    ◇ 管理选择键 
        选择是累积的。一旦一个选择器将一个键添加到它的已选择的键的集合中,它就不会移除这个键。并且,一旦一个键处于已选择的键的集合中,这个键的 ready 集合将只会被设置,而不会被清理。乍一看,这好像会引起麻烦,因为选择操作可能无法表现出已注册的通道的正确状态。它提供了极大的灵活性,但把合理地管理键以确保它们表示的状态信息不会变得陈旧的任务交给了程序员。 

        合理地使用选择器的秘诀是理解选择器维护的选择键集合所扮演的角色。最重要的部分是当键已经不再在已选择的键的集合中时将会发生什么。当通道上的至少一个感兴趣的操作就绪时,键的 ready 集合就会被清空,并且当前已经就绪的操作将会被添加到 ready 集合中。该键之后将被添加到已选择的键的集合中。 

        清理一个 SelectKey 的 ready 集合的方式是将这个键从已选择的键的集合中移除。选择键的就绪状态只有在选择器对象在选择操作过程中才会修改。处理思想是只有在已选择的键的集合中的键才被认为是包含了合法的就绪信息的。这些信息将在键中长久地存在,直到键从已选择的键的集合中移除,以通知选择器你已经看到并对它进行了处理。如果下一次通道的一些感兴趣的操作发生时,键将被重新设置以反映当时通道的状态并再次被添加到已选择的键的集合中。 

        这种框架提供了很多灵活性。通常的做法是在选择器上调用一次 select 操作(这将更新已选择的键的集合),然后遍历 selectKeys() 方法返回的键的集合。在按顺序进行检查每个键的过程中,相关的通道也根据键的就绪集合进行处理。然后键将从已选择的键的集合中被移除(通过在 Iterator 对象上调用 remove() 方法),然后检查下一个键。完成后,通过再次调用 select() 方法重复这个循环。


























你可能感兴趣的:(Java 7之异步I/O第4篇 - 异步I/O操作之选择器)