JAVA——NIO详解

简介

在传统的 Java I/O 模型(BIO)中,I/O 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 I/O 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈,因为需要为每个连接创建一个线程,而线程的创建和切换都是有开销的。

为了解决这个问题,在Java1.4 版本引入了一种新的 I/O 模型 — NIO (New IO,也称为 Non-blocking IO) 。NIO 弥补了同步阻塞I/O的不足,它在标准 Java 代码中提供了非阻塞、面向缓冲、基于通道的 I/O,可以使用少量的线程来处理多个连接,大大提高了 I/O 效率和并发。

核心组件

  • Buffer(缓冲区):NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer中,而写操作时将 Buffer中的数据写入到 Channel 中。
  • Channel(通道):Channel 是一个双向的、可读可写的数据传输通道,NIO 通过Channel来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。
  • Selector(选择器):允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到Selector上,由Selector来分配线程来处理事件。

Buffer(缓冲区)

在传统的 BIO 中,数据的读写是面向流的,分为字节流和字符流。使用 NIO在读写数据时,都是通过缓冲区进行操作。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。

你可以将 Buffer 理解为一个数组,IntBuffer、FloatBuffer、CharBuffer 等分别对应 int[]、float[]、char[] 等。

Buffer 类中定义的四个成员变量:

public abstract class Buffer {
    // Invariants: mark <= position <= limit <= capacity
    private int mark = -1;
    private int position = 0;
    private int limit;
    private int capacity;
}
  • 容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变;
  • 界限(limit):Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit) 方法设置);读模式下,limit 等于 Buffer 中实际写入的数据大小。
  • 位置(position):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了。
  • 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性;

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

另外,Buffer 有读模式和写模式这两种模式,分别用于从 Buffer 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 flip() 可以切换到读模式。如果要再次切换回写模式,可以调用 clear() 或者 compact() 方法。

20230920144743

Buffer 对象不能通过 new 调用构造方法创建对象 ,只能通过静态方法实例化 Buffer。

这里以 ByteBuffer 为例进行介绍:

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

Buffer 最核心的两个方法:

  • get : 读取缓冲区的数据。
  • put :向缓冲区写入数据。

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

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

示例——

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

写入3个字符后的状态:
capacity: 8, limit: 8, position: 3

准备读取buffer中的数据!

调用flip()方法后的状态:
capacity: 8, limit: 3, position: 0

读取到的数据:abc

调用clear()方法后的状态:
capacity: 8, limit: 8, position: 0   

Channel(通道)

Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。

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

Channel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer中,而写操作时将 Buffer中的数据写入到 Channel 中。

20230920151245

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

其中,最常用的是以下几种类型的通道:

  • FileChannel:文件访问通道;
  • SocketChannel、ServerSocketChannel:TCP通信通道;
  • DatagramChannel:UDP 通信通道;

Channel 最核心的两个方法:

  • read :读取数据并写入到 Buffer 中。
  • write :将 Buffer 中的数据写入到 Channel 中。

Selector(选择器)

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

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

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

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

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

一个Selector 实例有三个 SelectionKey 集合:

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

简单演示一下如何遍历被选择的 SelectionKey 集合并进行处理:

Set<SelectionKey> selectedKeys = selector.selectedKeys();
Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
while (keyIterator.hasNext()) {
    SelectionKey key = keyIterator.next();
    if (key != null) {
        if (key.isAcceptable()) {
            // ServerSocketChannel 接收了一个新连接
        } else if (key.isConnectable()) {
            // 表示一个新连接建立
        } else if (key.isReadable()) {
            // Channel 有准备好的数据,可以读取
        } else if (key.isWritable()) {
            // Channel 有空闲的 Buffer,可以写入数据
        }
    }
    keyIterator.remove();
}

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

  • int select():监控所有注册的 Channel,当它们中间有需要处理的 IO 操作时,该方法返回,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中,该方法返回这些 Channel 的数量。
  • int select(long timeout):可以设置超时时长的 select() 操作。
  • int selectNow():执行一个立即返回的 select() 操作,相对于无参数的 select() 方法而言,该方法不会阻塞线程。
  • 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();
    }
  }
}

  • 首先,创建了一个ServerSocketChannel对象 serverSocketChannel,它是用于监听客户端连接请求的通道。然后将其配置为非阻塞模式(configureBlocking(false)),以便能够使用非阻塞IO操作。

  • 使用 bind 方法将 serverSocketChannel 绑定到本地端口8080,以监听来自客户端的连接请求。

  • 创建了一个Selector对象 selector,Selector是Java NIO中的关键组件,用于多路复用IO事件。后续的代码将会将ServerSocketChannel注册到Selector上,并监听OP_ACCEPT事件,以便在有客户端连接请求时能够进行处理。

  • 进入一个无限循环,不断监听事件。使用 select 方法来阻塞等待事件,一旦有事件发生,select 方法将返回已准备就绪的通道数量(readyChannels)。

  • 如果没有准备就绪的通道,继续循环等待。

  • 如果有准备就绪的通道,使用 selectedKeys() 方法获取已准备就绪的SelectionKey集合,然后使用迭代器遍历这些SelectionKey。

  • 对于每个SelectionKey,首先检查它的状态。isAcceptable() 方法检查是否有新的客户端连接请求。如果是,就接受连接并将新的SocketChannel注册到Selector上,以便监听读取事件(OP_READ)。

  • 如果SelectionKey的状态是isReadable(),则表示有数据可以读取。在这种情况下,从SocketChannel中读取数据并进行处理,然后将SocketChannel注册为可写(OP_WRITE)以发送响应数据。

  • 如果SelectionKey的状态是isWritable(),则表示可以向客户端写数据。在这种情况下,向SocketChannel写入数据,并将SocketChannel重新注册为可读(OP_READ)以继续接收数据。

  • 最后,使用 keyIterator.remove() 将已处理的SelectionKey从集合中移除,以便下一次循环不再处理它。

posted @ 2023-09-21 16:32  岸南  阅读(118)  评论(0)    收藏  举报