Java程序员面试笔试宝典(第2版)
上QQ阅读APP看书,第一时间看更新

2.4 NIO

在NIO(Nonblocking IO,非阻塞IO)出现之前,Java是通过传统的Socket来实现基本的网络通信功能的。以服务端为例,其实现基本流程如图2-3所示。

图2-3 Socket使用流程

如果客户端还没有对服务端发起连接请求,那么accept就会阻塞[阻塞指的是暂停一个线程的执行以等待某个条件发生(例如某资源就绪)]。如果连接成功,当数据还没有准备好的时候,对read的调用同样会阻塞。当要处理多个连接的时候,就需要采用多线程的方式,由于每个线程都拥有自己的栈空间,而且由于阻塞会导致大量线程进行上下文切换,使得程序的运行效率非常低下。因此在J2SE1.4中引入了NIO来解决这个问题。

NIO通过Selector、Channels和Buffers来实现非阻塞的IO操作。NIO是指New I/O,既然有New I/O,那么就会有Old I/O,Old I/O是指基于流的I/O方法。NIO是在Java 1.4中被纳入JDK中的,它最主要的特点是,提供了基于Selector的异步网络I/O,使得一个线程可以管理多个连接。下面给出基于NIO处理多个连接的结构图,如图2-4所示。

图2-4 NIO结构图

在介绍NIO的原理之前,首先介绍几个重要的概念:Channel(通道)、Buffer(缓冲区)和Selector(选择器)。

(1)Channel(通道)

为了更容易地理解什么是Channel,这里以InputStream为例来介绍什么是Channel。传统的IO中经常使用下面的代码来读取文件(此处忽略异常处理):

InputStream其实就是一个用来读取文件的通道。只不过InputStrem是一个单向的通道,只能用来读取数据。而NIO中的Channel是一个双向的通道,不仅能读取数据,而且还能写入数据。

(2)Buffer(缓冲区)

在上面的示例代码中,InputStream把读取到的数据放在了byte数组中,如果用OutputStream写数据,那么也可以把byte数组中的数据写到文件中。而在NIO中,数据只能被写到Buffer中,同理读取的数据也只能放在Buffer中,由此可见Buffer是Channel用来读写数据的非常重要的一个工具。

(3)Selector(选择器)

Selector是NIO中最重要的部分,是实现一个线程管理多个连接的关键,它的作用就是轮询所有被注册的Channel,一旦发现Channel上被注册的事件发生,就可以对这个事件进行处理。

2.4.1 Buffer

在Java NIO中,Buffer主要的作用就是与Channel进行交互。它本质上是一块可读写数据的内存,这块内存中有很多可以存储byte、int、char等的小单元。这块内存被包装成NIO Buffer对象,并提供了一组方法,来简化数据的读写。在Java NIO中,核心的Buffer有7类,如图2-5所示。

图2-5 Buffer的类图

为了更好地理解上面四个步骤,下面将重点介绍Buffer中几个非常重要的属性:capacity、position和limit。

1)capacity用来表示Buffer的容量,也就是刚开始申请的Buffer的大小。

2)position表示下一次读(写)的位置。

在写数据到Buffer中时,position表示当前可写的位置。初始的position值为0。当写入一个数据(例如int或short)到Buffer后,position会向前移动到下一个可插入数据的Buffer单元。position最大的值为capacity-1。

在读取数据时,也是从某个位置开始读。当从Buffer的position处读取数据完成时,position也会从向前位置移动到下一个可读的位置。

buffer从写入模式变为读取模式时,position会归零,每次读取后,position向后移动。

3)limit表示本次读(写)的极限位置。

在写入数据时,limit表示最多能往Buffer里写入多少数据,它等同于buffer的容量。

在读取数据时,limit表示最多能读到多少数据,也就是说position移动到limit时读操作会停止。它的值等同于写模式下position的位置。

为了更容易地理解这三个属性之间的关系,下面通过图2-6来说明。

从上图可以看出,在写模式中,position表示下一个可写入位置,一旦切换到读模式,position就会置0(可以从Buffer最开始的地方读数据),而此时这个Buffer的limit就是在读模式下的position,因为在position之后是没有数据的。

图2-6 Buffer的内部原理

在理解了Buffer的内部实现原理后,下面重点介绍如何使用Buffer。

(1)申请Buffer

在使用Buffer前必须先申请一块固定大小的内存空间来供Buffer使用,这个工作可以通过Buffer类提供的allocate()方法来实现。例如:

(2)向Buffer中写数据

可以通过Buffer的put方法来写入数据,也可以通过Channel向Buffer中写数据,例如:

(3)读写模式的转换

Buffer的flip()方法用来把Buffer从写模式转换为读模式,flip方法的底层实现原理为:把position置0,并把Buffer的limit设置为当前的position值。

(4)从Buffer中读取数据

与写数据类似,读数据也有两种方式,分别为:通过Buffer的get方法读取,或从buffer中读取数据到Channel中。例如:

当完成数据的读取后,需要调用clear()或compact()方法来清空Buffer,从而实现Buffer的复用。这两个方法的实现原理为:clear()方法会把position置0,把limit设置为capacity;由此可见,如果Buffer中还有未读的数据,那么clear()方法也会清理这部分数据。如果想保留这部分未读的数据,那么就需要调用compact()方法。下面以IntBuffer为例介绍compact()方法的实现原理:

将缓冲区当前位置和界限之间的int(如果有)复制到缓冲区的开始处。即将索引p=position()处的int复制到索引0处,将索引p+1处的int复制到索引1处,依此类推,直到将索引limit()-1处的int复制到索引n=limit()-1-p处。然后将缓冲区的位置设置为n+1,并将其界限设置为其容量。如果已定义了标记,那么丢弃它。

(5)重复读取数据

Buffer还有另外一个重要的重复读取数据的方法:rewind(),它的实现原理如下:只把position的值置0,而limit保持不变,使用rewind()方法可以实现对Buffer中的数据进行重复的读取。

由此可见在NIO中使用Buffer的时候,通常都需要遵循如下4个步骤:

1)向Buffer中写入数据。

2)调用flip()方法把Buffer从写模式切换到读模式。

3)从Buffer中读取数据。

4)调用clear()方法或compact()方法来清空Buffer。

(6)标记与复位。

Buffer中还有两个非常重要的方法:mark()和reset()。mark()方法用来标记当前的position,一旦标记完成,在任何时刻都可以使用reset()方法来把position恢复到标记的值。

2.4.2 Channel

在NIO中,数据的读写都是通过Channel(通道)来实现的。Channel与传统的“流”非常类似,只不过Channel不能直接访问数据,而只能与Buffer进行交互,也就是说Channel只能通过buffer来实现数据的读写。如图2-7所示。

图2-7 Channel与Buffer的关系

虽然通道与流有很多相似的地方,但是它们也有很多区别,下面主要介绍3个区别:

1)通道是双向的,既可以读也可以写。但是大部分流都是单向的,只能读或者写。

2)通道可以实现异步的读写,大部分流只支持同步的读写。

3)通道的读写只能通过Buffer来完成。

在Java语言中,主要有以下4个常见的Channel的实现:

1)FileChannel:用来读写文件;

2)DatagramChannel:用来对UDP的数据进行读写;

3)SocketChannel:用来对TCP的数据进行读写,一般用作客户端实现;

4)ServerSocketChannel:用来监听TCP的连接请求,然后针对每个请求会创建一个SocketChannel,一般被用作服务器实现。

下面通过一个例子来介绍FileChannel的使用方法:

程序的运行结果为:

2.4.3 Selector

Selector表示选择器或者多路复用器。它主要的功能为轮询检查多个通道的状态,判断通道注册的事件是否发生,也就是说判断通道是否可读或可写。然后根据发生事件的类型对这个通道做出对应的响应。由此可见,一个Selector完全可以用来管理多个连接,由此大大提高了系统的性能。这一节将重点介绍Selector的使用方法。

(1)创建Selector

Selector的创建非常简单,只需要调用Selector的静态方法open就可以创建一个Selector,示例代码如下所示:

一旦Selector被创建出来,接下来就需要把感兴趣的Channel的事件注册给Selector了。

(2)注册Channel的事件到Selector

由于Selector需要轮询多个Channel,因此注册的Channel必须是非阻塞的。在注册前需要使用下面的代码来把channel注册为非阻塞的。

配置完成后就可以使用下面的代码来注册感兴趣的事件了:

需要注意的是,只有继承了SelectableChannel或AbstractSelectableChannel的类才有configureBlocking这个方法。常用的SocketChannel和ServerSocketChannel都是继承自AbstractSelectableChannel的,因此它们都有configureBlocking方法,可以注册到Selector上。

register方法用来向给定的选择器注册此通道,并返回一个选择键。

第一个参数表示要向其注册此通道的选择器;第二个参数表示的是感兴趣的键的可用操作集,键的取值有下面四种或者是它们的组合(SelectionKey.OP_READ|SelectionKey.OP_WRITE):

(3)SelectionKey

向Selector注册Channel的时候,register方法会返回一个SelectionKey的对象,这个对象表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系。它主要包含如下的一些属性:

1)interest集合。interest集合表示Selector对这个通道感兴趣的事件的集合,通常会使用位操作来判断Selector对哪些事件感兴趣,如下例所示:

2)ready集合。ready集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,会首先访问这个ready集合。可以使用位操作来检查某一个事件是否就绪。在实际编程中,经常使用下面的方法来判断事件是否就绪:

3)附加对象。可以把一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道,有两种方法来给SelectionKey添加附加对象:

(4)使用Selector选择Channel

如果对Selector注册了一个或多个通道,那么就可以使用select方法来获取那些准备就绪的通道(如果对读事件感兴趣,那么会返回读就绪的通道;如果对写事件感兴趣,那么会获取写就绪的通道)。select方法主要有下面三种重载方式:

1)select():选择一组键,其相应的通道已为I/O操作准备就绪。此方法执行处于阻塞模式的选择操作。仅在至少选择一个通道、调用此选择器的wakeup方法,或者当前的线程已中断(以先到者为准)后此方法才返回。

2)select(long timeout):此方法执行处于阻塞模式的选择操作。仅在至少选择一个通道、调用此选择器的wakeup方法、当前的线程已中断,或者给定的超时期满(以先到者为准)后此方法才返回。

3)int selectNow():此方法执行非阻塞的选择操作。如果自从前一次选择操作后,没有通道变成可选择的,那么此方法直接返回零。

一旦select()方法的返回值表示有通道就绪了,此时就可以通过selector的selectedKeys()方法来获取那些就绪的通道。示例代码如下所示:

下面给出一个Selector简单的使用示例:

1)服务端代码:

2)客户端代码:

2.4.4 AIO

从上面的介绍可以看出BIO使用同步阻塞的方式工作的,而NIO则使用的是异步阻塞的方式。对于NIO而言,它最重要的作用是当一个连接创建后,不需要对应一个线程,这个连接会被注册到多路复用器上面,所以所有的连接只需要一个线程就可以管理,当这个线程中的多路复用器进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。

在NIO的处理方式中,当一个请求来的话,开启线程进行处理,但是它仍然需要使用阻塞的方式读取数据,显然在这种情况下这个线程就被阻塞了,在高并发的环境下,也会有一定的性能的问题。造成这个问题的主要原因就是NIO仍然使用了同步的IO。

AIO是对NIO的改进(所以AIO又称NIO.2),它是基于Proactor模型实现的。

在IO读写的时候,如果想把IO请求与读写操作分离调配进行,那么就需要用到事件分离器。根据处理机制的不同,事件分离器又分为:同步的Reactor和异步的Proactor。为了更好地理解AIO与NIO的区别,下面首先简要介绍一下Reactor模型与Proactor模型的区别:

Reactor模型

它的工作原理为(以读操作为例):

1)应用程序在事件分离器上注册“读就绪事件”与“读就绪事件处理器”;

2)事件分离器会等待读就绪事件发生;

3)一旦读就绪事件发生,事件分离器就会被激活,分离器就会调用“读就绪事件处理器”;

4)此时读就绪处理器就知道有数据可以读了,然后开始读取数据,把读到的数据提交程序使用。

Proactor模型

1)应用程序在事件分离器上注册“读完成事件”和“读完成事件处理器”,并向操作系统发出异步读请求;

2)事件分离器会等待操作系统完成数据读取;

3)在操作系统完成数据的读取并将结果数据存入用户自定义缓冲区后会通知事件分离器读操作完成;

4)事件分离器监听到“读完成事件”后会激活“读完成事件处理器”;

5)读完成事件处理器此时就可以把读取到的数据提供给应用程序使用。

由此可以看出它们的主要区别为:在Reactor模型中,应用程序需要负责数据的读取操作;而在Proactor模型中,应用程序不需要负责读取数据。由此可以看出,AIO的处理流程如下所示:

1)每个socket连接在事件分离器注册“IO完成事件”和“IO完成事件处理器”;

2)应用程序需要进行IO操作时,会向分离器发出IO请求并把所需的Buffer区域告诉分离器,分离器则会通知操作系统进行IO操作;

3)操作系统则尝试IO操作,等操作完成后会通知分离器;

4)分离器检测到IO完成事件后,就激活IO完成事件处理器,处理器会通知应用程序,接着应用程序就可以直接从Buffer区进行数据的读写。

在AIO socket编程中,服务端通道是AsynchronousServerSocketChannel,这个类提供了一个open()静态工厂,一个bind()方法用于绑定服务端IP地址(还有端口号),另外还提供了accept()用于接收用户连接请求。在客户端使用的通道是AsynchronousSocketChannel,这个通道除了提供open静态工厂方法外,还提供了read和write方法。

在AIO编程中,当应用程序发出一个事件(accept、read或write等)后需要指定事件处理类(也就是回调函数),AIO中使用的事件处理类是CompletionHandler<V,A>,这个接口有如下两个方法:分别在异步操作成功和失败时被回调。

下面给出一个简单的AIO的使用示例,在实例中服务器端只是简单地回显客户端发送的数据。

服务端代码:

客户端代码: