Java NIO中的Selector和IO复用

Selection的概念和意义

Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够知晓通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。

在《Java NIO》一书里介绍readiness selection概念是举了一个很好的例子,结合我们今天去银行办理个人业务的情况,我描述讲解一下。虽然我们现在的网银和其它互联网工具已经很发达了,但免不了我们需要去工行、招行办个业务啥的。因为银行网点多、服务网点门店小,而需要办理业务的人又很多,就不得不排队等待,那么常见的有两种方式,比如3个服务窗口:

 1. 可以3个窗口分别排队,为了方便队伍秩序维护,站到某一队的人不得到其它队伍插队,相互分隔开来,每一个队伍单独维护着先后顺序      

 2. 另一种方式是当你走进招商银行正门,服务员给你打一张号码条,即不分窗口进行全局排队,无论服务窗口有几个都是公用的,只要全局排队号码在前面的人已经完成了服务,则下一个号码的人就可以去空闲窗口去办理业务

这个描述大概说了下不使用和使用readiness selection的情况,可能在效率体现上也未必完全恰当,但在计算机编程上,readiness selection和IO复用在特定场景下很大的提高了效率。我之前整理过Java并发的文章,有一个观点就是,并发并不是线程越多越好,一方面这需要很大的维护成本,更重要的是我们的计算机处理资源都是非常有限的,多开一个线程就多耗费一些资源,所以我们可能会考虑使用一个线程熟练有上限的线程池。那么如果每个线程负责处理一个网络连接,线程占用达到上限的时候,新的连接又将如何处理?已被之前连接占用的线程始终不被释放,这样调度是否是最高效的?在Java中,Selector和IO复用很好的解决了这个问题。

其实Java中的Selector和IO复用是基于各个操作系统平台实现的。在操作系统底层的API中,也早有select的概念,有select()、poll()等函数。

随着Ajax技术和Web长连接推送应用场景的发展,NIO和IO复用有了很大的需求。在Java开源项目的Jetty和Tomcat服务器实现中,也都对NIO的IO复用机制做了很大支持。

在readiness selection的设计和实现中,有三个重要角色SelectableChannel、SelectionKey和Selector。

Java NIO中的Selector和IO复用_第1张图片

1. SelectableChannel

在Java第4版的API中,Channel的子类分为两大块:

  • FileChannel,针对文件IO的Channel,可以通过FileInputStream、FileOutputStream和RandomAccessFile来获得,不支持非阻塞模式,进而也就不支持readiness selection

  • SelectableChannel,除File以外,像对Socket
    IO做支持的Channel都属于SelectableChannel,支持非阻塞模式和readiness selection

    我们这里讲的readiness selection就需要SelectableChannel在非阻塞模式下使用,可以通过

public abstract void configureBlocking (boolean block)
    throws IOException;

这个方法进行配置。从上面的关系图中我们可以看到Channel最终是要和Selector关联起来使用的,实际上是通过SelectableChannel中的register()方法进行注册的。

public abstract SelectionKey register (Selector sel, int ops)
    throws ClosedChannelException;
public abstract SelectionKey register (Selector sel, int ops,
Object att)
    throws ClosedChannelException;

第二个参数是感兴趣的事件,默认常量有4个(连接、接受、读、写),定义在SelectionKey类中,但并不是所有Channel都一定支持,可以用validOps()判断。除此之外,同一SelectableChannel对象可以注册到多个Selector,可以调用它的keyFor()方法,来得到对应的SelectionKey。

2. SelectionKey

接下来说说在Channel和Selector之间的关联对象SelectionKey。既然是关联对象,那肯定是可以得到连接的两个对象的:

public abstract SelectableChannel channel()
public abstract Selector selector()

还有支持的感兴趣的事件,以及已经准备好IO的事件,感兴趣的事件的方法是同名重载,一个为get另一个为set:

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

Selection中维护了两个Set集合,正如上面方法中所示,一个是感兴趣的事件集合,另一个是准备好了的,可以进行IO操作的集合。

对于SelectionKey的cancel()方法需要注意的是,并不直接生效,而是到Selector下次select()时,但SelectionKey的isValid()会立即回复false。

3. Selector

终于,最重要的对象出现了。通常,Selector是由静态工厂方法open()实例化的,也可以直接调用SelectorProvider的openSelector()返回,Selector的provider()方法会返回特定的provider对象。用完了调用close()以释放资源,可以用isOpen()判断Selector是否已经关闭。

public abstract int select( ) throws IOException;

public abstract int select (long timeout) throws IOException;

public abstract int selectNow( ) throws IOException;

public abstract void wakeup( );

当Selector和特定的SelectableChannel关联好了,开始工作了,那么就需要进行select操作,如上面方法所示。

◾select() 阻塞调用线程,直到有某个Channel的某个感兴趣的Op准备好了

◾select(long) 阻塞调用线程,但超时会自动返回

◾selectNow() 则不阻塞

◾wakeup()则是从另一线程对Selector调用,恢复调用select()的线程执行;注意这这是取消最近一次的调用,如果还没有调用,则下一次调用会直接返回

select()只返回本次执行select时从未准备好到准备好状态的channel数,如果不为0,将调用如下方法进行处理。

public abstract Set selectedKeys( );

这个方法返回一个包含SelectionKey对象的集合,分别对应各个准备好的Channel。而对于注册在这个Selector的所有Key,还有一个方法可以获取到。

public abstract Set keys( );

Selector对象维护了3个key集合,一个注册过的,一个是选择过的,最后一个是cancel过但是未反注册的,这个我们没有方法直接获取到。

4.常规使用示例

了解过了这3个重要角色,看一段常规使用的代码示例。


//服务器端Socket端口
int port = 30;

//打开服务器端通道
ServerSocketChannel serverChannel = ServerSocketChannel.open( );

//注册Socket
ServerSocket serverSocket = serverChannel.socket( );

//打开Selector
Selector selector = Selector.open( );

//将socket与指定端口绑定
serverSocket.bind (new InetSocketAddress (port));

//配置服务器端通道为非阻塞模式
serverChannel.configureBlocking (false);

//将该通道绑定到指定的selector中
serverChannel.register (selector, SelectionKey.OP_ACCEPT);
/*
selector.select()

Selects a set of keys whose corresponding channels are ready for I/O operations. 

This method performs a blocking selection operation. It returns only after at least one channel is selected, this selector's wakeup method is invoked, or the current thread is interrupted, whichever comes first. 
*/
while (true) {
    int n = selector.select( );  //这里将阻塞当前线程
    if (n == 0) {
        continue; // nothing to do
    }

  /*Returns this selector's selected-key set. */
    Iterator it = selector.selectedKeys().iterator( );

     while (it.hasNext( )) {
        SelectionKey key = (SelectionKey) it.next( );
        if (key.isAcceptable( )) {
            //返回该key对应的Channel
            ServerSocketChannel server =
            (ServerSocketChannel) key.channel( );
            //获得新的连接
            SocketChannel channel = server.accept( );
            if (channel == null) {
                ;//handle code, could happen
            }
            channel.configureBlocking (false);
            //把该新key注册到selector中
            channel.register (selector, SelectionKey.OP_READ);

        }
        if (key.isReadable( )) {
            readDataFromSocket (key);
        }
        //手动移除 
      it.remove( );
    }
}

这是一个简单服务器接受请求,并做读取的代码逻辑。

readDataFromSocket (key);

这句是通过Channel和Buffer进行数据读取处理。

注意最后的:

it.remove( );

这行代码是必要的。

5.ReadinessSelection注意点和IO复用

为了解释为什么上面实例中最后的iterator的remove()调用是必要的,我们需要先来看下Java在select实现上的原理和过程。

首先,针对关联每个Channel的SelectionKey对象,都维护者2个Set集合,分别是
◾interestOps
◾readyOps

然后,每个Selector又维护着3个Set集合,分别是
◾registeredKeys,可以通过keys()方法获得
◾selectedKeys
◾cancelledKeys,存储着调用过cancel()方法,但并没有被反注册或者说解开注册的SelectionKey对象,没有方法直接获得

每次select()方法调用时,先把cancelledKeys数据同步到registerKeys和selectedKeys,做减法以完成反注册,接下来调用操作系统底层的select实现,重点在于阻塞之后得到的结果处理:

◾如果有在registeredKeys中的key的感兴趣事件发生了,检查是否该key存在于selectedKeys中,如果没有,则将该key的readOps清空,根据此次的情况进行重新设置,并将key加入到selectedKeys

◾如果不是上面这种情况,即selectedKeys中已经包含了事件中的key,那么只做“从无到有”的更新操作,这里的所谓“从无到有”就是如果原来已经有了的key不做自动移除,key对应的readOps也只是将之前没有ready而此次ready的放进去,不会将之前ready而此时已经非ready的做更新

说道这里,remove()的必要性就不必多解释了,在select()返回之前,再将阻塞过程当中发生cancel的key做一次同步。

上面提到了几个集合,其实Selector对象本身的操作是线程安全的,但3个keySet是可能随时变化的,可以获取到再进行更改,这个Set的使用需要额外做同步来保证线程安全。

其实,大多数情况下使用Selector的select()只需单线程就可以满足了,而对于select得到的channel和对应的IO操作,可以新开线程或者使用线程池来处理。这也正是IO复用的意义所在。

参考:

Linux下select, poll和epoll IO模型的详解

Linux中select poll和epoll的区别

Java NIO中的Selector和IO复用

NIO入门

Java NIO系列教程(六) Selector

你可能感兴趣的:(javaNIO)