你不得不懂的Java NIO

一、背景

  大家都知道Java BIO,其全称是java blocking IO,相对的Java NIO 全称为java non-blocking IO。顾名思义,java nio 是一种非阻塞IO。NIO是为了弥补IO操作的不足而诞生的,NIO的一些新特性有:非阻塞I/O,选择器,缓冲以及管道。管道(Channel),缓冲(Buffer) ,选择器( Selector)是其主要特征。提供基于缓冲区(buffer)的块写入/读取,而以前的I/O是基于流(Stream)的方式,NIO基于块的IO操作,将最耗时的缓存区读取和填充交由底层操作系统实现,因此速度上要快得多。

二、NIO的名词解释

1、Buffer

  Buffer是一个对象,它用来存放即将发送的数据和即将到来的数据。发送给一个通道的所有对象都必须首先放到缓冲区中;同样地,从通道中读取的任何数据都要读到缓冲区中。Buffer是NIO核心思想,它与普通流IO的区别是,普通流IO直接把数据写入或读取到Stream对象中,而NIO是先把读写数据交给Buffer,后在用流处理的。Buffer实际上就是一个数组,通常是字节数组,但是这个数组提供了访问数据的读写等操作属性,如位置,容量,上限等概念。

  在NIO中,Buffer是一个顶层父类,它是一个抽象类,常用的Buffer的子类有:ByteBuffer、IntBuffer、CharBuffer、LongBuffer、DoubleBuffer、FloatBuffer、ShortBuffer。如果是对于文件读写,上面几种Buffer都可能会用到。但是对于网络读写来说,用的最多的是ByteBuffer。     

  前面提到,buffer实际上是一个封装的字节数组,有两个重要组件:状态变量和访问方法。而buffer提供的访问数据的读写等操作属性,如位置,容量,上限等概念是依靠状态变量实现的。可以用三个值指定缓冲区在任意时刻的状态:

    • position:跟踪已经写了多少数据。position 总是小于或者等于 limit
    • limit:表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。limit 决不能大于 capacity。
    • capacity:表明可以储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小 ― 或者至少是指定了准许我们使用的底层数组的容量。

  向Buffer中写数据有两种方式:

(1) 从Channel写到Buffer。如:int bytesRead = inChannel.read(buf); //read into buffer.

(2) 通过Buffer的put()方法写到Buffer里。如:buf.put(127); 

  从Buffer中读取数据也有两种方式:

(1)从Buffer读取数据到Channel。如: int bytesWritten = inChannel.write(buf);

(2)使用get()方法从Buffer中读取数据。如:byte aByte = buf.get();

2、Channel (通道)

  与Stream(流)的不同之处在于通道是双向的,流只能在一个方向上操作(一个流必须是InputStream或者OutputStream的子类),而通道可以用于读,写或者二者同时进行,最关键的是可以和多路复用器结合起来,提供状态位,多路复用器可识别Channel所处的状态。 

  通道可以分两大类:用于网络读写的SelectableChannel,和用于文件操作的FileChannel。具体来说:通过FileChannel可以从文件读或者向文件写入数据;通过SocketChannel,以TCP来向网络连接的两端读写数据;通过ServerSocketChanel能够监听客户端发起的TCP连接,并为每个TCP连接创建一个新的SocketChannel来进行数据读写;通过DatagramChannel,以UDP协议来向网络连接的两端读写数据。

3、Selector

  Selector提供选择已经就绪的任务的能力。简单说,就是Selector会不断轮询注册在Selector上的通道(Channel),如果这个通道发生了读写操作,这个通道就会处于就绪状态,会被Selector察觉到,然后通过SelectionKey可以取出就绪的Channel集合,从而进行IO操作。

   一个Selector可以负责成千上万的通道,没有上限。这也是JDK使用了epoll代替传统的Select实现,获得连接句柄没有限制。意味着我们只需要一个线程负责Selector的轮询,就可以接入成百上千的客户端,这是JDK NIO库的巨大进步。

  Selector类是NIO的核心类,Selector能够检测多个注册的通道上是否有事件发生,如果有事件发生,便获取事件然后针对每个事件进行相应的响应处理。这样一来,只是用一个单线程就可以管理多个通道,也就是管理多个连接。这样使得只有在连接真正有读写事件发生时,才会调用函数来进行读写,就大大地减少了系统开销,并且不必为每个连接都创建一个线程,不用去维护多个线程,并且避免了多线程之间的上下文切换导致的开销。

  与Selector有关的一个关键类是SelectionKey,一个SelectionKey表示一个到达的事件,这2个类构成了服务端处理业务的关键逻辑。

三、标准的NIO步骤

一个简单(标准)的NIO输入输出一般包含如下步骤: 
1. 从数据源获取通道 ;
2. 分配缓冲区 ;
3. 切换缓存区为写模式; 
4. 从通道读取数据写入缓冲区; 
5. 切换缓冲区为读模式 ;
6. 缓冲区数据写入通道中 ;
7. 关闭资源。

package com.denny.aio.test;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

public class Test {

    public static void main(String[] args) throws IOException {
        
        RandomAccessFile formFile = new RandomAccessFile("src\\a.txt", "rw");
        RandomAccessFile toFile = new RandomAccessFile("src\\b.txt", "rw");
        
        //获取channel
        FileChannel fromChannel = formFile.getChannel();
        FileChannel toChannel = toFile.getChannel();
        
        // 定义缓冲大小
        int bufSize = 1024*4;
        
        // 定义缓冲
        ByteBuffer byteBuffer = ByteBuffer.allocate(bufSize);
        
        int len = 0;
        
        // 将数据从源channel写入到缓冲区
        while(  (len=fromChannel.read(byteBuffer)) !=-1 ){
            
            //切换到读模式
            byteBuffer.flip();
            
            //读取缓冲区数据写到目标channel
            toChannel.write(byteBuffer);
            
            // 清空缓冲
            byteBuffer.clear();
        }
        
        // 释放资源
        toChannel.close();
        fromChannel.close();
    }
}

  

  注意:

将数据写到输出通道中。在这之前,我们必须调用 flip() 方法。这个方法做两件非常重要的事:

(1) 它将 limit 设置为当前 position。这意味着它包括以前读到的所有字节,并且一个字节也不多。

(2)它将 position 设置为 0。这意味着我们得到的下一个字节是第一个字节。

最后一步是调用缓冲区的 clear() 方法。这个方法重设缓冲区以便接收更多的字节。 Clear 做两种非常重要的事情:

(1)它将 limit 设置为与 capacity 相同。

(2)它设置 position 为 0。

四、NIO和传统的IO区别

1,IO是面向流的,NIO是面向块(缓冲区)的。

        IO面向流的操作一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。,导致了数据的读取和写入效率不佳;

        NIO面向块的操作在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多,同时数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。通俗来说,NIO采取了“预读”的方式,当你读取某一部分数据时,他就会猜测你下一步可能会读取的数据而预先缓冲下来。

2,IO是阻塞的,NIO是非阻塞的。

        对于传统的IO,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。

        而对于NIO,使用一个线程发送读取数据请求,没有得到响应之前,线程是空闲的,此时线程可以去执行别的任务,而不是像IO中那样只能等待响应完成。而在NIO的非阻塞模式下,线程发送数据与接收数据都是通过“通道”进行的,线程只需要去询问通道是否有数据需要处理,有则处理,无则立即返回不会进行等待。线程通常将非阻塞IO的空闲时间用于处理其他通道上的IO事件,使用一个单独的线程就可以管理多个输入和输出通道。那么NIO是怎么实现非阻塞的呢?其实原理很简单,NIO是面向块的,先把数据搬运过来,存放到一个缓冲区中,线程过一段时间来缓冲区看看,有没有数据,这个样线程就不需要始终关注IO了。

3,NIO和IO适用场景

        NIO是为弥补传统IO的不足而诞生的,但是尺有所短寸有所长,NIO也有缺点,因为NIO是面向缓冲区的操作,每一次的数据处理都是对缓冲区进行的,那么就会有一个问题,在数据处理之前必须要判断缓冲区的数据是否完整或者已经读取完毕,如果没有,假设数据只读取了一部分,那么对不完整的数据处理没有任何意义。所以每次数据处理之前都要检测缓冲区数据。

那么NIO和IO各适用的场景是什么呢?

        如果需要管理同时打开的成千上万个连接,这些连接每次只是发送少量的数据,例如聊天服务器,这时候用NIO处理数据可能是个很好的选择。

        而如果只有少量的连接,而这些连接每次要发送大量的数据,这时候传统的IO更合适。使用哪种处理数据,需要在数据的响应等待时间和检查缓冲区数据的时间上作比较来权衡选择。

 

  对于NIO和传统IO,有一个网友讲的生动的例子:以前的流总是堵塞的,一个线程只要对它进行操作,其它操作就会被堵塞,也就相当于水管没有阀门,你伸手接水的时候,不管水到了没有,你就都只能耗在接水(流)上。nio的Channel的加入,相当于增加了水龙头(有阀门),虽然一个时刻也只能接一个水管的水,但依赖轮换策略,在水量不大的时候,各个水管里流出来的水,都可以得到妥善接纳,这个关键之处就是增加了一个接水工,也就是Selector,他负责协调,也就是看哪根水管有水了的话,在当前水管的水接到一定程度的时候,就切换一下:临时关上当前水龙头,试着打开另一个水龙头(看看有没有水)。当其他人需要用水的时候,不是直接去接水,而是事前提了一个水桶给接水工,这个水桶就是Buffer。也就是,其他人虽然也可能要等,但不会在现场等,而是回家等,可以做其它事去,水接满了,接水工会通知他们。

  这其实也是非常接近当前社会分工细化的现实,也是统分利用现有资源达到并发效果的一种很经济的手段,而不是动不动就来个并行处理,虽然那样是最简单的,但也是最浪费资源的方式。

文中部分参考自:https://www.cnblogs.com/bootdo/p/10431789.html

猜你喜欢

转载自www.cnblogs.com/DDgougou/p/10520865.html