除了第一篇小结中讲的Java New/IO的几个基本新特性外,New I/O中一个最突出的特性就是Non-blocking I/O了,这个特性是针对原java.net包中socket编程的一个极大的补充和拓展。究竟non-blocking如何使用?有何特点?与socket的blocking IO相比,有哪些优势?
在 JDK1.4 以前,在调用 ServerSocket.accept() 方法等待一个连接入的套接字时,该方法一直是等待的,直到有客户端的套接字接入才返回一个套接字,或者抛出 IOException 。套接字读取输入流时到缓存中的时候,必须等到套接字输入流的输入数据是可用的,或者出现异常,或者读取输入流的末尾。试想,假如客户端套接字发送报文给服务端套接字的过程中,因网络的问题,数据没有发送过来或者有延迟,那么服务器端套接字在读取数据时,会一直处于等待状态。常见的框架代码如下,
ServerSocket server = new ServerSocket(1299); while (true) { Socket socket = server.accept(); (new RequestHandlerThread(socket)).start(); }
在面向线程的阻塞 IO 编程模式下, ServerSocket 接受到一个套接字后,会启动一个线程负责套接字之间的通讯,但是,如果出现 IO 阻塞,这个线程也会处于“阻塞”状态,如果 IO 阻塞没有被及时排除,会引起僵尸线程,甚至整个系统不可用。
而在多路复用 Non-blocking 模式下, socket 编程采用事件模式,主要用 Selector 来读取感兴趣的事件,用 Channel 注册感兴趣的事件和负责发送、接收数据, SelectionKey 是对事件的封装。这种编程模式的框架代码如下,
// 打开selctor和服务器端SocketChannel,并且配置为non-blocking模式,然后绑定到9000端口。 Selector sel = Selector.open(); ServerSocketChannel ssc = ServerSocketChannel.open(); ssc.configureBlocking(false); ssc.socket().bind(new InetSocketAddress(9000)); // 注册OP_ACCEPT事件,让selector在下一次select操作中选择OP_ACCEPT事件 ssc.register(sel, SelectionKey.OP_ACCEPT); while (selector.select()>0){ Iterator<SelectionKey> keys = selector.selectedKeys().iterator(); while (keys.hasNext()) { SelectionKey key = keys.next(); keys.remove(); if (key.isValid()) { if (key.isAcceptable()) { // .... 接受SocketChannel,并注册OP_READ事件 } else if (key.isReadable()) { // ... 从SocketChannel读取数据 } else if (key.isWritable()){ // ... 向SocketChannel 写入数据 } // 把当前已处理的key放入到selector的cancelling selectionkey集合当中,在下一次select操作中,该key被移去。 key.cancel(); } } }
Selector 是包含多个 Selectable Channel 的转接器,这些通道可以设置成非阻塞模式去执行多路 I/O 操作。这些通道必须先被创建,然后设置成非阻塞模式,并注册到 Selector 之中。注册通道时指定一些特定的 I/O 操作,这些 I/O 操作将会为 selector 所检测,并返回一个代表这个注册的 SelectionKey 。
一旦通道被注册到一个 Selector, 选择操作才可以执行去发现有没有通道已经处于可以执行这些先前被生命注册 I/O 操作的状态。如果一个通道已经就绪,返回一个 SelectionKey 并把这个 key 添加到 Selector 的 Selected-Key 集。在下一次选择操作过程中,会把这个带有相关通道 key 检索出来去执行相关的 I/O 操作。
一个 SelectionKey 标明一个通道可以准备执行什么操作,仅仅是一个提示,而不是保证。因此,这个操作可以放到一个线程中去执行,而不会引起线程阻塞。这对于编写多路复用 I/O 的代码是非常重要的,假使这个提示在后来被证明是不正确的,也可以在代码中忽略掉它。
Selector 有三个主要的方法,
Select() 以阻塞的方式从当前的 selector 中选取那些已经准备好 I/O 操作 Channel 的 key ,至少有一个 channel 被选取后才返回,或者 weekup 被调用,或者当前线程被终止执行。
selectNow() ,与 select() 方法类似,只不过是非阻塞的,如果当前 selector 中没有已准备 I/O 操作的 key ,该方法立即返回 0 。之前如有 weekup() 被调用, SelectNow() 会清除掉前者的执行效果。
weekup() ,让第一个还没有返回的选择操作立即返回,如果另外一个线程被 select() 或者 select(long) 方法调用阻止,调用 weekup() 方法,会让 select() 或者 select(long) 调用立即返回。如果当前进程中没有选择操作,会导致下轮 select(), select(long) 方法调用立即返回,除非同时有 selectNow() 方法被调用。在两此成功的选择操作过程中间调用多次 weekup() 方法与只调用一次的效果是相同的。
在 Non-blocking 模式下,用 Channel 读取输入缓冲中的数据时,读取到的字节数依赖于当前 Channel 的状态。比如下面的代码。
ByteBuffer buff = ByteBuffer.allocate(128); socketChannel.read(buff);
如果 Channel 缓冲区没有数据,那么读取了 0 字节,如果 Channel 的缓冲区里有 50 个“立即可读取”字节,则读取 50 个字节,假如已经到了流的末尾,则返回 -1 。因为 Channel 是直接从缓冲区读取数据,而不是从流读取,所以, Channel 的这种工作方式效率比从流直接读取效率高。
Channel 写数据的时候,也是先写入 Channel 的写入缓冲区,而不是直接写入 Channel 的输出流。具体写入的字节数由 Channel 的输出缓冲区的状态决定。考察下面的代码。
byte out[] = new byte[256]; for (int i=0; i<256; i++) out[i]=i; socketChannel.write(ByteBuffer.wrap(out));
假如输出缓冲区还剩下 50 个空余字节,那么本次写入操作只能写 50 个字节。如果输出缓冲区剩余 500 个空余字节,则能写入 256 个字节。
至于 Channel 的从输入流读数据到输入缓冲,什么时候把输出缓冲写入到输出流,由 Channle 的实现细节决定。以后会对 Channel 中的缓冲于流的机制做深入的研究。使用 Channel 时,一定要记住,虽然看起来和 InputStream 、 OutputStream 使用方式看起来一样,但其内部机制是有天壤之别的。
末尾,以表格的形式对 socket 编程中用到的主要方法的阻塞特性作一个小结。
ServerSocketChannel.accept() |
非阻塞模式 / 阻塞模式 |
ServerSocket.accept() |
阻塞模式 |
Socket input stream read |
阻塞模式 |
Socket output stream write |
阻塞模式 |
Channel input Buffer read |
非阻塞模式 / 阻塞模式 |
Channel output buffer write |
非阻塞模式 / 阻塞模式 |
Selector.select() |
阻塞模式 |
Selector.select(long l) |
阻塞模式 |
Selector.selectNow() |
非阻塞模式 / 阻塞模式 |