1
0
mirror of https://github.com/Snailclimb/JavaGuide synced 2025-06-16 18:10:13 +08:00
2023-06-26 23:16:09 +08:00

18 KiB
Raw Blame History

在学习 NIO 之前,需要先了解一下计算机 I/O模型的基础理论知识。还不了解的话,可以参考这篇文章:Java IO 模型详解

NIO 简介

在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈。

为了解决这个问题,在Java1.4 版本引入了一种新的 I/O 模型 —— Java NIO New IO,也称为 Non-blocking IOJava NIO 弥补了原来同步阻塞I/O的不足,它在标准 Java 代码中提供了高速的、面向块的 I/O。定义包含数据的类,并通过以块的形式处理这些数据。NIO 包含三个核心的组件:Buffer(缓冲区)、Channel(通道)、Selector(多路复用器)。

图片来着《面试官:Java NIO 了解?》

在开始介绍 NIO 的核心组件之前,可以先回顾一下I/O 模型中的同步非阻塞 I/O Non-blocking IO)和 I/O 多路复用。读完文章之后,思考下对应 Java NIO 属于哪一种 I/O 模型?

NIO 核心组件

Java NIO 主要包括以下三个核心组件:Buffer(缓冲区)、Channel(通道)和 Selector(选择器)。下面分别介绍这三个组件。

Buffer(缓冲区)

在传统的 BIO 中,数据的读写是面向流的,通过各种输入/输出 Stream 对象实现数据的读写。BIO 中有一个庞大的 "stream 家族" 应用于各种场景的数据读写:

BIO "stream 家族"

Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别。与 BIO 流式处理的 Stream 家族相对应,新库引入了 Buffer 对象。见名思义:Buffer 是一个缓存区,在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。

◼ Buffer是抽象类它有如下子类 ByteBufferCharBufferShortBufferIntBufferLongBufferFloatBufferDoubleBuffer ;通过名称就可以看出来,这些子类用来存储对应类型的数据。

NIO ”Buffer 家族“

Buffer 中的四个成员变量:

public abstract class Buffer {
    // Invariants: mark <= position <= limit <= capacity
    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;
}
  1. 容量(capacityBuffer可以存储的最大数据量,该值不可改变;
  2. 界限(limitBuffer 中可以读/写数据的边界,limit 之后的数据不能访问;
  3. 位置(position):下一个可以被读写的数据的位置(索引);
  4. 标记(markBuffer允许将位置直接定位到该标记处,这是一个可选属性;

并且上述变量满足如下的关系0 <= mark <= position <= limit <= capacity 。

Buffer 成员变量关系

Buffer 对象不能通过 new 调用构造方法创建对象 ,只能通过静态方法实例化 Buffer。下面是 ByteBuffer 类中创建示例提供的静态方法:

public static ByteBuffer allocate(int capacity); // 分配堆内存
public static ByteBuffer allocateDirect(int capacity); // 分配直接内存

Buffer 最核心的两个方法:

  1. get : 读取缓冲区的数据
  2. put :向缓冲区写入数据

除上述两个方法之外,其他的重要方法:

  • flip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0。
  • clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0limit 的值设置为 capacity 的值。

Buffer中数据变化的过程

import java.nio.*;

public class CharBufferDemo {
    public static void main(String[] args) {
        // 分配一个容量为8的CharBuffer
        CharBuffer buffer = CharBuffer.allocate(8);
        System.out.println("初始状态:"); 
        printState(buffer); 

        // 向buffer写入3个字符
        buffer.put('a').put('b').put('c');
        System.out.println("写入3个字符后的状态");
        printState(buffer);

        // 调用flip()方法准备读取buffer中的数据将 position 置 0,limit 的置 3
        buffer.flip();
        System.out.println("调用flip()方法后的状态:");
        printState(buffer);

        // 读取字符
        while (buffer.hasRemaining()) { 
            System.out.print(buffer.get());
        }

        // 调用clear()方法,清空缓冲区,将 position 的值置为 0将 limit 的值置为 capacity 的值
        buffer.clear();
        System.out.println("调用clear()方法后的状态:");
        printState(buffer);

    }

    // 打印buffer的capacity、limit、position、mark的位置
    private static void printState(CharBuffer buffer) {
        System.out.print("capacity: " + buffer.capacity());
        System.out.print(", limit: " + buffer.limit());
        System.out.print(", position: " + buffer.position());
        System.out.print(", mark 开始读取的字符: " + buffer.mark());
        System.out.println("\n");
    }
}

打印结果:

初始状态:
capacity: 8, limit: 8, position: 0, mark 开始读取的字符:8个空字符        

初始状态

写入3个字符后的状态
capacity: 8, limit: 8, position: 3, mark 开始读取的字符:5个空字符   

写入3个字符后的状态

调用flip()方法后的状态:
capacity: 8, limit: 3, position: 0, mark 开始读取的字符: abc

调用flip方法后的状态

调用clear()方法后的状态:
capacity: 8, limit: 8, position: 0, mark 开始读取的字符: abc5个空字符     

调用clear方法后的状态

Channel(通道)

Channel 是一个通道,它代表着与数据源(如文件、网络套接字等)之间的连接。可以通过它读取和写入数据,它就像自来水管一样,网络数据通过 Channel 读取和写入。

BIO 中的 Stream 是单向的,分为各种 InputStream(输入流)和 OutputStream(输出流),数据只是在一个方向上传输。 Channel(通道)与流的不同之处在于Channel是双向的,它可以用于读、写或者同时用于读写。

因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API 。特别是在 UNIX 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。

Java NIO 中,主要有以下几种类型的通道:

◼ Channel的实现类

  1. FileChannel:文件访问通道;
  2. SocketChannelServerSocketChannelTCP通信通道;
  3. DatagramChannelUDP通信通道;
  4. Pipe.SourceChannelPipe.SinkChannel:线程通信通道。

channel 类继承图

Channel 的实例化:

  1. 各个 Channel 类提供的 open() 方法;
  • 打开文件的 FileChannel
FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ);
  • 建立网络连接的 ServerSocketChannel
ServerSocketChannel serverChannel = ServerSocketChannel.open();	
  1. BIO 的各种Stream提供了getChannel()方法,可以直接返回 FileChannel
FileInputStream inputStream = new FileInputStream("source.txt");
FileChannel inputChannel = inputStream.getChannel();

Channel 的方法:

  1. map()方法用于将 Channel 对应的数据映射成 ByteBuffer
  2. read() 方法有一系列重载的形式,用于从 Buffer 中读取数据;
  3. write() 方法有一系列重载的形式,用于向 Buffer 中写入数据。

◼ 使用Channel复制数据的代码示例:

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

public class FileChannelExample {
    public static void main(String[] args) throws Exception {
        RandomAccessFile file = new RandomAccessFile("D:/source.txt", "rw");
        FileChannel channel = file.getChannel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        // 从通道读取数据到 Buffer 中
        int bytesRead = channel.read(buffer);
        while (bytesRead != -1) {
            System.out.println("Read " + bytesRead + " bytes");
            buffer.flip();
            while (buffer.hasRemaining()) {
                System.out.print((char) buffer.get());
            }
            buffer.clear();
            bytesRead = channel.read(buffer);
        }

        String data = "Hello, FileChannel!";
        buffer.put(data.getBytes());
        buffer.flip();
        // 向文件中追加写入数据
        int bytesWrite = channel.write(buffer);
        System.out.println("\nwrite " + bytesWrite + " bytes");

        channel.close();
        file.close();
    }
}

Selector(选择器)

Selector(选择器) 是 Java NIO中的一个关键组件,它允许一个线程处理多个 ChannelSelector 是基于事件驱动的 I/O 多路复用模型,主要运作原理是:通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行响应的 I/O 操作。

IO多路复用模型

一个多路复用器 Selector 可以同时轮询多个 Channel,由于 JDK 使用了 epoll() 代替传统的 select 实现,所以它并没有最大连接句柄 1024/2048 的限制。这也就意味着只需要一个线程负责 Selector 的轮询,就可以接入成千上万的客户端。

◼监听事件类型

Selector 可以监听以下四种事件类型:

  1. SelectionKey.OP_ACCEPT:表示通道接受连接的事件,这通常用于 ServerSocketChannel
  2. SelectionKey.OP_CONNECT:表示通道完成连接的事件,这通常用于 SocketChannel
  3. SelectionKey.OP_READ:表示通道准备好进行读取的事件,即有数据可读。
  4. SelectionKey.OP_WRITE:表示通道准备好进行写入的事件,即可以写入数据。

SelectionKey 集合

Selector 是抽象类,可以通过调用此类的 open() 静态方法来创建 Selector 实例。Selector 可以同时监控多个 SelectableChannelIO 状况,是非阻塞 IO 的核心。一个Selector 实例有三个 SelectionKey 集合:

  1. 所有的 SelectionKey 集合:代表了注册在该 Selector 上的 Channel,这个集合可以通过 keys() 方法返回。
  2. 被选择的 SelectionKey 集合:代表了所有可通过 select() 方法获取的、需要进行 IO 处理的 Channel这个集合可以通过 selectedKeys() 返回。
  3. 被取消的 SelectionKey 集合:代表了所有被取消注册关系的 Channel,在下一次执行 select() 方法时,这些 Channel 对应的 SelectionKey 会被彻底删除,程序通常无须直接访问该集合。

select() 相关的方法

Selector 还提供了一系列和 select() 相关的方法:

  1. int select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。
  2. int select(long timeout):可以设置超时时长的 select() 操作。
  3. int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程。
  4. Selector wakeup():使一个还未返回的 select() 方法立刻返回。

◼使用 Selector 实现网络读写的简单示例:

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

public class NioSelectorExample {

  public static void main(String[] args) {
    try {
      ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
      serverSocketChannel.configureBlocking(false);
      serverSocketChannel.socket().bind(new InetSocketAddress(8080));

      Selector selector = Selector.open();
      // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件
      serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

      while (true) {
        int readyChannels = selector.select();

        if (readyChannels == 0) {
          continue;
        }

        Set<SelectionKey> selectedKeys = selector.selectedKeys();
        Iterator<SelectionKey> keyIterator = selectedKeys.iterator();

        while (keyIterator.hasNext()) {
          SelectionKey key = keyIterator.next();

          if (key.isAcceptable()) {
            // 处理连接事件
            ServerSocketChannel server = (ServerSocketChannel) key.channel();
            SocketChannel client = server.accept();
            client.configureBlocking(false);

            // 将客户端通道注册到 Selector 并监听 OP_READ 事件
            client.register(selector, SelectionKey.OP_READ);
          } else if (key.isReadable()) {
            // 处理读事件
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int bytesRead = client.read(buffer);

            if (bytesRead > 0) {
              buffer.flip();
              System.out.println("收到数据:" +new String(buffer.array(), 0, bytesRead));
              // 将客户端通道注册到 Selector 并监听 OP_WRITE 事件
              client.register(selector, SelectionKey.OP_WRITE);
            } else if (bytesRead < 0) {
              // 客户端断开连接
              client.close();
            }
          } else if (key.isWritable()) {
            // 处理写事件
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.wrap("Hello, Client!".getBytes());
            client.write(buffer);

            // 将客户端通道注册到 Selector 并监听 OP_READ 事件
            client.register(selector, SelectionKey.OP_READ);
          }

          keyIterator.remove();
        }
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

在示例中我们创建了一个简单的服务器监听8080端口使用 Selector 处理连接、读取和写入事件。当接收到客户端的数据时,服务器将读取数据并将其打印到控制台,然后向客户端回复 "Hello, Client!"。

NIO 零拷贝

DirectBuffer是Java NIO库中提供的一种可以直接操作内存的缓冲区类型。

零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka 、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。

零拷贝是指计算机执行 IO 操作时CPU 不需要将数据从一个存储区域复制到另一个存储区域,从而可以减少上下文切换以及 CPU 的拷贝时间。也就是说,零拷贝主主要解决操作系统在处理 I/O 操作时频繁复制数据的问题。零拷贝的常见实现技术有: mmap+writesendfilesendfile + DMA gather copy

下图展示了各种零拷贝技术的对比图:

CPU 拷贝 DMA 拷贝 系统调用 上下文切换
传统方法 2 2 read+write 4
mmap+write 1 2 mmap+write 4
sendfile 1 2 sendfile 2
sendfile + DMA gather copy 0 2 sendfile 2

可以看出,无论是传统的 I/O 方式还是引入了零拷贝之后2 次 DMA(Direct Memory Access) 拷贝是都少不了的。因为两次 DMA 都是依赖硬件完成的。零拷贝主要是减少了 CPU 拷贝及上下文的切换。

Java 对零拷贝的支持:

  • MappedByteBuffer 是 NIO 基于内存映射mmap这种零拷⻉⽅式的提供的⼀种实现它的底层是调用了 Linux 内核的 mmap
  • FileChanneltransferTo()/transferFrom()调用了 Linux 内核的 sendfile。关于FileChannel的用法可以看看这篇文章:Java NIO 文件通道 FileChannel 用法

代码示例:

private void loadFileIntoMemory(File xmlFile) throws IOException {
  FileInputStream fis = new FileInputStream(xmlFile);
  // 创建 FileChannel 对象
  FileChannel fc = fis.getChannel();
  // FileChannle.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象
  MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
  xmlFileBuffer = new byte[(int)fc.size()];
  mmb.get(xmlFileBuffer);
  fis.close();
}

参考