Java I/O流(2)-- NIO、AIO

NIO

新的输入/输出 (NIO) 库是在 JDK 1.4 中引入的,弥补了原来的 I/O 的不足,提供了高速的、面向块的 I/O。NIO本身是基于事件驱动思想来完成的,其主要想解决的是BIO的大并发问题:在使用同步I/O的网络应用中,如果要同时处理多个客户端请求,或是在客户端要同时和多个服务器进行通讯,就必须使用多线程来处理。也就是说,将每一个客户端请求分配给一个线程来单独处理。这样做虽然可以达到我们的要求,但同时又会带来另外一个问题。由于每创建一个线程,就要为这个线程分配一定的内存空间(也叫工作存储器),而且操作系统本身也对线程的总数有一定的限制。如果客户端的请求过多,服务端程序可能会因为不堪重负而拒绝客户端的请求,甚至服务器可能会因此而瘫痪。

I/O 与 NIO 最重要的区别是数据打包和传输的方式,I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。

BIO与NIO一个比较重要的不同,是我们使用BIO的时候往往会引入多线程,每个连接一个单独的线程;而NIO则是使用单线程或者只使用少量的多线程,所有连接共用一个线程。

Java I/O流(2)-- NIO、AIO_第1张图片
Java I/O流(2)-- NIO、AIO_第2张图片

NIO的最重要的地方是当一个连接创建后,不需要对应一个线程,这个连接会被注册到多路复用器上面,所以所有的连接只需要一个线程就可以搞定,当这个线程中的多路复用器进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。在NIO的处理方式中,当一个请求来的话,开启线程进行处理,可能会等待后端应用的资源(JDBC连接等),其实这个线程就被阻塞了,当并发上来的话,还是会有BIO一样的问题。    

NIO主要有三大核心部分:Channel(通道)Buffer(缓冲区)Selector(选择器)。传统IO基于字节流和字符流进行操作,而NIO基于Channel和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。

IO的各种流是同步阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。 NIO是一种同步非阻塞的IO模型。同步是指线程不断轮询IO事件是否就绪,非阻塞是指线程在等待IO的时候,可以同时做其他任务。同步的核心就是Selector,Selector代替了线程本身轮询IO事件,避免了阻塞同时减少了不必要的线程消耗;非阻塞的核心就是通道和缓冲区,当IO事件就绪时,可以通过写道缓冲区,保证IO的成功,而无需线程阻塞式地等待。

NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。

Java I/O流(2)-- NIO、AIO_第3张图片

Buffer(缓冲区):

缓冲区,实际上是一个ByteBuffer数组。发送给一个通道的所有数据都必须首先放到缓冲区中,同样地,从通道中读取的任何数据都要先读到缓冲区中。也就是说,不会直接对通道进行读写数据,而是要先经过缓冲区。缓冲区实质上是一个数组,但它不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。

缓冲区包括以下类型:

ByteBuffer

CharBuffer

ShortBuffer

IntBuffer

LongBuffer

FloatBuffer

DoubleBuffer

为什么说NIO是基于缓冲区的IO方式呢?因为,当一个链接建立完成后,IO的数据未必会马上到达,为了当数据到达时能够正确完成IO操作,在BIO(阻塞IO)中,等待IO的线程必须被阻塞,以全天候地执行IO操作。为了解决这种IO方式低效的问题,引入了缓冲区的概念,当数据到达时,可以预先被写入缓冲区,再由缓冲区交给线程,因此线程无需阻塞地等待IO。

缓冲区状态变量

capacity:最大容量;

position:当前已经读写的字节数;

limit:还可以读写的字节数。

状态变量的改变过程举例:

1)新建一个大小为 8 个字节的缓冲区,此时 position 为 0,而 limit = capacity = 8。capacity 变量不会改变,下面的讨论会忽略它。

Java I/O流(2)-- NIO、AIO_第4张图片

 2)从输入通道中读取 5 个字节数据写入缓冲区中,此时 position 为 5,limit 保持不变。

Java I/O流(2)-- NIO、AIO_第5张图片

3)在将缓冲区的数据写到输出通道之前,需要先调用 flip() 方法,这个方法将limit 设置为当前 position,并将 position 设置为 0。

Java I/O流(2)-- NIO、AIO_第6张图片

4)从缓冲区中取 4 个字节到输出缓冲中,此时 position 设为 4。

Java I/O流(2)-- NIO、AIO_第7张图片

5)最后需要调用 clear() 方法来清空缓冲区,此时 position 和 limit 都被设置为最初位置。

Java I/O流(2)-- NIO、AIO_第8张图片

文件 NIO 实例

以下展示了使用 NIO 快速复制文件的实例:

public staticvoid fastCopy(String src, String dist) throws IOException {

    /*获得源文件的输入字节流*/

    FileInputStream fin = new FileInputStream(src);

    /* 获取输入字节流的文件通道*/

    FileChannel fcin = fin.getChannel();

    /*获取目标文件的输出字节流*/

    FileOutputStream fout = new FileOutputStream(dist);

    /*获取输出字节流的文件通道*/

    FileChannel fcout = fout.getChannel();

    /*为缓冲区分配 1024 个字节*/

    ByteBuffer buffer =ByteBuffer.allocateDirect(1024);


     while (true) {

        /*从输入通道中读取数据到缓冲区中*/

        int r = fcin.read(buffer);

        /* read()返回 -1 表示EOF */

        if (r == -1) {

            break;

        }

        /*切换读写*/

        buffer.flip();

        /*把缓冲区的内容写入输出文件中*/

        fcout.write(buffer);

        /*清空缓冲区*/

        buffer.clear();

    }

}

Channel通道):

通道 Channel 是对原 I/O 包中的流的模拟,可以通过它读取和写入数据。通道分两种(网络读写和文件读写)。通道与流的不同之处在于,流只能在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而通道是双向的,可以用于读、写或者同时用于读写。

通道包括以下类型:

FileChannel:从文件中读写数据;

DatagramChannel:通过 UDP 读写网络中数据;

SocketChannel:通过 TCP 读写网络中数据;

ServerSocketChannel:可以监听新进来的 TCP 连接,对每一个新进来的连接都会创建一个 SocketChannel。


以SocketChannel为例:

当执行SocketChannel.write(Buffer),便将一个 buffer 写到了一个通道中。如果说缓冲区还好理解,通道相对来说就更加抽象。引用Java NIO中权威的说法:通道是 I/O 传输发生时通过的入口,而缓冲区是这些数据传输的来源或目标。对于离开缓冲区的传输,想传递出去的数据被置于一个缓冲区,被传送到通道。对于传回缓冲区的传输,一个通道将数据放置在所提供的缓冲区中。

   例如有一个服务器通道ServerSocketChannel serverChannel,一个客户端通道SocketChannel clientChannel;服务器缓冲区:serverBuffer(内存中),客户端缓冲区:clientBuffer(内存中)。

   当服务器想向客户端发送数据时,需要调用:clientChannel.write(serverBuffer),数据从serverBuffer写入clientChannel。

   当客户端要读时,调用 clientChannel.read(clientBuffer),从clientChannel读取数据到clientBuffer。

   当客户端想向服务器发送数据时,需要调用:serverChannel.write(clientBuffer)。当服务器要读时,调用serverChannel.read(serverBuffer)


Selector(选择器):

多路复用器,负责轮询Channel,找出就绪状态的Channel。实现机制在Linux 2.6之前是select、poll,2.6之后是epoll,所以没有最大连接句柄1024/2048的限制。所以,只需要一个线程负责Selector的轮询,就可以接入成千上万的客户端。NIO 常常被叫做非阻塞 IO,主要是因为 NIO 在网络通信中的非阻塞特性被广泛使用。NIO 实现了 IO 多路复用中的 Reactor 模型,一个线程 Thread 使用一个选择器 Selector 通过轮询的方式去监听多个通道 Channel 上的事件,从而让一个线程就可以处理多个事件。

通过配置监听的通道 Channel 为非阻塞,那么当 Channel 上的 IO 事件还未到达时,就不会进入阻塞状态一直等待,而是继续轮询其它 Channel,找到 IO 事件已经到达的 Channel 执行。因为创建和切换线程的开销很大,因此使用一个线程来处理多个事件而不是一个线程处理一个事件,对于 IO 密集型的应用具有很好地性能。

应该注意的是,只有套接字 Channel 才能配置为非阻塞,而 FileChannel 不能,为 FileChannel 配置非阻塞也没有意义。

通道和缓冲区的机制,使得线程无需阻塞地等待IO事件的就绪,但是总是要有人来监管这些IO事件。这个工作就交给了selector来完成,这就是所谓的同步。

Selector允许单线程处理多个Channel。如果你的应用打开了多个连接(通道),但每个连接的流量都很低,使用Selector就会很方便。

Java I/O流(2)-- NIO、AIO_第9张图片

1. 创建选择器

Selector selector = Selector.open();

2. 将通道注册到选择器上

ServerSocketChannel ssChannel = ServerSocketChannel.open();

ssChannel.configureBlocking(false);

ssChannel.register(selector, SelectionKey.OP_ACCEPT);

通道必须配置为非阻塞模式,否则使用选择器就没有任何意义了,因为如果通道在某个事件上被阻塞,那么服务器就不能响应其它事件,必须等待这个事件处理完毕才能去处理其它事件,显然这和选择器的作用背道而驰。

在将通道注册到选择器上时,还需要指定要注册的具体事件,主要有以下几类:

SelectionKey.OP_CONNECT

SelectionKey.OP_ACCEPT

SelectionKey.OP_READ

SelectionKey.OP_WRITE

它们在 SelectionKey 的定义如下:

public static final int OP_READ = 1 << 0;

public static final int OP_WRITE = 1 << 2;

public static final int OP_CONNECT = 1 << 3;

public static final int OP_ACCEPT = 1 << 4;

可以看出每个事件可以被当成一个位域,从而组成事件集整数。例如:

int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;

3. 监听事件

int num = selector.select();

使用 select() 来监听到达的事件,它会一直阻塞直到有至少一个事件到达。

4. 获取到达的事件

Set keys = selector.selectedKeys();

Iterator keyIterator = keys.iterator();

while (keyIterator.hasNext()) {

    SelectionKey key = keyIterator.next();

    if (key.isAcceptable()){

        // ...

    } else if(key.isReadable()) {

        // ...

    }

    keyIterator.remove();

}

5. 事件循环

因为一次 select() 调用不能处理完所有的事件,并且服务器端有可能需要一直监听事件,因此服务器端处理事件的代码一般会放在一个死循环内。

while (true) {

    int num =selector.select();

    Set keys = selector.selectedKeys();

    Iterator keyIterator = keys.iterator();

    while(keyIterator.hasNext()) {

        SelectionKey key =keyIterator.next();

        if(key.isAcceptable()) {

            // ...

        } else if(key.isReadable()) {

            // ...

        }

        keyIterator.remove();

    }

}


优化:

    一种优化方式是:将Selector进一步分解为Reactor,将不同的感兴趣事件分开,每一个Reactor只负责一种感兴趣的事件。这样做的好处是:1、分离阻塞级别,减少了轮询的时间;2、线程无需遍历set以找到自己感兴趣的事件,因为得到的set中仅包含自己感兴趣的事件。

Java I/O流(2)-- NIO、AIO_第10张图片
Java I/O流(2)-- NIO、AIO_第11张图片

对比

BIO与NIO之间的共同点是他们都是同步的,而非异步的。

BIO是阻塞的(当前线程必须等待感兴趣的事情发生), NIO是非阻塞的(事件选择,感兴趣的事情发生可以通知线程,而不必一直在哪等待);

BIO是面向流式的IO抽象(一次一个字节地处理数据),NIO是面向块的IO抽象(每一个操作都在一步中产生或者消费一个数据块(Buffer));

BIO的服务器实现模式为一个连接一个线程,NIO服务器实现模式为一个请求一个线程;


AIO

最后我们再来看看 AIO,也就是 JDK 1.7 中的 NIO 2.0。它引入了新的异步通道的概念,提供了以下两种方式来获取异步操作的结果:

• 将来式:通过 java.util.concurrent.Future 表示异步操作的结果;

• 回调式:执行异步操作时,注册一个 CompletionHandler,作为操作完后后执行的回调。

对应于UNIX网络编程中的事件驱动I/O(AIO),他不需要过多的Selector对注册的通道进行轮询即可实现异步读写,从而简化了NIO的编程模型。与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。  即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。 

在JDK1.7中,这部分内容被称作NIO.2,主要在java.nio.channels包下增加了下面四个异步通道:

AsynchronousSocketChannel

AsynchronousServerSocketChannel

AsynchronousFileChannel

AsynchronousDatagramChannel

其中的read/write方法,会返回一个带回调函数的对象,当执行完读取/写入操作后,直接调用回调函数。


Java对BIO、NIO、AIO的支持:

BIO :同步阻塞IO,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,即使该连接不做任何事,或者使用频率很少都会造成服务器的线程开销,当线程数达到操作系统的限制,可能会延迟或者拒绝新的连接请求。其read方法自己阻塞自己等待有数据可读为止,所以说是同步阻塞。

 NIO :JDK1.4提出的同步非阻塞IO,服务器实现模式为一个请求一个线程,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求时才启动一个线程进行处理。NIO基于Reactor模式,其select底层调用操作系统的select/poll模型(Linux2.6采用epoll)不断的轮询,直到有数据可操作为止,这其实就是一种同步IO,当发现有数据操作之后进行的IO操作将不会被阻塞,即是非阻塞IO。

 AIO(NIO.2) :JDK1.7提出的异步非阻塞IO,服务器实现模式为一个有效请求一个线程,客户端的I/O请求都是由OS先完成了再通知服务器应用去启动线程进行处理。与NIO不同,AIO基于Proactor模式当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。在这种情况下,操作系统会负责将可读取的数据从内核空间转移至用户空间之后,才主动通知应用程序,应用程序不但不需要不停的轮询,并且数据的读取过程也更加快捷。

BIO、NIO、AIO适用场景分析:

BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。

NIO方式适用于连接数目多且连接比较短(轻操作)的架构,比如聊天服务器,并发局限于应用中,编程比较复杂,JDK1.4开始支持。

AIO方式使用于连接数目多且连接比较长(重操作)的架构,比如相册服务器,充分调用OS参与并发操作,编程比较复杂,JDK7开始支持。

Netty简介:

它是一个异步的、基于事件Client/Server的网络框架,目标是提供一种简单、快速构建网络应用的方式,同时保证高吞吐量、低延时、高可靠性。



参考:

https://matt33.com/2017/08/12/java-nio/

http://patchouli-know.com/2017/03/18/java-bio-nio-aio/

https://tech.meituan.com/2016/11/04/nio.html


参考书目:《Java编程思想》、《Java核心技术卷一》、《Netty权威指南》

你可能感兴趣的:(Java I/O流(2)-- NIO、AIO)