JAVA之NIO

148 阅读13分钟

NIO是什么

NIO (New lO)也有人称之为java non-blocking lO是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java lO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件的读写操作。NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式。

  • NIO相关类都被放在java.nio包及子包下,并且对原java.io包中的很多类进行改写。
  • NIO有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)
  • Java NlO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。
  • 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有1000个请求过来,根据实际情况,可以分配20或者80个线程来处理。不像之前的阻塞IO那样,非得分配1000个。

NIO与BIO的区别

  • BIO以流的方式处理数据,而NIO以块的方式处理数据,块I/O的效率比流IO高很多

  • BIO是阻塞的,NIO则是非阻塞的

  • BlO基于字节流和字符流进行操作,而NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道

  • NIO可以先将数据写入到缓冲区,然后再有缓冲区写入通道,因此可以做到同步非阻塞

    BIO则是面向的流,读写数据都是单向的。因此是同步阻塞。

NIO的三大核心部分

核心对应类应用作用
缓冲区Buffer文件IO/网络IO存储数据
管道Channel文件IO/网络IO运输数据
选择器Selector网络IO控制

工作原理

Buffer(缓冲区)

​ 缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。相比较直接对数组的操作,Buffer APl更加容易操作和管理。

Channel(通道)

​ Java NIO的通道类似流,但又有些不同:既可以从通道中读取数据,又可以写数据到通道。但流的(input或output)读写通常是单向的。通道可以非阻塞读取和写入通道,通道可以支持读取或写入缓冲区,也支持异步地读写。

Selector(选择器)

​ Selector是一个ava NIO组件,可以能够检查一个或多个NIO通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接,提高效率

  • image-20231003164510721 - 每个channel都会对应一个 Buffer - 一个线程对应Selector ,一个Selector对应多个channel(连接)程序 - 切换到哪个channel是由事件决定的 - Selector 会根据不同的事件,在各个通道上切换 - Buffer 就是一个内存块,底层是一个数组 - 数据的读取写入是通过 Buffer完成的,BlO中要么是输入流,或者是输出流,不能双向,但是NIO的Buffer是可以读也可以写。 - Java NIO系统的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开到lO设备(例如:文件、套接字)的连接。若需要使用NIO系统,需要获取用于连接IO设备的通道以及用于容纳数据的缓冲区。然后操作缓冲区,对数据进行处理。简而言之,Channel负责传输,Buffer负责存取数据

Buffer

image-20231003164646623

  • Buffer有七种类型,Buffer是一个内存块,在NIO中所有的数据都是用Buffer处理,既可以读也可以写

  • Buffer 类他们都采用相似的方法进行管理数据,只是各自 管理的数据类型不同而已。都是通过如下方法获取一个 Buffer 对象:

static XxxBuffer allocate(int capacity) : 创建一个容量为capacity 的 XxxBuffer 对象

缓冲区的基本属性 Buffer 中的重要概念:

**容量 (capacity) :**作为一个内存块,Buffer具有一定的固定大小, 也称为"容量",缓冲区容量不能为负,并且创建后不能更改。

**限制 (limit):**表示缓冲区中可以操作数据的大小 (limit 后数据不能进行读写)。缓冲区的限制不能 为负,并且不能大于其容量。 写入模式,限制等于 buffer的容量。读取模式下,limit等于写入的数据量。

**位置 (position):**下一个要读取或写入的数据的索引。 缓冲区的位置不能为 负,并且不能大于其限制

**标记 (mark)与重置 (reset):**标记是一个索引, 通过 Buffer 中的 mark() 方法 指定 Buffer 中一个 特定的 position,之后可以通过调用 reset() 方法恢 复到这 个 position.

标记、位置、限制、容量遵守以下不变式: 0 <= mark <= position <= limit <= capacity

  • Buffer的常见方法

    1. **Buffer clear() :**清空缓冲区并返回对缓冲区的引用
    2. **Buffer flip() :**为 将缓冲区的界限设置为当前位置, 并将当前位置重置为 0
    3. **int capacity() :**返回 Buffer 的 capacity 大小
    4. boolean hasRemaining(): 判断缓冲区中是否还有元素
    5. **int limit() :**返回 Buffer 的界限(limit) 的位置
    6. Buffer limit(int n) 将设置缓冲区界限为 n, 并返回一个具有新 limit 的缓冲区对象
    7. Buffer mark(): 对缓冲区设置标记
    8. **int position() :**返回缓冲区的当前位置 position
    9. **Buffer position(int n) :**将设置缓冲区的当前位置为 n, 并返回修改后的 Buffer 对象
    10. **int remaining() :**返回 position 和 limit 之间的元素个数
    11. **Buffer reset() :**将位置 position 转到以前设置的mark 所在的位置
    12. **Buffer rewind() :**将位置设为为 0, 取消设置的 mark
    13. **get() :**读取单个字节
    14. **get(byte[] dst):**批量读取多个字节到 dst 中
    15. **get(int index):**读取指定索引位置的字节(不会移动 position)放到入数据到Buffer中
    16. **put(byte b):**将给定单个字节写入缓冲区的当前位置
    17. **put(byte[] src):**将 src 中的字节写入缓冲区的当前位置
    18. **put(int index, byte b):**将指定字节写入缓冲区的索引 位置(不会移动 position)
  • 使用Buffer读取数据的步骤

    1. 写入数据到Buffer
    2. 调用flip()方法,转换为读取模式
    3. 从Buffer中读取数据
    4. 调用buffer.clear()方法或者buffer.compact()方 法清除缓冲区

Channel

image-20231003165557892

  • FileChannel,读写文件中的数据。 SocketChannel,通过TCP读写网络中的数据。 ServerSockectChannel,监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。 DatagramChannel,通过UDP读写网络中的数据。

  • Channel本身并不负责存储数据,只负责运输数据,必须配合buffer一起使用

  • 获取通道的一种方式是对支持通道的对象调用getChannel() 方法。支持通道的类如下

    • FileInputStream
    • FileOutputStream
    • RandomAccessFile
    • DatagramSocket
    • Socket
    • ServerSocket

    获取通道的其他方式是使用 Files 类的静态方法 newByteChannel() 获取字节通道。或者通过通道的静态方法 open() 打开并返回指定通道

  • FileChannel常用方法

    • **int read(ByteBuffer dst) :**从Channel 到 中读取数据到 ByteBuffer
    • long read(ByteBuffer[] dsts) : 将Channel中的数据“分散”到 ByteBuffer[]
    • **int write(ByteBuffer src) :**将 ByteBuffer中的数据写入到 Channel
    • **long write(ByteBuffer[] srcs) :**将 ByteBuffer[] 到 中的数据“聚集”到 Channel
    • long position() :返回此通道的文件位置
    • **FileChannel position(long p) :**设置此通道的文件位置
    • long size() :返回此通道的文件的当前大小
    • **FileChannel truncate(long s) :**将此通道的文件截取为给定大小
    • **void force(boolean metaData) :**强制将所有对此通道的文件更新写入到存储设备中

Selector

  • Selector`翻译成选择器,有些人也会翻译成多路复用器,实际上指的是同一样东西。

    只有网络IO才会使用选择器,文件IO是不需要使用的。

    选择器可以说是NIO的核心组件,它可以监听通道的状态,来实现异步非阻塞的IO。换句话说,也就是事件驱动。以此实现单线程管理多个Channel的目的。

  • 核心API

    • Selector.open():打开一个选择器
    • select(): 选择一组键,其相应的通道已为 I/O 操作准备就绪。
    • selectedKeys():返回此选择器的已选择键集

管道间的数据传输

transferTo():把源通道的数据传输到目的通道中。

transferFrom():把来自源通道的数据传输到目的通道。

// 例子
// 把源通道的数据传输到目的通道中。
 inputStreamChannel.transferTo(0, byteBuffer.limit(), outputStreamChannel);
// 把来自源通道的数据传输到目的通道。
outputStreamChannel.transferFrom(inputStreamChannel,0,byteBuffer.limit());

分散读取和聚合写入

可以通过一个缓冲区数组读取管道数据,这就叫分散读取

也可以将一个缓冲区数组的数据写入管道,这就叫聚合写入

 public static void main(String[] args) throws Exception {
        //获取文件输入流
        File file = new File("1.txt");
        FileInputStream inputStream = new FileInputStream(file);
        //从文件输入流获取通道
        FileChannel inputStreamChannel = inputStream.getChannel();
        //获取文件输出流
        FileOutputStream outputStream = new FileOutputStream(new File("2.txt"));
        //从文件输出流获取通道
        FileChannel outputStreamChannel = outputStream.getChannel();
        //创建三个缓冲区,分别都是5
        ByteBuffer byteBuffer1 = ByteBuffer.allocate(5);
        ByteBuffer byteBuffer2 = ByteBuffer.allocate(5);
        ByteBuffer byteBuffer3 = ByteBuffer.allocate(5);
        //创建一个缓冲区数组
        ByteBuffer[] buffers = new ByteBuffer[]{byteBuffer1, byteBuffer2, byteBuffer3};
        //循环写入到buffers缓冲区数组中,分散读取
        long read;
        long sumLength = 0;
        while ((read = inputStreamChannel.read(buffers)) != -1) {
            sumLength += read;
            Arrays.stream(buffers)
                    .map(buffer -> "posstion=" + buffer.position() + ",limit=" + buffer.limit())
                    .forEach(System.out::println);
            //切换模式
            Arrays.stream(buffers).forEach(Buffer::flip);
            //聚合写入到文件输出通道
            outputStreamChannel.write(buffers);
            //清空缓冲区
            Arrays.stream(buffers).forEach(Buffer::clear);
        }
        System.out.println("总长度:" + sumLength);
        //关闭通道
        outputStream.close();
        inputStream.close();
        outputStreamChannel.close();
        inputStreamChannel.close();
    }
  • 使用场景就是可以使用一个缓冲区数组,自动地根据需要去分配缓冲区的大小。可以减少内存消耗

直接与非直接缓冲区

  • ByteBuffer有两种:HeapByteBuffer与DirectByteBuffer

  • 其实根据类名就可以看出,HeapByteBuffer所创建的字节缓冲区就是在JVM堆中的,即JVM内部所维护的字节数组。而DirectByteBuffer直接操作操作系统本地代码创建的内存缓冲数组

    DirectByteBuffer的使用场景:

    1. java程序与本地磁盘、socket传输数据
    2. 大文件对象,可以使用。不会受到堆内存大小的限制。
    3. 不需要频繁创建,生命周期较长的情况,能重复使用的情况。

    HeapByteBuffer的使用场景:

    除了以上的场景外,其他情况还是建议使用HeapByteBuffer,没有达到一定的量级,实际上使用DirectByteBuffer是体现不出优势的。

  • // 非直接缓冲区的创建方式
    static ByteBuffer allocate(int capacity)
    // 直接缓冲区的创建方式:
    static ByteBuffer allocateDirect(int capacity)
    

image-20231003171436125

从示意图中我们可以发现,最大的不同在于直接缓冲区不需要再把文件内容copy到物理内存中。这就大大地提高了性能。其实在介绍Buffer时,我们就有接触到这个概念。直接缓冲区是堆外内存,在本地文件IO效率会更高一点。

网络IO

其实NIO的主要用途是网络IO,在NIO之前java要使用网络编程就只有用Socket。而Socket是阻塞的,显然对于高并发的场景是不适用的。所以NIO的出现就是解决了这个痛点。

主要思想是把Channel通道注册到Selector中,通过Selector去监听Channel中的事件状态,这样就不需要阻塞等待客户端的连接,从主动等待客户端的连接,变成了通过事件驱动。没有监听的事件,服务器可以做自己的事情。

  • 示例

    • 服务端

      public class NIOServer {
          public static void main(String[] args) throws Exception {
              //打开一个ServerSocketChannel
              ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
              InetSocketAddress address = new InetSocketAddress("127.0.0.1", 6666);
              //绑定地址
              serverSocketChannel.bind(address);
              //设置为非阻塞
              serverSocketChannel.configureBlocking(false);
              //打开一个选择器
              Selector selector = Selector.open();
              //serverSocketChannel注册到选择器中,监听连接事件
              serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
              //循环等待客户端的连接
              while (true) {
                  //等待3秒,(返回0相当于没有事件)如果没有事件,则跳过
                  if (selector.select(3000) == 0) {
                      System.out.println("服务器等待3秒,没有连接");
                      continue;
                  }
                  //如果有事件selector.select(3000)>0的情况,获取事件
                  Set<SelectionKey> selectionKeys = selector.selectedKeys();
                  //获取迭代器遍历
                  Iterator<SelectionKey> it = selectionKeys.iterator();
                  while (it.hasNext()) {
                      //获取到事件
                      SelectionKey selectionKey = it.next();
                      //判断如果是连接事件
                      if (selectionKey.isAcceptable()) {
                          //服务器与客户端建立连接,获取socketChannel
                          SocketChannel socketChannel = serverSocketChannel.accept();
                          //设置成非阻塞
                          socketChannel.configureBlocking(false);
                          //把socketChannel注册到selector中,监听读事件,并绑定一个缓冲区
                          socketChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
                      }
                      //如果是读事件
                      if (selectionKey.isReadable()) {
                          //获取通道
                          SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                          //获取关联的ByteBuffer
                          ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
                          //打印从客户端获取到的数据
                          socketChannel.read(buffer);
                          System.out.println("from 客户端:" + new String(buffer.array()));
                      }
                      //从事件集合中删除已处理的事件,防止重复处理
                      it.remove();
                  }
              }
          }
      }
      
    • 客户端

      public class NIOClient {
          public static void main(String[] args) throws Exception {
              SocketChannel socketChannel = SocketChannel.open();
              InetSocketAddress address = new InetSocketAddress("127.0.0.1", 6666);
              socketChannel.configureBlocking(false);
              //连接服务器
              boolean connect = socketChannel.connect(address);
              //判断是否连接成功
              if(!connect){
                  //等待连接的过程中
                  while (!socketChannel.finishConnect()){
                      System.out.println("连接服务器需要时间,期间可以做其他事情...");
                  }
              }
              String msg = "hello java技术爱好者!";
              ByteBuffer byteBuffer = ByteBuffer.wrap(msg.getBytes());
              //把byteBuffer数据写入到通道中
              socketChannel.write(byteBuffer);
              //让程序卡在这个位置,不关闭连接
              System.in.read();
          }
      }
      
  • SelectionKey

    • SelectionKey类中有四个常量表示四种事件,来看源码:
    public abstract class SelectionKey {
        //读事件
        public static final int OP_READ = 1 << 0; //2^0=1
        //写事件
        public static final int OP_WRITE = 1 << 2; // 2^2=4
        //连接操作,Client端支持的一种操作
        public static final int OP_CONNECT = 1 << 3; // 2^3=8
        //连接可接受操作,仅ServerSocketChannel支持
        public static final int OP_ACCEPT = 1 << 4; // 2^4=16
    }
    

    附加的对象(可选),把通道注册到选择器中时可以附加一个对象。

    public final SelectionKey register(Selector sel, int ops, Object att)
    

    selectionKey中获取附件对象可以使用attachment()方法

    public final Object attachment() {
        return attachment;
    }