Java NIO入门与详解

http://www.yangyong.me/java-nio%E5%85%A5%E9%97%A8%E4%B8%8E%E8%AF%A6%E8%A7%A3/

 

Java NIO介绍

nio 是 New I/O 的简称,属于当时 jdk1.4 提供的新 api。如今 jdk 版本已经到 1.8 了,新 IO 这个称谓有点不合适了,nio 还有一个更合适的叫法——非阻塞(non-blocking)IO。

  1. nio与io对比
  2. I/O相关概念整理
  3. 缓冲区
  4. 通道
  5. 选择器

1. nio与io对比

1.1 文件流与文件块的比较
原来的 I/O 库(在 java.io.*中) 与 NIO 最重要的区别是数据打包和传输的方式。原来的 I/O 以流的方式处理数据,经常为了处理个别字节或字符,就要执行好几个对象层的方法调用。而 NIO 以块的方式处理数据。

面向流 的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的 I/O 通常相当慢。

一个 面向块 的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。

1.2 集成的 文件I/O
在 JDK 1.4 中原来的 I/O 包和 NIO 已经很好地集成了。 java.io.* 已经以 NIO 为基础重新实现了,所以现在它可以利用 NIO 的一些特性。例如, java.io.* 包中的一些类包含以块的形式读写数据的方法,这使得即使在更面向流的系统中,处理速度也会更快。

也可以用 NIO 库实现标准 I/O 功能。例如,可以容易地使用块 I/O 一次一个字节地移动数据。NIO 还提供了原 I/O 包中所没有的许多好处。

1.3 为什么要使用 NIO?(主要是网络I/O)
I/O的终极目标是效率,而效率离不开底层操作系统和文件系统的特性支持。这些特性包括:文件锁定、非阻塞I/O、就绪性选择、和内存映射。当今操作系统大都支持这些特性,而Java传统I/O机制并没有模拟这些通用的I/O服务。

NIO 的创建目的是为了让 Java 程序员可以实现高速 I/O 而无需编写自定义的本机代码。NIO 将最耗时的 I/O 操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。

通常一次缓冲区操作是这样的:某个进程需要进行I/O操作,它执行了一次读(read)或者写(write)的系统调用,向底层操作系统发出了请求,操作系统会按要求把数据缓冲区填满或者排干。

对于文件I/O,集成的 I/O提供了对于NIO特性的支持。 企业级应用软件中涉及I/O的部分多半是读写文件的功能性需求,很少有在并发上的要求,那么JavaIO包已经很胜任了。

对于网络I/O,传统的阻塞式I/O,一个线程对应一个连接,采用线程池的模式在大部分场景下简单高效。当连接数茫茫多时,并且数据的移动非常频繁,NIO无疑是更好的选择。


2. I/O相关概念整理

2.1 DMA
DMA(Direct Memory Access,直接内存存取) 是所有现代电脑的重要特色,在DMA控制器和内存之间传输数据的时候CPU是空闲的。

在dma传输数据的过程中,要求源物理地址和目标物理地址必须是连续的。但在有的计算机体系中,如IA,连续的存储器地址在物理上不一定是连续的,则dma传输要分成多次完成。如果传输完一块物理连续的数据后发起一次中断,同时主机进行下一块物理连续的传输,则这种方式即为block dma方式。scatter/gather方式则不同,它是用一个链表描述物理不连续的存储器,然后把链表首地址告诉dma master。dma master传输完一块物理连续的数据后,就不用再发中断了,而是根据链表传输下一块物理连续的数据,最后发起一次中断。

很显然scatter/gather方式比block dma方式效率高。

2.2 内核空间和用户空间
用户空间是常规进程所在区域。JVM就是常规进程,驻守于用户空间。用户空间是非特权区域:比如,在该区域执行的代码就不能直接访问硬件设备。内核空间是操作系统所在区域。内核代码有特别的权力:它能与设备控制器通讯,控制着用户区域进程的运行状态,等等。最重要的是,所有I/O都直接或间接通过核空间。

当进程请求I/O操作的时候,它执行一个系统调用将控制权移交给内核。当内核以这种方式被调用,它随即采取任何必要步骤,找到进程所需数据,并把数据传送到用户空间内的指定缓冲区。内核试图对数据进行高速缓存或预读取,因此进程所需数据可能已经在内核空间里了。如果是这样,该数据只需简单地拷贝出来即可。如果数据不在内核空间,则进程被挂起,内核着手把数据读进内存。

为什么不直接让磁盘控制器把数据送到用户空间的缓冲区呢?这样做有几个问题。首先,硬件通常不能直接访问用户空间 (硬件设备通常不能直接使用虚拟内存地址) 。其次,像磁盘这样基于块存储的硬件设备操作的是固定大小的数据块,而用户进程请求的可能是任意大小的或非对齐的数据块。在数据往来于用户空间与存储设备的过程中,内核负责数据的分解、再组合工作,因此充当着中间人的角色。

2.3 虚拟内存
所有现代操作系统都使用虚拟内存。虚拟内存意为使用虚假(或虚拟)地址取代物理(硬件
RAM)内存地址。这样做好处颇多,总结起来可分为两大类:

  1. 一个以上的虚拟地址可指向同一个物理内存地址。
  2. 虚拟内存空间可大于实际可用的硬件内存。

设备控制器不能通过DMA直接存储到用户空间,但通过利用上面提到的第一项,则可以达到相同效果。把内核空间地址与用户空间的虚拟地址映射到同一个物理地址,这样,DMA硬件(只能访问物理内存地址)就可以填充对内核与用户空间进程同时可见的缓冲区。但前提条件是,内核与用户缓冲区必须使用相同的页对齐,缓冲区的大小还必须是磁盘控制器块大小(通常为512 字节磁盘扇区)的倍数。操作系统把内存地址空间划分为页,即固定大小的字节组。内存页的大小总是磁盘块大小的倍数,通常为2次幂(这样可简化寻址操作)。典型的内存页为1,024、2,048和4,096字节。

现代操作系统的分页技术

2.4 分页技术
现代CPU包含一个称为内存管理单元(MMU)的子系统,逻辑上位于CPU与物理内存之间。
该设备包含虚拟地址向物理内存地址转换时所需映射信息。当CPU引用某内存地址时,MMU负责
确定该地址所在页(往往通过对地址值进行移位或屏蔽位操作实现),并将虚拟页号转换为物理页
号(这一步由硬件完成,速度极快)。如果当前不存在与该虚拟页形成有效映射的物理内存页,
MMU会向CPU提交一个页错误。

页错误随即产生一个系统调用,把控制权移交给内核,附带导致错误的虚拟地址信息,然后内核采取步骤验证页的有效性。内核会安排页面调入操作,把缺失的页内容读回物理内存。这往往导致别的页被移出物理内存,好给新来的页让地方。在这种情况下,如果待移出的页已经被碰过了(自创建或上次页面调入以来,内容已发生改变),还必须首先执行页面调出,把页内容拷贝到磁盘上的分页区。

如果所要求的地址不是有效的虚拟内存地址(不属于正在执行的进程的任何一个内存段),则该页不能通过验证,段错误随即产生。于是,控制权转交给内核的另一部分,通常导致的结果就是进程被强令关闭。一旦出错的页通过了验证,MMU随即更新,建立新的虚拟到物理的映射(如有必要,中断被移出页的映射),用户进程得以继续。造成页错误的用户进程对此不会有丝毫察觉,一切都在不知不觉中进行。

2.5 面向块(文件)的I/O和流I/O

文件I/O属文件系统范畴,文件系统与磁盘迥然不同。磁盘把数据存在扇区上,通常一个扇区512 字节。磁盘属硬件设备,对何谓文件一无所知,它只是提供了一系列数据存取窗口。在这点上,磁盘扇区与内存页颇有相似之处:都是统一大小,都可作为大的数组被访问。

文件系统是更高层次的抽象,是安排、解释磁盘(或其他随机存取块设备)数据的一种独特方
式。您所写代码几乎无一例外地要与文件系统打交道,而不是直接与磁盘打交道。是文件系统定义
了文件名、路径、文件、文件属性等抽象概念。
文件系统把一连串大小一致的数据块组织到一起。有些块存储元信息,如空闲块、目录、索引
等的映射,有些包含文件数据。单个文件的元信息描述了哪些块包含文件数据、数据在哪里结束、
最后一次更新是什么时候,等等。

当用户进程请求读取文件数据时,文件系统需要确定数据具体在磁盘什么位置,然后着手把相
关磁盘扇区读进内存。老式的操作系统往往直接向磁盘驱动器发布命令,要求其读取所需磁盘扇
区。而采用分页技术的现代操作系统则利用请求页面调度取得所需数据。

采用分页技术的操作系统执行I/O的全过程可总结为以下几步:
• 确定请求的数据分布在文件系统的哪些页(磁盘扇区组)。磁盘上的文件内容和元数
据可能跨越多个文件系统页,而且这些页可能也不连续。
• 在内核空间分配足够数量的内存页,以容纳得到确定的文件系统页。
• 在内存页与磁盘上的文件系统页之间建立映射。
• 为每一个内存页产生页错误。
• 虚拟内存系统俘获页错误,安排页面调入,从磁盘上读取页内容,使页有效。
• 一旦页面调入操作完成,文件系统即对原始数据进行解析,取得所需文件内容或属性
信息。

大多数操作系统假设进程会继续读取文件剩余部分,因而会预读额外的文件系统页。如果内存
争用情况不严重,这些文件系统页可能在相当长的时间内继续有效。这样的话,当稍后该文件又被
相同或不同的进程再次打开,可能根本无需访问磁盘。这种情况您可能也碰到过:当重复执行类似
的操作,如在几个文件中进行字符串检索,第二遍运行得似乎快多了。

类似的步骤在写文件数据时也会采用。这时,文件内容的改变(通过write( ))将导致文件系统
页变脏,随后通过页面调出,与磁盘上的文件内容保持同步。文件的创建方式是,先把文件映射到
空闲文件系统页,在随后的写操作中,再将文件系统页刷新到磁盘。

并非所有I/O都是面向块的,也有流I/O,其原理模仿了通道。I/O字节流必须顺序存取,常见的例子有TTY(控制台)设备、打印机端口和网络连接。

流的传输一般(也不必然如此)比块设备慢,经常用于间歇性输入。多数操作系统允许把流置于非阻塞模式,这样,进程可以查看流上是否有输入,即便当时没有也不影响它干别的。这样一种能力使得进程可以在有输入的时候进行处理,输入流闲置的时候执行其他功能。

比非阻塞模式再进一步,就是就绪性选择。就绪性选择与非阻塞模式类似(常常就是建立在非阻塞模式之上),但是把查看流是否就绪的任务交给了操作系统。操作系统受命查看一系列流,并提醒进程哪些流已经就绪。这样,仅仅凭借操作系统返回的就绪信息,进程就可以使用相同代码和单一线程,实现多活动流的多路传输。这一技术广泛用于网络服务器领域,用来处理数量庞大的网络连接。就绪性选择在大容量缩放方面是必不可少的。

2.6 内存映射文件
内存映射I/O使用文件系统建立从用户空间直到可用文件系统页的虚拟内存映射。这样做有几
个好处:
• 用户进程把文件数据当作内存,所以无需发布read( )或write( )系统调用。
• 当用户进程碰触到映射内存空间,页错误会自动产生,从而将文件数据从磁盘读进内存。如果用户修改了映射内存空间,相关页会自动标记为脏,随后刷新到磁盘,文件得到更新。
• 操作系统的虚拟内存子系统会对页进行智能高速缓存,自动根据系统负载进行内存管理。
• 数据总是按页对齐的,无需执行缓冲区拷贝。
• 大型文件使用映射,无需耗费大量内存,即可进行数据拷贝。

2.7 文件锁定
文件锁定机制允许一个进程阻止其他进程存取某文件,或限制其存取方式。通常的用途是控制共享信息的更新方式,或用于事务隔离。在控制多个实体并行访问共同资源方面,文件锁定是必不可少的。数据库等复杂应用严重信赖于文件锁定。

“文件锁定”从字面上看有锁定整个文件的意思(通常的确是那样),但锁定往往可以发生在更为细微的层面,锁定区域往往可以细致到单个字节。锁定与特定文件相关,开始于文件的某个特定字节地址,包含特定数量的连续字节。这对于协调多个进程互不影响地访问文件不同区域,是至关重要的。

文件锁定有两种方式:共享的和独占的。多个共享锁可同时对同一文件区域发生作用;独占锁则不同,它要求相关区域不能有其他锁定在起作用。


3. 缓冲区

通道 和 缓冲区 是 NIO 中的核心对象,几乎在每一个 I/O 操作中都要使用它们。

通道是对原 I/O 包中的流的模拟。到任何目的地(或来自任何地方)的所有数据都必须通过一个 Channel 对象。一个 Buffer 实质上是一个容器对象。发送给一个通道的所有对象都必须首先放到缓冲区中;同样地,从通道中读取的任何数据都要读到缓冲区中。

Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,您将数据直接写入或者将数据直接读到 Stream 对象中。

在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,您都是将它放到缓冲区中。

缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不仅仅 是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

最常用的缓冲区类型是 ByteBuffer。一个 ByteBuffer 可以在其底层字节数组上进行 get/set 操作(即字节的获取和设置)。

ByteBuffer 不是 NIO 中唯一的缓冲区类型。事实上,对于每个非布尔原始数据类型都有一种缓冲区类

ByteBuffer
CharBuffer
ShortBuffer
IntBuffer
LongBuffer
FloatBuffer
DoubleBuffer

每一个 Buffer 类都是 Buffer 接口的一个实例。 除了 ByteBuffer,每一个 Buffer 类都有完全一样的操作,只是它们所处理的数据类型不一样。因为大多数标准 I/O 操作都使用 ByteBuffer,所以它具有所有共享的缓冲区操作以及一些特有的操作。

3.1 缓冲区基础
所有的缓冲区都具有四个属性来提供关于其所包含的数据元素的信息。它们是:

容量(Capacity)

缓冲区能够容纳的数据元素的最大数量。这一容量在缓冲区创建时被设定,并且永远不能
被改变。

limit 决不能大于 capacity。

上界(Limit)

缓冲区的第一个不能被读或写的元素limit 变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。

position 总是小于或者等于 limit。。或者说,缓冲区中现存元素的计数。

位置(Position)

下一个要被读或写的元素的索引。位置会自动由相应的get()和put( )方法更新。

您可以回想一下,缓冲区实际上就是美化了的数组。在从通道读取时,您将所读取的数据放到底层的数组中。 position 变量跟踪已经写了多少数据。更准确地说,它指定了下一个字节将放到数组的哪一个元素中。因此,如果您从通道中读三个字节到缓冲区中,那么缓冲区的 position 将会设置为3,指向数组中第四个元素。

同样,在写入通道时,您是从缓冲区中获取数据。 position 值跟踪从缓冲区中获取了多少数据。更准确地说,它指定下一个字节来自数组的哪一个元素。因此如果从缓冲区写了5个字节到通道中,那么缓冲区的 position 将被设置为5,指向数组的第六个元素。

标记(Mark)

一个备忘位置。调用mark( )来设定mark= postion。调用reset( )设定position=
mark。标记在设定前是未定义的(undefined)。

这四个属性之间总是遵循以下关系:0 <= mark <= position <= limit <= capacity

3.2 缓冲区API

    package java.nio; 
    /** *关于这个API有一点要注意的是,像clear()这类方法,您通常应当返回void,而不 *是Buffer引用。这些方法将引用返回到它们在(this)上被引用的对象。这是一个允许级 *联调用的类设计方法。级联调用允许这种类型的代码: * *对于API还要注意的一点是isReadOnly()方法。所有的缓冲区都是可读的,但并非所 *有都可写。 */
    public abstract class Buffer { 
        public final int capacity();
        public final int position();
        public final Buffer position(int newPosition);
        public final int limit();
        public final Buffer limit(int newLimit); 
        //缓冲区的标记在mark( )方法被调用之前是未定义的(值为-1)。一些缓冲区方法会抛弃已经设定的标记(rewind(),clear(),以及flip()总是抛弃标记)。如果新设定的值比当前的标记小,调用limit()或position()带有索引参数的版本会抛弃标记。
        public final Buffer mark();
        //reset( )方法将位置设为当前的标记值。
        public final Buffer reset();
        //将上界设为容量的值,并把位置设回0,这使得缓冲区可以被重新填入。
        public final Buffer clear();
        //翻转 相当于limit(buffer.position()).position(0)
        public final Buffer flip();
        //与flip()相似,但不影响上界属性。它只是将位置值设回0。您可以使用rewind()后退,重读已经被翻转的缓冲区中的数据。
        public final Buffer rewind();
        public final int remaining();
        public final boolean hasRemaining();
        public abstract  boolean isReadOnly(); 
    }

上面所列出的的Buffer API并没有包括get()或put()方法。每一个Buffer类都有这两个方法,但它们所采用的参数类型,以及它们返回的数据类型,对每个子类来说都是唯一的,所以它们不能在顶层Buffer类中被抽象地声明。

Get和Put可以是相对的或者是绝对的。当相对方法被调用时,位置在返回时前进一。如果位置前进过多,相对运算就会抛出异常。绝对存取需要传递索引参数,方法调用不会影响缓冲区的位置属性,但是如果您所提供的索引超出范围(负数或不小于上界),也将抛出IndexOutOfBoundsException异常。

3.3 字节缓冲区
字节是操作系统及其I/O设备使用的基本数据类型。当在JVM和操作系统间传递数据时,将其他的数据类型拆分成构成它们的字节是十分必要的。系统层次的I/O面向字节的性质可以在整个缓冲区的设计以及它们互相配合的服务中感受到。

    package java.nio; 
    public abstract class ByteBuffer extends Buffer implements Comparable { 
        public static ByteBuffer allocate (int capacity); 
        public static ByteBuffer allocateDirect (int capacity); 
        public abstract  boolean isDirect(); 
        public static ByteBuffer wrap (byte[] array, int offset, int length); 
        public static ByteBuffer wrap (byte[] array); 
        public abstract ByteBuffer duplicate(); 
        public abstract  ByteBuffer asReadOnlyBuffer(); 
        public abstract  ByteBuffer slice(); 
        public final boolean hasArray(); 
        public final byte[] array(); 
        public final int arrayOffset(); 
        public abstract  byte get(); 
        public abstract byte get (int index); 
        public ByteBuffer get (byte[] dst, int offset, int length); 
        public abstract ByteBuffer put (byte b); 
        public abstract ByteBuffer put (int index, byte b); 
        public ByteBuffer put (ByteBuffer src); 
        public ByteBuffer put (byte[] src, int offset, int length); 
        public final ByteBuffer put (byte[] src); 
        public final ByteOrder order(); 
        public final ByteBuffer order (ByteOrder bo); 
        public abstract  CharBuffer asCharBuffer(); 
        public abstract ShortBuffer asShortBuffer(); 
        public abstract  IntBuffer asIntBuffer(); 
        public abstract LongBuffer asLongBuffer(); 
        public abstract  FloatBuffer asFloatBuffer(); 
        public abstract DoubleBuffer asDoubleBuffer(); 
        public abstract char getChar(); 
        public abstract char getChar (int index); 
        public abstract ByteBuffer putChar (char value); 
        public abstract ByteBuffer putChar (int index, char value); 
        public abstract short getShort(); 
        public abstract short getShort (int index); 
        public abstract ByteBuffer putShort (short value); 
        public abstract ByteBuffer putShort (int index, short value); 
        public abstract int getInt(); 
        public abstract int getInt (int index); 
        public abstract ByteBuffer putInt (int value); 
        public abstract ByteBuffer putInt (int index, int value); 
        public abstract long getLong(); 
        public abstract long getLong (int index); 
        public abstract ByteBuffer putLong (long value); 
        public abstract ByteBuffer putLong (int index, long value); 
        public abstract float getFloat(); 
        public abstract float getFloat (int index); 
        public abstract ByteBuffer putFloat (float value); 
        public abstract ByteBuffer putFloat (int index, float value); 
        public abstract double getDouble(); 
        public abstract double getDouble (int index); 
        public abstract ByteBuffer putDouble (double value); 
        public abstract ByteBuffer putDouble (int index, double value); 
        public abstract ByteBuffer compact(); 
        public boolean equals (Object ob); 
        public int compareTo (Object ob);  
        public String toString(); 
        public int hashCode();
    }

• 字节顺序

多字节数值被存储在内存中的方式一般被称为endian-ness(字节顺序)。如果数字数值的最高字节——big end(大端),位于低位地址,那么系统就是大端字节顺序。字节顺序很少由软件设计者决定;它通常取决于硬件设计。如果最低字节最先保存在内存中,那么小端字节顺序。

当Internet的设计者为互联各种类型的计算机而设计网际协议(IP)时,他们意识到了在具有不同内部字节顺序的系统间传递数值数据的问题。因此,IP协议规定了使用大端的网络字节顺序概念。所有在IP分组报文的协议部分中使用的多字节数值必须先在本地主机字节顺序和通用的网络字节顺序之间进行转换。

在java.nio中,字节顺序由ByteOrder类封装。

    package java.nio; 
    public final class ByteOrder { 
        public static final ByteOrder BIG_ENDIAN;
        public static final ByteOrder LITTLE_ENDIAN; 
        //获取JVM运行平台的字节顺序
        public static ByteOrder nativeOrder();
        public String toString();
    }

ByteBuffer类有所不同:默认字节顺序总是ByteBuffer.BIG_ENDIAN,无论系统的固有字节顺序是什么。Java的默认字节顺序是大端字节顺序,这允许类文件等以及串行化的对象可以在任何JVM中工作。如果固有硬件字节顺序是小端,这会有性能隐患。在使用固有硬件字节顺序时,将ByteBuffer的内容当作其他数据类型存取很可能高效得多。

视图缓冲区的字节顺序设定在创建后不能被改变,而且如果原始的字节缓冲区的字节顺序在之后被改变,它也不会受到影响。。


4. 通道

通道(Channel)是java.nio的第二个主要创新。它们既不是一个扩展也不是一项增强,而是全新、极好的Java I/O示例,提供与I/O服务的直接连接。Channel用于在字节缓冲区和位于通道另一侧的实体(通常是一个文件或套接字)之间有效地传输数据。

4.1 通道基础

    package java.nio.channels;
    public interface Channel;
    {
        public boolean isOpen();
        public void close() throws IOException;
    }

与缓冲区不同,通道API主要由接口指定。不同的操作系统上通道实现(Channel Implementation)会有根本性的差异,所以通道API仅仅描述了可以做什么。因此很自然地,通道实现经常使用操作系统的本地代码。通道接口允许您以一种受控且可移植的方式来访问底层的I/O服务。

通道是访问I/O服务的导管。I/O可以分为广义的两大类别:File I/O和Stream I/O。那么相应地有两种类型的通道也就不足为怪了,它们是文件(file)通道和套接字(socket)通道 ———— 一个FileChannel类和三个socket通道类:SocketChannel、ServerSocketChannel和 DatagramChannel。

4.2 通道API
ByteChannel接口,它同时继承了ReadableByteChannel 和WritableByteChannel两个接口。ByteChannel接口本身并不定义新的API方法,它是一个聚集了所继承的多个接口,并重新命名的便捷接口。根据定义,实现ByteChannel接口的通道同时也会实现ReadableByteChannel 和WritableByteChannel两个接口,所以此类通道是双向的。这是简化类定义的语法糖(syntactic sugar),这样,使用instanceof操作符测试通道对象更简单了。

通道可以以阻塞(blocking)或非阻塞(nonblocking)模式运行。非阻塞模式的通道永远不会让调用的线程休眠。请求的操作要么立即完成,要么返回一个结果表明未进行任何操作。只有面向流的(stream-oriented)的通道,如sockets和pipes才能使用非阻塞模式。

socket通道类继承了SelectableChannel。继承SelectableChannel的类可以和Selector一起使用,后者支持就绪选择(readiness selection)。将非阻塞I/O和选择器组合起来可以使您的程序利用多路复用I/O(multiplexed I/O)。

与缓冲区不同,通道不能被重复使用。一个打开的通道即代表与一个特定I/O服务的特定连接并封装该连接的状态。当通道关闭时,那个连接会丢失,然后通道将不再连接任何东西。

调用通道的close( )方法时,可能会导致在通道关闭底层I/O服务的过程中线程暂时阻塞,哪怕该通道处于非阻塞模式。通道关闭时的阻塞行为(如果有的话)是高度取决于操作系统或者文件系统的。在一个通道上多次调用close( )方法是没有坏处的,但是如果第一个线程在close( )方法中阻塞,那么在它完成关闭通道之前,任何其他调用close( )方法都会阻塞。后续在该已关闭的通道上调用close( )不会产生任何操作,只会立即返回。

通道引入了一些与关闭和中断有关的新行为。如果一个通道实现InterruptibleChannel接口,它的行为以下述语义为准:如果一个线程在一个通道上被阻塞并且同时被中断(由调用该被阻塞线程的interrupt( )方法的另一个线程中断),那么该通道将被关闭,该被阻塞线程也会产生一个ClosedByInterruptException异常。

此外,假如一个线程的中断状态被设置,并且该线程试图访问一个通道,那么这个通道将立即被关闭,同时将抛出相同的ClosedByInterruptException异常。线程的中断状态在线程的interrupt( )方法被调用时会被设置。我们可以使用isInterrupted( )来测试某个线程当前的中断状态。当前线程的中断状态可以通过调用静态的Thread.interrupted( )方法清除。

可中断的通道也是可以异步关闭的。实现InterruptibleChannel接口的通道可以在任何时候被关闭,即使有另一个被阻塞的线程在等待该通道上的一个I/O操作完成。当一个通道被关闭时,休眠在该通道上的所有线程都将被唤醒并接收到一个AsynchronousCloseException异常。接着通道就被关闭并将不再可用。

4.3 文件通道API
FileChannel类可以实现常用的read,write以及scatter/gather操作,同时它也提供了很多专用于文件的新方法。这些方法中的许多都是我们所熟悉的文件操作。

    package java.nio.channels;
    public abstract class FileChannel extends AbstractChannel implements ByteChannel, GatheringByteChannel, ScatteringByteChannel {
        // This is a partial API listing
        // All methods listed here can throw java.io.IOException
        public abstract int read (ByteBuffer dst, long position);
        public abstract int write (ByteBuffer src, long position);
        public abstract long size();
        public abstract long position();
        public abstract void position (long newPosition);
        public abstract void truncate (long size);
        public abstract void force (boolean metaData);
        public final FileLock lock();
        public abstract FileLock lock (long position, long size, boolean shared);
        public final FileLock tryLock();
        public abstract FileLock tryLock (long position, long size, boolean shared);
        public abstract MappedByteBuffer map (MapMode mode, long position, long size);
        public static class MapMode;
        public static final MapMode READ_ONLY;
        public static final MapMode READ_WRITE;
        public static final MapMode PRIVATE;
        public abstract long transferTo (long position, long count, WritableByteChannel target);
        public abstract long transferFrom (ReadableByteChannel src, long position, long count);
    }

文件通道总是阻塞式的,因此不能被置于非阻塞模式。现代操作系统都有复杂的缓存和预取机制,使得本地磁盘I/O操作延迟很少。网络文件系统一般而言延迟会多些,不过却也因该优化而受益。面向流的I/O的非阻塞范例对于面向文件的操作并无多大意义,这是由文件I/O本质上的不同性质造成的。对于文件I/O,最强大之处在于异步I/O(asynchronous I/O),它允许一个进程可以从操作系统请求一个或多个I/O操作而不必等待这些操作的完成。发起请求的进程之后会收到它请求的I/O操作已完成的通知。

FileChannel对象是线程安全(thread-safe)的。多个进程可以在同一个实例上并发调用方法而不会引起任何问题,不过并非所有的操作都是多线程的(multithreaded)。影响通道位置或者影响文件大小的操作都是单线程的(single-threaded)。如果有一个线程已经在执行会影响通道位置或文件大小的操作,那么其他尝试进行此类操作之一的线程必须等待。并发行为也会受到底层的操作系统或文件系统影响。

每个FileChannel对象都同一个文件描述符(file descriptor)有一对一的关系,所以上面列出的API方法与在您最喜欢的POSIX(可移植操作系统接口)兼容的操作系统上的常用文件I/O系统调用紧密对应也就不足为怪了。本质上讲,RandomAccessFile类提供的是同样的抽象内容。在通道出现之前,底层的文件操作都是通过RandomAccessFile类的方法来实现的。FileChannel模拟同样的I/O服务,因此它的API自然也是很相似的。

FILECHANNEL RANDOMACCESSFILE POSIX SYSTEM CALL
read( ) read( ) read( )
write( ) write( ) write( )
size( ) length( ) fstat( )
position( ) getFilePointer( ) lseek( )
position (long newPosition) seek( ) lseek( )
truncate( ) setLength( ) ftruncate( )
force( ) getFD().sync( ) fsync( )

尝试在文件末尾之外的position进行一个绝对读操作,size( )方法会返回一个end-of-file。在超出文件大小的position上做一个绝对write( )会导致文件增加以容纳正在被写入的新字节。文件中位于之前end-of-file位置和新添加的字节起始位置之间区域的字节的值不是由FileChannel类指定,而是在大多数情况下反映底层文件系统的语义。取决于操作系统和(或)文件系统类型,这可能会导致在文件中出现一个空洞。

文件锁定API
有关FileChannel实现的文件锁定模型的一个重要注意项是:锁的对象是文件而不是通道或线程,这意味着文件锁不适用于协调同一台Java虚拟机上的多个线程发起的访问。我们使用锁来协调外部进程,而不是协调同一个Java虚拟机上的线程。如果您需要控制多个Java线程的并发访问,您可能需要实施您自己的、轻量级的锁定方案。那种情形下,内存映射文件可能是一个合适的选择。

这次我们先看FileChannel带参数形式的lock()方法。锁是在文件内部区域上获得的。调用带参数的Lock()方法会指定文件内部锁定区域的开始position以及锁定区域的size。第三个参数 shared表示您想获取的锁是共享的(参数值为true)还是独占的(参数值为false)。要获得一个共享锁,您必须先以只读权限打开文件,而请求独占锁时则需要写权限。另外,您提供的position和size参数的值不能是负数。

锁定区域的范围不一定要限制在文件的size值以内,锁可以扩展从而超出文件尾。因此,我们可以提前把待写入数据的区域锁定,我们也可以锁定一个不包含任何文件内容的区域,比如文件最后一个字节以外的区域。如果之后文件增长到达那块区域,那么您的文件锁就可以保护该区域的文件内容了。相反地,如果您锁定了文件的某一块区域,然后文件增长超出了那块区域,那么新增加的文件内容将不会受到您的文件锁的保护。

如果您正请求的锁定范围是有效的,那么lock()方法会阻塞,它必须等待前面的锁被释放。假如您的线程在此情形下被暂停,该线程的行为受中断语义控制。如果通道被另外一个线程关闭,该暂停线程将恢复并产生一个AsynchronousCloseException异常。假如该暂停线程被直接中断(通过调用它的interrupt( )方法),它将醒来并产生一个FileLockInterruptionException异常。如果在调用lock( )方法时线程interrupt status已经被设置,也会产生FileLockInterruptionException异常。

    public abstract class FileLock {
        public final FileChannel channel(); 
        public final long position();
        public final long size();
        public final boolean isShared();
        public final boolean overlaps (long position, long size);
        public abstract boolean isValid();
        public abstract void release() throws IOException;
    }

FileLock类封装一个锁定的文件区域。FileLock对象由FileChannel创建并且总是关联到那个特定的通道实例。您可以通过调用channel( )方法来查询一个lock对象以判断它是由哪个通道创建的。

一个FileLock对象创建之后即有效,直到它的release( )方法被调用或它所关联的通道被关闭或Java虚拟机关闭时才会失效。我们可以通过调用isValid( )布尔方法来测试一个锁的有效性。一个锁的有效性可能会随着时间而改变,不过它的其他属性——位置(position)、范围大小(size)和独占性(exclusivity)——在创建时即被确定,不会随着时间而改变。

您可以通过调用isShared( )方法来测试一个锁以判断它是共享的还是独占的。如果底层的操作系统或文件系统不支持共享锁,那么该方法将总是返回false值,即使您申请锁时传递的参数值是true。假如您的程序依赖共享锁定行为,请测试返回的锁以确保您得到了您申请的锁类型。FileLock对象是线程安全的,多个线程可以并发访问一个锁对象。

请小心管理文件锁以避免出现此问题。一旦您成功地获取了一个文件锁,如果随后在通道上出现错误的话,请务必释放这个锁。

4.4 内存映射文件
新的FileChannel类提供了一个名为map( )的方法,该方法可以在一个打开的文件和一个特殊类型的ByteBuffer之间建立一个虚拟内存映射。在FileChannel上调用map( )方法会创建一个由磁盘文件支持的虚拟内存映射(virtual memory mapping)并在那块虚拟内存空间外部封装一个MappedByteBuffer对象。

由map( )方法返回的MappedByteBuffer对象的行为在多数方面类似一个基于内存的缓冲区,只不过该对象的数据元素存储在磁盘上的一个文件中。调用get( )方法会从磁盘文件中获取数据,此数据反映该文件的当前内容,即使在映射建立之后文件已经被一个外部进程做了修改。通过文件映射看到的数据同您用常规方法读取文件看到的内容是完全一样的。相似地,对映射的缓冲区实现一个put( )会更新磁盘上的那个文件(假设对该文件您有写的权限),并且您做的修改对于该文件的其他阅读者也是可见的。

通过内存映射机制来访问一个文件会比使用常规方法读写高效得多,甚至比使用通道的效率都高。因为不需要做明确的系统调用,那会很消耗时间。更重要的是,操作系统的虚拟内存可以自动缓存内存页(memory page)。这些页是用系统内存来缓存的,所以不会消耗Java虚拟机内存堆(memory heap)。

一旦一个内存页已经生效(从磁盘上缓存进来),它就能以完全的硬件速度再次被访问而不需要再次调用系统命令来获取数据。那些包含索引以及其他需频繁引用或更新的内容的巨大而结构化文件能因内存映射机制受益非常多。如果同时结合文件锁定来保护关键区域和控制事务原子性,那您将能了解到内存映射缓冲区如何可以被很好地利用。

您应该注意到了没有unmap( )方法。也就是说,一个映射一旦建立之后将保持有效,直到MappedByteBuffer对象被施以垃圾收集动作为止。同锁不一样的是,映射缓冲区没有绑定到创建它们的通道上。关闭相关联的FileChannel不会破坏映射,只有丢弃缓冲区对象本身才会破坏该映射。NIO设计师们之所以做这样的决定是因为当关闭通道时破坏映射会引起安全问题,而解决该安全问题又会导致性能问题。如果您确实需要知道一个映射是什么时候被破坏的,他们建议使用虚引用(phantom references,参见java.lang.ref.PhantomReference)和一个cleanup线程。不过有此需要的概率是微乎其微的。

因为MappedByteBuffers也是ByteBuffers,所以能够被传递SocketChannel之类通道的read()或write()以有效传输数据给被映射的文件或从被映射的文件读取数据。如能再结合scatter/gather,那么从内存缓冲区和被映射文件内容中组织数据就变得很容易了。

当我们为一个文件建立虚拟内存映射之后,文件数据通常不会因此被从磁盘读取到内存(这取决于操作系统)。该过程类似打开一个文件:文件先被定位,然后一个文件句柄会被创建,当您准备好之后就可以通过这个句柄来访问文件数据。对于映射缓冲区,虚拟内存系统将根据您的需要来把文件中相应区块的数据读进来。这个页验证或防错过程需要一定的时间,因为将文件数据读取到内存需要一次或多次的磁盘访问。某些场景下,您可能想先把所有的页都读进内存以实现最小的缓冲区访问延迟。如果文件的所有页都是常驻内存的,那么它的访问速度就和访问一个基于内存的缓冲区一样了。

4.5 Socket通道
新的socket通道类可以运行非阻塞模式并且是可选择的。这两个性能可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性。本节中我们会看到,再也没有为每个socket连接使用一个线程的必要了,也避免了管理大量线程所需的上下文交换总开销。借助新的NIO类,一个或几个线程就可以管理成百上千的活动socket连接了并且只有很少甚至可能没有性能损失。

所有的socket通道类(DatagramChannel、SocketChannel和ServerSocketChannel)都继承了位于java.nio.channels.spi包中的AbstractSelectableChannel。这意味着我们可以用一个Selector对象来执行socket通道的就绪选择(readiness selection)。

请注意DatagramChannel和SocketChannel实现定义读和写功能的接口而ServerSocketChannel不实现。ServerSocketChannel负责监听传入的连接和创建新的SocketChannel对象,它本身从不传输数据。

在我们具体讨论每一种socket通道前,您应该了解socket和socket通道之间的关系。之前的章节中有写道,通道是一个连接I/O服务导管并提供与该服务交互的方法。就某个socket而言,它不会再次实现与之对应的socket通道类中的socket协议API,而java.net中已经存在的socket通道都可以被大多数协议操作重复使用。

全部socket通道类(DatagramChannel、SocketChannel和ServerSocketChannel)在被实例化时都会创建一个对等socket对象。这些是我们所熟悉的来自java.net的类(Socket、ServerSocket和DatagramSocket),它们已经被更新以识别通道。对等socket可以通过调用socket( )方法从一个通道上获取。此外,这三个java.net类现在都有getChannel( )方法。

Socket通道将与通信协议相关的操作委托给相应的socket对象。socket的方法看起来好像在通道类中重复了一遍,但实际上通道类上的方法会有一些新的或者不同的行为。

要把一个socket通道置于非阻塞模式,我们要依靠所有socket通道类的公有超级类:SelectableChannel。就绪选择(readiness selection)是一种可以用来查询通道的机制,该查询可以判断通道是否准备好执行一个目标操作,如读或写。非阻塞I/O和可选择性是紧密相连的,那也正是管理阻塞模式的API代码要在SelectableChannel超级类中定义的原因。

设置或重新设置一个通道的阻塞模式是很简单的,只要调用configureBlocking( )方法即可,传递参数值为true则设为阻塞模式,参数值为false值设为非阻塞模式。真的,就这么简单!您可以通过调用isBlocking( )方法来判断某个socket通道当前处于哪种模式。

非阻塞socket通常被认为是服务端使用的,因为它们使同时管理很多socket通道变得更容易。但是,在客户端使用一个或几个非阻塞模式的socket通道也是有益处的,例如,借助非阻塞socket通道,GUI程序可以专注于用户请求并且同时维护与一个或多个服务器的会话。在很多程序上,非阻塞模式都是有用的。

偶尔地,我们也会需要防止socket通道的阻塞模式被更改。API中有一个blockingLock( )方法,该方法会返回一个非透明的对象引用。返回的对象是通道实现修改阻塞模式时内部使用的。只有拥有此对象的锁的线程才能更改通道的阻塞模式。

4.5.1 ServerSocketChannel
让我们从最简单的ServerSocketChannel来开始对socket通道类的讨论。以下是ServerSocketChannel的完整API:

    public abstract class ServerSocketChannel extends AbstractSelectableChannel {
        public static ServerSocketChannel open() throws IOException;
        public abstract ServerSocket socket();
        public abstract ServerSocket accept()throws IOException;
        public final int validOps();
    }

ServerSocketChannel是一个基于通道的socket监听器。它同我们所熟悉的java.net.ServerSocket执行相同的基本任务,不过它增加了通道语义,因此能够在非阻塞模式下运行。

由于ServerSocketChannel没有bind( )方法,因此有必要取出对等的socket并使用它来绑定到一个端口以开始监听连接。我们也是使用对等ServerSocket的API来根据需要设置其他的socket选项。

同它的对等体java.net.ServerSocket一样,ServerSocketChannel也有accept( )方法。一旦您创建了一个ServerSocketChannel并用对等socket绑定了它,然后您就可以在其中一个上调用accept( )。如果您选择在ServerSocket上调用accept( )方法,那么它会同任何其他的ServerSocket表现一样的行为:总是阻塞并返回一个java.net.Socket对象。如果您选择在ServerSocketChannel上调用accept( )方法则会返回SocketChannel类型的对象,返回的对象能够在非阻塞模式下运行。

如果以非阻塞模式被调用,当没有传入连接在等待时,ServerSocketChannel.accept( )会立即返回null。正是这种检查连接而不阻塞的能力实现了可伸缩性并降低了复杂性。可选择性也因此得到实现。我们可以使用一个选择器实例来注册一个ServerSocketChannel对象以实现新连接到达时自动通知的功能。以下代码演示了如何使用一个非阻塞的accept( )方法:

    package com.ronsoft.books.nio.channels;
    import java.nio.ByteBuffer;
    import java.nio.channels.ServerSocketChannel;
    import java.nio.channels.SocketChannel;
    import java.net.InetSocketAddress;

    public class ChannelAccept {
        public static final String GREETING = "Hello I must be going.\r\n";
        public static void main (String [] argv) throws Exception
        {
            int port = 1234; // default
            if (argv.length > 0) {
                port = Integer.parseInt (argv [0]);
            }
            ByteBuffer buffer = ByteBuffer.wrap (GREETING.getBytes());
            ServerSocketChannel ssc = ServerSocketChannel.open();
            ssc.socket().bind (new InetSocketAddress (port));
            ssc.configureBlocking (false);
            while (true) {
                System.out.println ("Waiting for connections");
                SocketChannel sc = ssc.accept(); 
                if (sc == null) {
                    Thread.sleep (2000);
                } else {
                    System.out.println ("Incoming connection from: " + sc.socket().getRemoteSocketAddress());
                    buffer.rewind();
                    sc.write (buffer);
                    sc.close();
                }
            }
        }
    }

4.5.2 SocketChannel
下面开始学习SocketChannel,它是使用最多的socket通道类:

4.5.3 DatagramChannel
最后一个socket通道是DatagramChannel。正如SocketChannel对应Socket,ServerSocketChannel对应ServerSocket,每一个DatagramChannel对象也有一个关联的DatagramSocket对象。不过原命名模式在此并未适用:“DatagramSocketChannel”显得有点笨拙,因此采用了简洁的“DatagramChannel”名称。

正如SocketChannel模拟连接导向的流协议(如TCP/IP),DatagramChannel则模拟包导向的无连接协议(如UDP/IP)。

DatagramChannel是无连接的。每个数据报(datagram)都是一个自包含的实体,拥有它自己的目的地址及不依赖其他数据报的数据负载。与面向流的的socket不同,DatagramChannel可以发送单独的数据报给不同的目的地址。同样,DatagramChannel对象也可以接收来自任意地址的数据包。每个到达的数据报都含有关于它来自何处的信息(源地址)。


5. 选择器

选择器提供选择执行已经就绪的任务的能力,这使得多路复用I/O成为可能。就绪选择和多路复用使得单线程能够有效率地同时管理多个I/O通道(channels)。C/C++代码的工具箱中,许多年前就已经有select()和poll()这两个POSIX(可移植性操作系统接口)系统调用可供使用了。许过操作系统也提供相似的功能,但对Java程序员来说,就绪选择功能直到JDK 1.4才成为可行的方案。对于主要的工作经验都是基于Java环境的开发的程序员来说,之前可能还没有碰到过这种I/O模型。

从最基础的层面来看,选择器提供了询问通道是否已经准备好执行每个I/0 操作的能力。例如,我们需要了解一个SocketChannel对象是否还有更多的字节需要读取,或者我们需要知道ServerSocketChannel是否有需要准备接受的连接。

好像只要非阻塞模式就可以模拟就绪检查功能,但实际上还不够。即使简单地询问每个通道是否已经就绪的方法是可行的,在您的代码或一个类库的包里的某些代码需要遍历每一个候选的通道并按顺序进行检查的时候,仍然是有问题的。这会使得在检查每个通道是否就绪时都至少进行一次系统调用,这种代价是十分昂贵的,但是主要的问题是,这种检查不是原子性的。列表中的一个通道都有可能在它被检查之后就绪,但直到下一次轮询为止,您并不会觉察到这种情况。最糟糕的是,您除了不断地遍历列表之外将别无选择。您无法在某个您感兴趣的通道就绪时得到通知。

这就是为什么传统的监控多个socket的Java解决方案是为每个socket创建一个线程并使得线程可以在read( )调用中阻塞,直到数据可用。这事实上将每个被阻塞的线程当作了socket监控器,并将Java虚拟机的线程调度当作了通知机制。这两者本来都不是为了这种目的而设计的。程序员和Java虚拟机都为管理所有这些线程的复杂性和性能损耗付出了代价,这在线程数量的增长失控时表现得更为突出。

真正的就绪选择必须由操作系统来做。操作系统的一项最重要的功能就是处理I/O请求并通知各个线程它们的数据已经准备好了。选择器类提供了这种抽象,使得Java代码能够以可移植的方式,请求底层的操作系统提供就绪选择服务。

5.1 选择器基础

选择器(Selector)

选择器类管理着一个被注册的通道集合的信息和它们的就绪状态。通道是和选择器一起被注册的,并且使用选择器来更新通道的就绪状态。当这么做的时候,可以选择将被激发的线程挂起,直到有就绪的的通道。

可选择通道(SelectableChannel)

这个抽象类提供了实现通道的可选择性所需要的公共方法。它是所有支持就绪检查的通道类的父类。FileChannel对象不是可选择的,因为它们没有继承SelectableChannel。所有socket通道都是可选择的,包括从管道(Pipe)对象的中获得的通道。SelectableChannel可以被注册到Selector对象上,同时可以指定对那个选择器而言,哪种操作是感兴趣的。一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次。

选择键(SelectionKey)

选择键封装了特定的通道与特定的选择器的注册关系。选择键对象被SelectableChannel.register() 返回并提供一个表示这种注册关系的标记。选择键包含了两个位集合(以整数的形式进行编码),指示了该注册关系所关心的通道操作,以及通道已经准备好的操作。

5.2 选择器API
让我们看看SelectableChannel的相关API方法

    public abstract class SelectableChannel extends AbstractChannel implements Channel {
        // This is a partial API listing
        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();
    }

非阻塞特性与多路复用特性的关系是十分密切的——以至于java.nio的架构将两者的API放到了一个类中。

调用可选择通道的register()方法会将它注册到一个选择器上。如果您试图注册一个处于阻塞状态的通道,register()将抛出未检查的IllegalBlockingModeException异常。此外,通道一旦被注册,就不能回到阻塞状态。试图这么做的话,将在调用configureBlocking( )方法时将抛出IllegalBlockingModeException异常。

在我们进一步了解register( )和SelectableChannel的其他方法之前,让我们先了解一下Selector类的API,以确保我们可以更好地理解这种关系:

    public abstract class Selector {
        public static Selector open() throws IOException;
        public abstract boolean isOpen();
        public abstract void close() throws IOException;
        public abstract SelectionProvider provider();
        public abstract int select() throws IOException;
        public abstract int select (long timeout) throws IOException;
        public abstract int selectNow() throws IOException;
        public abstract void wakeup();
        public abstract Set keys();
        public abstract Set selectedKeys();
    }

尽管SelectableChannel类上定义了register()方法,还是应该将通道注册到选择器上,而不是另一种方式。选择器维护了一个需要监控的通道的集合。一个给定的通道可以被注册到多于一个的选择器上,而且不需要知道它被注册了那个Selector对象上。将register()放在SelectableChannel上而不是Selector上,这种做法看起来有点随意。它将返回一个封装了两个对象的关系的选择键对象。重要的是要记住选择器对象控制了被注册到它之上的通道的选择过程。选择器才是提供管理功能的对象,而不是可选择通道对象。选择器对象对注册到它之上的通道执行就绪选择,并管理选择键。

对于键的interest(感兴趣的操作)集合和ready(已经准备好的操作)集合的解释是和特定的通道相关的。每个通道的实现,将定义它自己的选择键类。在register( )方法中构造它并将它传递给所提供的选择器对象。

5.3 建立选择器
为了建立监控三个Socket通道的选择器,您需要做像这样的事情:

Selector selector = Selector.open();
channel1.register (selector, SelectionKey.OP_READ);
channel2.register (selector, SelectionKey.OP_WRITE);
channel3.register (selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);
// Wait up to 10 seconds for a channel to become ready
readyCount = selector.select (10000);

这些代码创建了一个新的选择器,然后将这三个(已经存在的)socket通道注册到选择器上,而且感兴趣的操作各不相同。select( )方法在将线程置于睡眠状态,直到这些刚兴趣的事情中的操作中的一个发生或者10秒钟的时间过去。

Selector对象是通过调用静态工厂方法open()来实例化的。选择器不是像通道或流(stream)那样的基本I/O对象:数据从来没有通过它们进行传递。类方法open()向SPI发出请求,通过默认的
SelectorProvider对象获取一个新的实例。通过调用一个自定义的SelectorProvider对象的openSelector()方法来创建一个Selector实例也是可行的。您可以通过调用provider()方法来决定由哪个SelectorProvider对象来创建给定的Selector实例。大多数情况下,您不需要关心SPI;只需要调用open( )方法来创建新的Selector对象。

继续关于将Select作为I/O对象进行处理的话题的探讨:当您不再使用它时,需要调用close( )方法来释放它可能占用的资源并将所有相关的选择键设置为无效。一旦一个选择器被关闭,试图调用它的大多数方法都将导致ClosedSelectorException。注意ClosedSelectorException是一个非检查(运行时的)错误。您可以通过isOpen( )方法来测试一个选择器是否处于被打开的状态。

就像之前提到的那样,register()方法位于SelectableChannel类,尽管通道实际上是被注册到选择器上的。您可以看到register()方法接受一个Selector对象作为参数,以及一个名为ops的整数参数。第二个参数表示所关心的通道操作。这是一个表示选择器在检查通道就绪状态时需要关心的操作的位掩码。特定的操作位值在SelectonKey类中被定义为public static字段。

在JDK 1.4 中,有四种被定义的可选择操作:读(read),写(write),连接(connect)和接受(accept)。并非所有的操作都在所有的可选择通道上被支持。例如,SocketChannel不支持accept。试图注册不支持的操作将导致IllegalArgumentException。您可以通过调用validOps( )方法来获取特定的通道所支持的操作集合。

一个单独的通道对象可以被注册到多个选择器上。可以调用isRegistered( )方法来检查一个通道是否被注册到任何一个选择器上。这个方法没有提供关于通道被注册到哪个选择器上的信息,而只能知道它至少被注册到了一个选择器上。此外,在一个键被取消之后,直到通道被注销为止,可能有时间上的延迟。这个方法只是一个提示,而不是确切的答案。

任何一个通道和选择器的注册关系都被封装在一个SelectionKey对象中。keyFor( )方法将返回与该通道和指定的选择器相关的键。如果通道被注册到指定的选择器上,那么相关的键将被返回。如果它们之间没有注册关系,那么将返回null。

5.4 使用选择键
让我们看看SelectionKey类的API:

    public abstract class 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;
        public abstract SelectableChannel channel();
        public abstract Selector selector();
        public abstract void cancel();
        public abstract boolean isValid();
        public abstract int interestOps();
        public abstract void interestOps (int ops);
        public abstract int readyOps();
        public final boolean isReadable();
        public final boolean isWritable();
        public final boolean isConnectable();
        public final boolean isAcceptable();
        public final Object attach (Object ob);
        public final Object attachment();
    }

键对象表示了一种特定的注册关系。当应该终结这种关系的时候,可以调用SelectionKey对象的cancel( )方法。可以通过调用isValid( )方法来检查它是否仍然表示一种有效的关系。当键被取消时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效(参见4.3节)。当再次调用select( )方法时(或者一个正在进行的select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。通道会被注销,而新的SelectionKey将被返回。

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

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

可以通过调用键的readyOps( )方法来获取相关的通道的已经就绪的操作。ready集合是interest集
合的子集,并且表示了interest集合中从上次调用select()以来已经就绪的那些操作。例如,下面的代
码测试了与键关联的通道是否就绪。如果就绪,就将数据读取出来,写入一个缓冲区,并将它送到
一个consumer(消费者)方法中。

if ((key.readyOps() & SelectionKey.OP_READ) != 0)
{
    myBuffer.clear();
    key.channel().read (myBuffer);
    doSomethingWithBuffer (myBuffer.flip()); 
}

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

5.5 使用选择器
在详细了解API之前,您需要知道一点和Selector内部工作原理相关的知识。选择器维护着注册过的通道的集合,并且这些注册关系中的任意一个都是封装在SelectionKey对象中的。每一个Selector对象维护三个键的集合。

已注册的键的集合(Registered key set)

与选择器关联的已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过keys( )方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引起java.lang.UnsupportedOperationException。

已选择的键的集合(Selected key set)

已注册的键的集合的子集。这个集合的每个成员都是相关的通道被选择器(在前一个选择操作中)判断为已经准备好的,并且包含于键的interest集合中的操作。这个集合通过selectedKeys()方法返回(并有可能是空的)。

不要将已选择的键的集合与ready集合弄混了。这是一个键的集合,每个键都关联一个已经准备好至少一种操作的通道。每个键都有一个内嵌的ready集合,指示了所关联的通道已经准备好的操作。键可以直接从这个集合中移除,但不能添加。试图向已选择的键的集合中添加元素将抛出java.lang.UnsupportedOperationException。

已取消的键的集合(Cancelled key set)
注册的键的集合的子集,这个集合包含了cancel( )方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。

在一个刚初始化的Selector对象中,这三个集合都是空的。

Selector类的核心是选择过程。这个名词您已经在之前看过多次了——现在应该解释一下了。基本上来说,选择器是对select()、poll()等本地调用(native call)或者类似的操作系统特定的系统调用的一个包装。但是Selector所作的不仅仅是简单地向本地代码传送参数。它对每个选择操作应用了特定的过程。对这个过程的理解是合理地管理键和它们所表示的状态信息的基础。

选择操作是select()被调用时,由选择器执行的。调用时下面步骤将被执行:

  1. 已取消的键的集合将会被检查。如果它是非空的,每个已取消的键的集合中的键将从另外两个集合中移除,并且相关的通道将被注销。这个步骤结束后,已取消的键的集合将是空的。

  2. 已注册的键的集合中的键的interest集合将被检查。在这个步骤中的检查执行过后,对interest集合的改动不会影响剩余的检查过程。

    一旦就绪条件被定下来,底层操作系统将会进行查询,以确定每个通道所关心的操作的真实就绪状态。依赖于特定的select( )方法调用,如果没有通道已经准备好,线程可能会在这时阻塞,通常会有一个超时值。直到系统调用完成为止,这个过程可能会使得调用线程睡眠一段时间,然后当前每个通道的就绪状态将确定下来。对于那些还没准备好的通道将不会执行任何的操作。对于那些操作系统指示至少已经准备好interest集合中的一种操作的通道,将执行以下两种操作中的一种:

    • 如果通道的键还没有处于已选择的键的集合中,那么键的ready集合将被清空,然后表示操作系统发现的当前通道已经准备好的操作的位掩码将被设置。

    • 否则,也就是键在已选择的键的集合中。键的ready集合将被表示操作系统发现的当前已经准备好的操作的位掩码更新。所有之前的已经不再是就绪状态的操作不会被清除。事实上,所有的比特位都不会被清理。由操作系统决定的ready集合是与之前的ready集合按位分离的,一旦键被放置于选择器的已选择的键的集合中,它的ready集合将是累积的。比特位只会被设置,不会被清理。

  3. 步骤2可能会花费很长时间,特别是所激发的线程处于休眠状态时。与该选择器相关的键可能会同时被取消。当步骤2结束时,步骤1将重新执行,以完成任意一个在选择进行的过程中,键已经被取消的通道的注销。

  4. select操作返回的值是ready集合在步骤2中被修改的键的数量,而不是已选择的键的集合中的通道的总数。返回值不是已准备好的通道的总数,而是从上一个select( )调用之后进入就绪状态的通道的数量。之前的调用中就绪的,并且在本次调用中仍然就绪的通道不会被计入,而那些在前一次调用中已经就绪但已经不再处于就绪状态的通道也不会被计入。这些通道可能仍然在已选择的键的集合中,但不会被计入返回值中。返回值可能是0。

使用内部的已取消的键的集合来延迟注销,是一种防止线程在取消键时阻塞,并防止与正在进行的选择操作冲突的优化。注销通道是一个潜在的代价很高的操作,这可能需要重新分配资源(请记住,键是与通道相关的,并且可能与它们相关的通道对象之间有复杂的交互)。清理已取消的键,并在选择操作之前和之后立即注销通道,可以消除它们可能正好在选择的过程中执行的潜在棘手问题。这是另一个兼顾健壮性的折中方案。

Selector类的select( )方法有以下三种不同的形式:

这三种select的形式,仅仅在它们在所注册的通道当前都没有就绪时,是否阻塞的方面有所不同。最简单的没有参数的形式可以用如下方式调用:
int n = selector.select()
这种调用在没有通道就绪时将无限阻塞。一旦至少有一个已注册的通道就绪,选择器的选择键就会被更新,并且每个就绪的通道的ready集合也将被更新。返回值将会是已经确定就绪的通道的数目。正常情况下,这些方法将返回一个非零的值,因为直到一个通道就绪前它都会阻塞。但是它也可以返回0,如果选择器的wakeup( )方法被其他线程调用。

有时您会想要限制线程等待通道就绪的时间。这种情况下,可以使用一个接受一个超时参数的select( )方法的重载形式:
int n = selector.select (10000);
这种调用与之前的例子完全相同,除了如果在您提供的超时时间(以毫秒计算)内没有通道就绪时,它将返回0。如果一个或者多个通道在时间限制终止前就绪,键的状态将会被更新,并且方法会在那时立即返回。将超时参数指定为0表示将无限期等待,那么它就在各个方面都等同于使用无参数版本的select( )了。

就绪选择的第三种也是最后一种形式是完全非阻塞的:
int n = selector.selectNow();
selectNow()方法执行就绪检查过程,但不阻塞。如果当前没有通道就绪,它将立即返回0。

Selector的API中的最后一个方法,wakeup(),提供了使线程从被阻塞的select()方法中优雅地退出的能力。

有三种方式可以唤醒在select()方法中睡眠的线程:

调用wakeup()

调用Selector对象的wakeup()方法将使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有在进行中的选择,那么下一次对select()方法的一种形式的调用将立即返回。后续的选择操作将正常进行。在选择操作之间多次调用wakeup( )方法与调用它一次没有什么不同。有时这种延迟的唤醒行为并不是您想要的。您可能只想唤醒一个睡眠中的线程,而使得后续的选择继续正常地进行。您可以通过在调用wakeup( )方法后调用selectNow( )方法来绕过这个问题。尽管如此,如果您将您的代码构造为合理地关注于返回值和执行选择集合,那么即使下一个select( )方法的调用在没有通道就绪时就立即返回,也应该不会有什么不同。不管怎么说,您应该为可能发生的事件做好准备。

调用close()
如果选择器的close( )方法被调用,那么任何一个在选择操作中阻塞的线程都将被唤醒,就像wakeup( )方法被调用了一样。与选择器相关的通道将被注销,而键将被取消。

调用interrupt()
如果睡眠中的线程的interrupt()方法被调用,它的返回状态将被设置。如果被唤醒的线程之后将试图在通道上执行I/O操作,通道将立即关闭,然后线程将捕捉到一个异常。这是由于通道的中断语义。使用wakeup()方法将会优雅地将一个在select( )方法中睡眠的线程唤醒。如果您想让一个睡眠的线程在直接中断之后继续执行,需要执行一些步骤来清理中断状态(参见Thread.interrupted( )的相关文档)。

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

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

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

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

    package com.yangyong.channel;

    import java.net.InetSocketAddress;
    import java.net.ServerSocket;
    import java.nio.ByteBuffer;
    import java.nio.channels.SelectableChannel;
    import java.nio.channels.SelectionKey;
    import java.nio.channels.Selector;
    import java.nio.channels.ServerSocketChannel;
    import java.nio.channels.SocketChannel;
    import java.util.Iterator;

    /** * Simple echo-back server which listens for incoming stream connections and * echoes back whatever it reads. A single Selector object is used to listen to * the server socket (to accept new connections) and all the active socket * channels. * * @author Ron Hitchens ([email protected]) */
    public class SelectSockets {
        public static int PORT_NUMBER = 1234;

        public static void main(String[] argv) throws Exception
        {
            new SelectSockets().go(argv);
        }

        public void go(String[] argv) throws Exception
        {
            int port = PORT_NUMBER;
            if (argv.length > 0)
            { // Override default listen port
                port = Integer.parseInt(argv[0]);
            }
            System.out.println("Listening on port " + port);
            // Allocate an unbound server socket channel
            ServerSocketChannel serverChannel = ServerSocketChannel.open();
            // Get the associated ServerSocket to bind it with
            ServerSocket serverSocket = serverChannel.socket();
            // Create a new Selector for use below
            Selector selector = Selector.open();
            // Set the port the server channel will listen to
            serverSocket.bind(new InetSocketAddress(port));
            // Set nonblocking mode for the listening socket
            serverChannel.configureBlocking(false);
            // Register the ServerSocketChannel with the Selector
            serverChannel.register(selector, SelectionKey.OP_ACCEPT);
            while (true)
            {
                // This may block for a long time. Upon returning, the
                // selected set contains keys of the ready channels.
                int n = selector.select();
                if (n == 0)
                {
                    continue; // nothing to do
                }
                // Get an iterator over the set of selected keys
                Iterator<SelectionKey> it = selector.selectedKeys().iterator();
                // Look at each key in the selected set
                while (it.hasNext())
                {
                    SelectionKey key = (SelectionKey) it.next();
                    // Is a new connection coming in?
                    if (key.isAcceptable())
                    {
                        ServerSocketChannel server = (ServerSocketChannel) key.channel();
                        SocketChannel channel = server.accept();
                        registerChannel(selector, channel, SelectionKey.OP_READ);
                        sayHello(channel);
                    }
                    // Is there data to read on this channel?
                    if (key.isReadable())
                    {
                        readDataFromSocket(key);
                    }
                    // Remove key from selected set; it's been handled
                    it.remove();
                }
            }
        }

        /** * Register the given channel with the given selector for the given * operations of interest */
        protected void registerChannel(Selector selector, SelectableChannel channel, int ops) throws Exception
        {
            if (channel == null)
            {
                return; // could happen
            }
            // Set the new channel nonblocking
            channel.configureBlocking(false);
            // Register it with the selector
            channel.register(selector, ops);
        }

        // Use the same byte buffer for all channels. A single thread is
        // servicing all the channels, so no danger of concurrent acccess.
        private ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

        /** * Sample data handler method for a channel with data ready to read. * * @param key A SelectionKey object associated with a channel determined by * the selector to be ready for reading. If the channel returns 142 * an EOF condition, it is closed here, which automatically * invalidates the associated key. The selector will then de-register * the channel on the next select call. */
        protected void readDataFromSocket(SelectionKey key) throws Exception
        {
            SocketChannel socketChannel = (SocketChannel) key.channel();
            int count;
            buffer.clear(); // Empty buffer
            // Loop while data is available; channel is nonblocking
            while ((count = socketChannel.read(buffer)) > 0)
            {
                buffer.flip(); // Make buffer readable
                // Send the data; don't assume it goes all at once
                while (buffer.hasRemaining())
                {
                    socketChannel.write(buffer);
                }
                // WARNING: the above loop is evil. Because
                // it's writing back to the same nonblocking
                // channel it read the data from, this code can
                // potentially spin in a busy loop. In real life
                // you'd do something more useful than this.
                buffer.clear(); // Empty buffer
            }
            if (count < 0)
            {
                // Close channel on EOF, invalidates the key
                socketChannel.close();
            }
        }

        /** * Spew a greeting to the incoming client connection. * * @param channel The newly connected SocketChannel to say hello to. */
        private void sayHello(SocketChannel channel) throws Exception
        {
            buffer.clear();
            buffer.put("Hi there!\r\n".getBytes());
            buffer.flip();
            channel.write(buffer);
        }
    }

这个简单的服务器创建了ServerSocketChannel和Selector对象,并将通道注册到选择器上。我们不在注册的键中保存服务器socket的引用,因为它永远不会被注销。这个无限循环在最上面先调用了select(),这可能会无限期地阻塞。当选择结束时,就遍历选择键并检查已经就绪的通道。

如果一个键指示与它相关的通道已经准备好执行一个accecpt( )操作,我们就通过键获取关联的通道,并将它转换为SeverSocketChannel对象。我们都知道这么做是安全的,因为只有ServerSocketChannel支持OP_ACCEPT操作。我们也知道我们的代码只把对一个单一的ServerSocketChannel对象的OP_ACCEPT操作进行了注册。通过对服务器socket通道的引用,我们调用了它的accept( )方法,来获取刚到达的socket的句柄。返回的对象的类型是SocketChannel,也是一个可选择的通道类型。这时,与创建一个新线程来从新的连接中读取数据不同,我们只是简单地将socket注册到选择器上。我们通过传入OP_READ标记,告诉选择器我们关心新的socket通道什么时候可以准备好读取数据。

如果键指示通道还没有准备好执行accept( ),我们就检查它是否准备好执行read( )。任何一个这么指示的socket通道一定是之前ServerSocketChannel创建的SocketChannel对象之一,并且被注册为只对读操作感兴趣。对于每个有数据需要读取的socket通道,我们调用一个公共的方法来读取并处理这个带有数据的socket。需要注意的是这个公共方法需要准备好以非阻塞的方式处理socket上的不完整的数据。它需要迅速地返回,以其他带有后续输入的通道能够及时地得到处理。

在循环的底部,我们通过调用Iterator(迭代器)对象的remove()方法,将键从已选择的键的集合中移除。键可以直接从selectKeys()返回的Set中移除,但同时需要用Iterator来检查集合,您需要使用迭代器的remove()方法来避免破坏迭代器内部的状态。

5.6 并发性和异步可关闭性
选择器对象是线程安全的,但它们包含的键集合不是。通过keys( )和selectKeys( )返回的键的集合是Selector对象内部的私有的Set对象集合的直接引用。这些集合可能在任意时间被改变。已注册的键的集合是只读的。如果您试图修改它,那么您得到的奖品将是一个
java.lang.UnsupportedOperationException,但是当您在观察它们的时候,它们可能发生了改变的话,您仍然会遇到麻烦。Iterator对象是快速失败的(fail-fast):如果底层的Set被改变了,它们将会抛出java.util.ConcurrentModificationException,因此如果您期望在多个线程间共享选择器和/或键,请对此做好准备。您可以直接修改选择键,但请注意您这么做时可能会彻底破坏另一个线程的Iterator。

如果在多个线程并发地访问一个选择器的键的集合的时候存在任何问题,您可以采取一些步骤来合理地同步访问。在执行选择操作时,选择器在Selector对象上进行同步,然后是已注册的键的集合,最后是已选择的键的集合,按照这样的顺序。已取消的键的集合也在选择过程的的第1步和第3步之间保持同步(当与已取消的键的集合相关的通道被注销时)。

在多线程的场景中,如果您需要对任何一个键的集合进行更改,不管是直接更改还是其他操作带来的副作用,您都需要首先以相同的顺序,在同一对象上进行同步。锁的过程是非常重要的。如果竞争的线程没有以相同的顺序请求锁,就将会有死锁的潜在隐患。如果您可以确保否其他线程不会同时访问选择器,那么就不必要进行同步了。

Selector类的close( )方法与slect( )方法的同步方式是一样的,因此也有一直阻塞的可能性。在选择过程还在进行的过程中,所有对close( )的调用都会被阻塞,直到选择过程结束,或者执行选择的线程进入睡眠。

关闭通道的过程不应该是一个耗时的操作。NIO的设计者们特别想要阻止这样的可能性:一个线程在关闭一个处于选择操作中的通道时,被阻塞于无限期的等待。当一个通道关闭时,它相关的键也就都被取消了。这并不会影响正在进行的select( ),但这意味着在您调用select( )时仍然是有效的键,在返回时可能会变为无效。您总是可以使用由选择器的selectKeys( )方法返回的已选择的键的集合:请不要自己维护键的集合。

对单CPU的系统而言使用一个线程来为多个通道提供服务可能是一个好主意,因为在任何情况下都只有一个线程能够运行。通过消除在线程之间进行上下文切换带来的额外开销,总吞吐量可以得到提高。但对于一个多CPU的系统呢?在一个有n个CPU的系统上,当一个单一的线程线性地轮流处理每一个线程时,可能有n-1个cpu处于空闲状态。

某些通道要求比其他通道更高的响应速度,可以通过使用两个选择器来解决:一个为命令连接服务,另一个为普通连接服务。但这种场景也可以使用与第一个场景十分相似的办法来解决。与将所有准备好的通道放到同一个线程池的做法不同,通道可以根据功能由不同的工作线程来处理。它们可能可以是日志线程池,命令/控制线程池,状态请求线程池,等等(使用线程池来为通道提供服务)。


参考文献以及相关网址:

  1. Java NIO
  2. 细说Java NIO
  3. NIO 入门
  4. Java中各种IOStream以及NIO Chanel的性能比较

你可能感兴趣的:(java NIO)