一、写在开头

我们在上一篇博文中提到了Java IO中常见得三大模型(BIO,NIO,AIO),其中NIO是我们在日常开发中使用比较多的一种IO模型,我们今天就一起来详细的学习一下。

在传统的IO中,多以这种同步阻塞的IO模型为主,程序发起IO请求后,处理线程处于阻塞状态,直到请求的IO数据从内核空间拷贝到用户空间。如下图可以直观的体现整个流程(图源:沉默王二)。

image

如果发起IO的应用程序并发量不高的情况下,这种模型是没问题的。但很明显,当前的互联网中,很多应用都有高并发IO请求的情况,这时就迫切的需要一款高效的IO模型啦。

NIO中的这个N既可以命名为NEW代表一种新型的IO模型,又可以理解为Non-Blocking,非阻塞之意。Java NIO 是 Java 1.4 版本引入的,基于通道(Channel)和缓冲区(Buffer)进行操作,采用非阻塞式 IO 操作,允许线程在等待 IO 时执行其他任务。常见的 NIO 类有 ByteBuffer、FileChannel、SocketChannel、ServerSocketChannel 等。(图源:深入拆解Tomcat & Jetty)

image

虽然在应用发起IO请求时,之多多次发起,无须阻塞。但在内核将数据拷贝到用户空间时,还是会阻塞的,为了保证数据的准确性和系统的安全稳定。

二、NIO的三大组件

在计算机与外部通信过程中,并非所有场景下NIO的性能都会好,对于连接少,并发地的应用系统中传统的BIO性能反而更好,因为在NIO中应用程序需要不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。

image

为了更好的熟悉和掌握NIO,我们这里从NIO的三大组件入手,这也是很多大厂面试官在面试时会问到的点,虽然频率不高,但一定得会!

三个核心组件:

  • Selector(选择器): 一种基于事件驱动的I/O多路复用模型,允许一个线程处理多个Channel,多个Channel注册到一个Selector上,然后由Selector进行轮询监听每一个Channel的变化。
  • Channel(通道): 是一个双向的,可读可写的数据传输管道,通过它来实现数据的输入与输出工作,它只负责运输数据,不负责处理数据,处理数据在Buffer中。一般将管道分为文件通道套接字通道
  • Buffer(缓冲区): NIO中数据的操作都是在缓冲区中完成的。读操作是将Channel中运输过来的数据填充到Buffer中;写操作是将Buffer中的数据写入到Channel中。

为了更好的理解NIO基于三大核心组件的运行流程,画了一个思维导图,如下:

image

三、组件详解

下面,我们针对上一章总结的三大组件,进行一个个的详细介绍。

3.1 Buffer(缓冲区)

在传统的BIO中,数据的读写操作是基于流的,写入采用输入字节流或字符流,而写出采用都的是输出字节流或者字符流,本质上都是基于字节的数据操作。而NIO库中,采用的是缓冲区,无论是写入还是写出数据,都不会进入到缓冲区里,由缓冲区进行下一步的操作。

image

上图是Buffer子类的继承关系结构图,我们可以看到,在Buffer中命名是基于基本数据类型的,而我们在日常使用中,ByteBuffer缓冲类最多,它是基于字节存储的,这一点和流一样。

而进入到这些缓冲类的内部够,我们可以发现,其实它们就相当于一个数组容器。在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. 容量(capacity):Buffer可以存储的最大数据量,Buffer创建时设置且不可改变;
  2. 界限(limit):Buffer 中可以读/写数据的边界。写模式下,limit 代表最多能写入的数据,一般等于 capacity(可以通过limit(int newLimit)方法设置);读模式下,limit 等于 Buffer 中实际写入的数据大小;
  3. 位置(position):下一个可以被读写的数据的位置(索引)。从写操作模式到读操作模式切换的时候(flip),position 都会归零,这样就可以从头开始读写了;
  4. 标记(mark):Buffer允许将位置直接定位到该标记处,这是一个可选属性。

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

这里我们需要注意一点,Buffer拥有读和写两种模式。Buffer被创建后,默认是写模式 ,调用flip()可以切换到读模式,再调用clear()或者compact()方法切换为写模式。

image

1️⃣ Buffer的实例化

Buffer无法通过调用构造方法来创建对象,而是需要通过静态方法进行实例化。我们以ByteBuffer为例:

// 分配堆内存,将缓冲区建立在JVM的内存中
public static ByteBuffer allocate(int capacity);
// 分配直接内存,将缓冲区建立在物理内存中,可以提交效率。但这里的数据不会被垃圾回收,容易导致内存溢出。
public static ByteBuffer allocateDirect(int capacity);

2️⃣ Buffer的核心方法

Buffer中我们常用的方法有:

  1. get : 读取缓冲区的数据;
  2. put :向缓冲区写入数据;
  3. flip :将缓冲区从写模式切换到读模式,它会将 limit 的值设置为当前 position 的值,将 position 的值设置为 0;
  4. clear: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 position 的值设置为 0,将 limit 的值设置为 capacity 的值。

image

3️⃣ Buffer的测试用例

基于以上的理论知识学习后,我们写一个小的测试demo,来感受一下Buffer的使用。

【测试案例】

public class TestBuffer {
        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);

            // 读取字符
            //hasRemaining()方法用于判断当前位置和限制之间是否有任何元素。
            //当且仅当此缓冲区中至少剩余一个元素时,此方法才会返回true。
            while (buffer.hasRemaining()) {
                System.out.println("读取字符:" + buffer.get());
            }
            // 调用clear()方法,清空缓冲区,将 position 的值置为 0,将 limit 的值置为 capacity 的值
            //调用clear()方法后,由读模式切换为写模式。
            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 开始读取的字符:         

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

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

读取字符:a
读取字符:b
读取字符:c

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

3.2 Channel(通道)

在上面的总结中,我们已经提过了,Channel作为一种双向的数据通道,给外部属于与程序之间搭建了一个传输的桥梁。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。甚至还可以同时读写!

image

Channel 的子类如下图所示。
image

这里虽然有很多通道类,但我们在日常生活中常用的,无非是 FileChannel:文件访问通道;SocketChannel、ServerSocketChannel:TCP 通信通道;DatagramChannel:UDP 通信通道;

FileChannel:用于文件 I/O 的通道,支持文件的读、写和追加操作。FileChannel 允许在文件的任意位置进行数据传输,支持文件锁定以及内存映射文件等高级功能。FileChannel 无法设置为非阻塞模式,因此它只适用于阻塞式文件操作。

SocketChannel:用于 TCP 套接字 I/O 的通道。SocketChannel 支持非阻塞模式,可以与 Selector(下文会讲)一起使用,实现高效的网络通信。SocketChannel 允许连接到远程主机,进行数据传输。

与之匹配的有ServerSocketChannel:用于监听 TCP 套接字连接的通道。与 SocketChannel 类似,ServerSocketChannel 也支持非阻塞模式,并可以与 Selector 一起使用。ServerSocketChannel 负责监听新的连接请求,接收到连接请求后,可以创建一个新的 SocketChannel 以处理数据传输。

DatagramChannel:用于 UDP 套接字 I/O 的通道。DatagramChannel 支持非阻塞模式,可以发送和接收数据报包,适用于无连接的、不可靠的网络通信。

1️⃣ Channel的核心方法

  1. read :读取数据并写入到 Buffer 中;
  2. write :将 Buffer 中的数据写入到 Channel 中。

2️⃣ Channel的测试案例

RandomAccessFile reader = new RandomAccessFile("E:\\testChannel.txt", "r");
FileChannel channel = reader.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
channel.read(buffer);
System.out.println("读取字符:" + new String(buffer.array()));

image

3.3 Selector(选择器)

选择器的概念在上面已经介绍过了,我们现在主要介绍它的运作原理:
通过 Selector 注册通道的事件,Selector 会不断地轮询注册在其上的 Channel。当事件发生时,比如:某个 Channel 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。Selector 会将相关的 Channel 加入到就绪集合中。通过 SelectionKey 可以获取就绪 Channel 的集合,然后对这些就绪的 Channel 进行相应的 I/O 操作。

主要监视事件类型:

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

SelectionKey集合:

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

Selector中的select()方法:

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

【测试案例】

public static void main(String[] args) {
     try {
         //1、通过open()方法构建一个服务套接字通道
         ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
         serverSocketChannel.configureBlocking(false);
         //封装8080端口
         serverSocketChannel.socket().bind(new InetSocketAddress(8080));

         //2、通过open方法构建一个选择器对象
         Selector selector = Selector.open();
         // 将 ServerSocketChannel 注册到 Selector 并监听 OP_ACCEPT 事件
         serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

         while (true) {
             //监听已注册的通道中是否有连接事件,并将对应的 SelectionKey 加入被选择的 SelectionKey 集合中
             int readyChannels = selector.select();
             if (readyChannels == 0) {
                 continue;
             }
             //通过selectedKeys返回所有需要进行 IO 处理的 Channel
             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();
     }
 }

上面的代码创建了一个基于 Java NIO 的简单 TCP 服务器。它使用 ServerSocketChannel 和 Selector 实现了非阻塞 I/O 和 I/O 多路复用。服务器循环监听事件,当有新的连接请求时,接受连接并将新的 SocketChannel 注册到 Selector,关注 OP_READ 事件。当有数据可读时,从 SocketChannel 中读取数据并写入 ByteBuffer,然后将数据从 ByteBuffer 写回到 SocketChannel。

四、总结

到这里基本上就把NIO的几个重要的组件介绍完啦,肯定不能面面俱到,大家想更多了解的,还是要多翻看不同的书籍。同时,后面我们将基于这部分内容,写一个小型的聊天室。

posted on 2024-07-22 19:31  JavaBuild  阅读(41)  评论(0编辑  收藏  举报