Java NIO(1)-简介及核心组件

220 阅读17分钟
  1. 1. Java NIO 简介

    1.1 Java IO vs Java NIO

    Java提供了两种IO操作方式:Java IO和Java NIO。Java IO是基于流(Stream)的操作方式,而Java NIO则是基于缓冲区(Buffer)和通道(Channel)的操作方式。下面是Java IO和Java NIO的一些比较:

    Java IOJava NIO
    阻塞IO,一个线程只能处理一个连接非阻塞IO,一个线程可以处理多个连接
    基于流(Stream)的操作方式基于缓冲区(Buffer)和通道(Channel)的操作方式
    读写操作是单向的读写操作可以是双向的
    使用面向字节(byte)或字符(char)的流使用缓冲区(Buffer)操作字节和字符
    适用于小量数据的读写适用于大量数据的读写
    代码简单易懂代码复杂,学习成本高

    1.2 Java NIO 组件介绍

    Java NIO将I/O操作分成了两部分:数据从Channel读取或写入Channel。这使得Java NIO更适合于处理需要大量连接的高并发网络应用程序。

    Java NIO的核心组件包括以下内容:

    1 Buffer(缓冲区) Buffer是一个线性数据结构,可以容纳一定数量的数据,可以通过Channel读取或写入数据。Buffer主要有以下类型:

    • ByteBuffer:用于存储字节数据
    • CharBuffer:用于存储字符数据
    • ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer等,用于存储对应数据类型的数据。

    2 Channel(通道) Channel是一个与底层I/O设备交互的组件,可以从中读取数据或将数据写入其中。Java NIO中的所有Channel都实现了java.nio.Channel接口,Channel的主要类型包括:

    • FileChannel:用于读写文件
    • SocketChannel:用于TCP/IP网络通信的客户端
    • ServerSocketChannel:用于TCP/IP网络通信的服务器端
    • DatagramChannel:用于UDP网络通信

    3 Selector(选择器) Selector是Java NIO实现多路复用的关键组件。它允许单个线程同时处理多个Channel的I/O操作。Selector本质上是一个用于管理注册到其中的Channel的集合,并且可以轮询这些Channel,以便发现哪些Channel已经准备好了读取或写入操作。

    1.3 Java NIO 组件之间的关系

            ┌───────────────┐
            │    Channel    │
            └───────────────┘
                     ▲
           ┌─────────┼──────────┐
           │         │          │
           ▼         ▼          ▼
    ┌──────────┐ ┌──────────┐ ┌──────────┐
    │  Buffer  │ │ Selector │ │  Pipe    │
    └──────────┘ └──────────┘ └──────────┘
    ​
    

    其中,Channel、Buffer、Selector、Pipe 都是 Java NIO 中的核心组件,它们之间的关系如下:

    1. Channel:表示一个连接到实体(如硬件设备、文件、网络套接字等)的通道,可以进行读、写或者同时进行读写操作。Channel 可以分为两类:一类是可读可写的,例如 SocketChannel 和 DatagramChannel;另一类是只读的,例如 FileChannel。
    2. Buffer:缓冲区,用于存储数据。Buffer 本质上是一个数组,通过 limit、position、capacity 等属性来控制数据的读写操作。
    3. Selector:选择器,用于监听多个 Channel 上的事件,当某个 Channel 上发生事件(如可读、可写等)时,Selector 就会得到通知。使用 Selector 可以使单个线程处理多个 Channel。
    4. Pipe:管道,用于将数据从一个 Channel 传输到另一个 Channel,可以实现多线程数据传输。

    这些组件之间的关系可以简单概括为:

    • Channel 和 Buffer 组合成了 IO 操作的基本单位。可以简单理解成 Buffer 是附属于 Channel 的,包括后面一篇文章中讲到的事件也是附属于Channel 的。
    • Selector 能够同时监听多个 Channel 上的事件,实现了单线程处理多个 IO 操作的能力。
    • Pipe 可以实现多线程之间的数据传输。

    2. Buffer 的使用

    2.1 Buffer 的介绍

    Buffer 是 Java NIO 中的一个重要概念,它是一个用于存储数据的容器,可以被视为一个数组,但比普通数组更加灵活和高效。Java NIO 中的所有 IO 操作都是通过 Buffer 来完成的,所以理解 Buffer 的概念和使用方法是非常重要的。

    在 Java NIO 中,Buffer 是一个抽象类,主要有以下几个子类:

    • ByteBuffer:存储字节类型数据的缓冲区。
    • CharBuffer:存储字符类型数据的缓冲区。
    • ShortBuffer:存储短整型数据的缓冲区。
    • IntBuffer:存储整型数据的缓冲区。
    • LongBuffer:存储长整型数据的缓冲区。
    • FloatBuffer:存储浮点型数据的缓冲区。
    • DoubleBuffer:存储双精度浮点型数据的缓冲区。

    除了上述子类,Buffer 还有一个子类,即MappedByteBuffer,它是一种特殊类型的 ByteBuffer,用于处理文件的内存映射 I/O 操作。

    Buffer 中主要包含以下几个核心属性:

    • 容量(capacity):缓冲区的容量,即可以存储的最大数据量。
    • 上界(limit):缓冲区的上界,即缓冲区中可操作的数据量,上界必须小于等于容量。
    • 位置(position):缓冲区的位置,即当前操作的位置,初始值为 0。
    • 标记(mark):一个临时标记,用于记录上次操作的位置。

    Buffer 主要提供了以下方法:

    • put():向缓冲区中写入数据。
    • get():从缓冲区中读取数据。
    • flip():重置缓冲区,将 limit 设置为当前位置,将位置设为 0。
    • clear():清空缓冲区,将 limit 设置为容量,将位置设为 0。
    • rewind():重置缓冲区,将位置设为 0,但不改变 limit 的值。
    • mark():记录当前位置,以便后续恢复。
    • reset():恢复到上一次 mark 的位置。

    2.2 Buffer 的分配与释放

    Java NIO 中的 Buffer 是用来暂存数据的容器,它们是基于内存的,并提供了一组操作 API 来让我们能够方便地读写数据。Buffer 是 Java NIO 中很重要的一个组件,不仅仅是因为它们是基于内存的数据容器,更是因为它们可以直接操作内存,实现了零拷贝(zero-copy)的特性,从而可以提升数据读写的效率。

    在 Java NIO 中,Buffer 是一个抽象类,它有 7 个子类,分别对应了不同的数据类型,包括 ByteBuffer、CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer 和 DoubleBuffer,每个 Buffer 子类都有一个 allocate() 方法来创建它们的实例。

    Buffer 的分配通常由静态方法 allocate() 来完成,这个方法的参数指定了分配的容量大小。在 allocate() 方法内部,它使用了 Unsafe.allocate() 方法分配了一段连续的内存空间,返回的是一个 DirectByteBuffer 类型的实例。

    javaCopy code
    ByteBuffer buffer = ByteBuffer.allocate(1024); // 分配1024字节的空间
    

    另外,Java NIO 还提供了一个更为灵活的方法 allocateDirect(),它会分配直接内存(Direct Memory),而不是像 allocate() 方法一样在堆上分配空间。使用直接内存可以避免数据拷贝,提高了数据读写的效率。

    javaCopy code
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024); // 分配1024字节的直接内存
    

    Buffer 的释放一般由 JVM 垃圾回收机制自动处理,但如果开发者在代码中手动使用了直接内存,那么就需要手动进行释放。Java NIO 提供了一个 Cleaner 类,可以用来释放直接内存,代码如下:

    javaCopy code
    import sun.misc.Cleaner;
    ​
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    Cleaner cleaner = ((DirectBuffer) buffer).cleaner();
    cleaner.clean();
    

    在 Java 9 及以上版本中,JDK 引入了一个更为方便的方法,即使用 try-with-resources 语句块,在语句块结束时会自动调用 Cleaner 释放直接内存,代码如下:

    javaCopy code
    try (ByteBuffer buffer = ByteBuffer.allocateDirect(1024)) {
        // do something with buffer
    } // buffer will be automatically released
    

    2.3 Buffer 的读写操作

    Buffer的读写操作分为两类:绝对位置操作和相对位置操作。

    绝对位置操作指的是通过指定绝对位置进行读写操作,而相对位置操作则是基于当前位置进行读写操作。

    2.4 Buffer 的内存管理

    Java NIO 中的 Buffer 是一个可以被写入或读取数据的对象,用于在内存和 I/O 之间传递数据。每个 Buffer 对象都包含一些元数据,例如缓冲区的容量、位置、限制和标记。在使用 Buffer 进行数据传输时,需要了解它的内存管理机制。

    Java NIO 中的 Buffer 内存管理分为两个阶段:分配和释放。

    1、分配

    Java NIO 提供了多种方式来分配 Buffer,常用的有 allocate()、wrap() 和 allocateDirect()。

    • allocate(): 该方法创建一个指定大小的 Buffer,并且使用 JVM 堆内存作为底层实现。这种方式的优点是速度快,但缺点是受到 JVM 堆内存大小的限制。

    示例代码:

    javaCopy code
    ByteBuffer buffer = ByteBuffer.allocate(1024);
    
    • wrap(): 该方法创建一个 Buffer 并包装一个已有的数组,该数组可以是任何类型的数组,例如 byte、char、short 等。wrap() 方法不会为缓冲区分配内存,因此不能用于创建新的缓冲区。它只是将已有的数组包装成一个缓冲区。

    示例代码:

    javaCopy code
    byte[] byteArray = new byte[1024];
    ByteBuffer buffer = ByteBuffer.wrap(byteArray);
    
    • allocateDirect(): 该方法创建一个指定大小的缓冲区,并且使用操作系统的堆外内存作为底层实现。这种方式的优点是不受 JVM 堆内存大小的限制,但缺点是速度相对较慢。

    示例代码:

    javaCopy code
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    

    2、释放

    Java NIO 的缓冲区是通过 Java 的垃圾回收器自动回收的。当缓冲区对象不再被引用时,垃圾回收器将自动回收其内存。

    在使用 allocateDirect() 方法分配的直接缓冲区时,需要手动释放内存,避免出现内存泄漏。

    示例代码:

    javaCopy code
    ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
    // 使用缓冲区
    buffer.clear();
    // 释放内存
    ((DirectBuffer) buffer).cleaner().clean();
    

    总之,Buffer 的分配和释放需要根据具体场景进行选择,以达到最优的效果。

    2.5 Buffer的零拷贝

    在 Java NIO 中,Buffer 零拷贝指的是在网络传输或文件 IO 过程中,尽可能地减少数据从内核态到用户态的拷贝次数,以提高 IO 效率。具体而言,Buffer 零拷贝的实现需要依靠操作系统提供的 sendfile 和 scatter/gather 等机制。

    其中,sendfile 是一个系统调用,它可以将一个文件描述符指向的数据直接发送到网络中,避免了数据在内核缓冲区和用户缓冲区之间的多次拷贝。在 Java NIO 中,可以使用 FileChannel 的 transferTo 或 transferFrom 方法来实现 sendfile 的效果。

    scatter/gather 则是一种内存映射技术,可以将一个或多个 Buffer 映射到一个文件或网络套接字上,以实现对文件或网络数据的读写操作。具体而言,scatter 是指从一个数据源(如网络套接字或文件)读取数据到多个 Buffer 中,而 gather 则是指将多个 Buffer 中的数据写入到同一个数据目标中。通过这种方式,避免了数据在内核缓冲区和用户缓冲区之间的多次拷贝,提高了 IO 效率。

    需要注意的是,Buffer 零拷贝的实现需要操作系统和硬件的支持,不是所有平台都支持该特性。在实际应用中,需要根据具体的场景选择是否使用 Buffer 零拷贝。

    3. Channel 的使用

    3.1 Channel 的介绍

    在 Java NIO 中,Channel 是一种表示与“实体”(例如文件或网络套接字)之间的连接的对象。它类似于 Java IO 包中的流,但有几个重要的区别。

    首先,通道可以同时进行读写操作,而流只能进行单向操作。

    其次,通道可以使用内存映射文件来进行高速数据传输。

    Java NIO 中的 Channel 接口是一个抽象的基类,具体的通道实现包括 FileChannel、DatagramChannel、SocketChannel 和 ServerSocketChannel。这些通道都实现了 Channel 接口,并提供了不同的功能,以便适用于不同类型的 I/O 操作。

    Channel 接口定义了一些常见的方法,例如读取和写入操作,以及用于关闭通道的方法。下面是一些常用的方法:

    • read():从通道中读取数据并将其存储在缓冲区中。
    • write():将数据从缓冲区写入通道。
    • close():关闭通道。

    除了这些方法之外,Channel 还具有一些其他的方法,例如实现了 FileChannel 接口的通道可以使用内存映射文件来进行高速数据传输。

    总之,Channel 是 Java NIO 中的一个核心组件,它提供了高效、灵活的 I/O 操作方式。

    3.2 Channel 的种类

    Java NIO提供了许多不同类型的Channel,每个Channel都有不同的用途和特性。以下是Java NIO中常见的Channel类型:

    1. FileChannel FileChannel用于从文件中读取数据或将数据写入文件。它是唯一可用于读写文件的Channel类型。
    2. DatagramChannel DatagramChannel用于通过UDP协议读取和写入网络数据包。
    3. SocketChannel SocketChannel用于通过TCP协议读取和写入网络数据。
    4. ServerSocketChannel ServerSocketChannel用于监听来自客户端的TCP连接请求。

    这些Channel类型提供了不同的功能和特性,可以根据具体的需求选择适合的类型。

    3.3 Channel 的数据传输

    Java NIO的Channel提供了一种快速高效的数据传输方式。数据传输可以从一个Channel到另一个Channel,或者从一个Channel到一个Buffer。

    Channel-to-Channel数据传输

    javaCopy code
        FileInputStream inputStream = new FileInputStream("input.txt");
        FileChannel inputChannel = inputStream.getChannel();
    
        FileOutputStream outputStream = new FileOutputStream("output.txt");
        FileChannel outputChannel = outputStream.getChannel();
    
        long transferredBytes = outputChannel.transferFrom(inputChannel, 0, inputChannel.size());
    
        inputChannel.close();
        outputChannel.close();
    

    上述代码使用了 transferFrom() 方法从一个FileChannel传输数据到另一个FileChannel。这个方法的第一个参数指定了从哪个Channel读取数据,第二个参数是指定从哪个位置开始读取,第三个参数是指定最多传输的字节数。这个方法会一直阻塞直到所有数据都被传输完成。

    Channel-to-Buffer数据传输

    javaCopy code
        FileInputStream inputStream = new FileInputStream("input.txt");
        FileChannel inputChannel = inputStream.getChannel();
    
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        inputChannel.read(buffer);
    
        buffer.flip();
    
        FileOutputStream outputStream = new FileOutputStream("output.txt");
        FileChannel outputChannel = outputStream.getChannel();
    
        outputChannel.write(buffer);
    
        inputChannel.close();
        outputChannel.close();
    

    上述代码使用了一个ByteBuffer从FileChannel读取数据,并将这些数据写入到另一个FileChannel中。首先创建了一个ByteBuffer,调用 read() 方法从Channel中读取数据,读取完成后调用 flip() 方法将ByteBuffer切换为读模式。然后调用 write() 方法将ByteBuffer中的数据写入到输出Channel中。

    需要注意的是,在Channel-to-Buffer数据传输中,需要使用 flip() 方法切换Buffer的读写模式。如果不切换,那么写入的数据可能是不完整的或者是错位的。

    3.4 pipe 是指从一个channel 将数据传递到另外一个 channel 吗?

    Java NIO 中的 Pipe 是一种特殊的 Channel,可以用于两个线程之间的数据传输,因此可以说 Pipe 也是一种从一个 Channel 将数据传递到另外一个 Channel 的方式。但是,与常规的 Channel 不同,Pipe 只能用于两个线程之间的通信,不能用于网络通信。另外,Pipe 是单向的,一个 Pipe 实例中有一个读取端和一个写入端,数据只能从写入端写入,然后从读取端读取。

    4. Selector 的使用

    4.1 Selector 的介绍

    Selector 是 Java NIO 组件中的一个重要组成部分,用于实现非阻塞 IO。Selector 允许单线程处理多个 Channel,可以监听多个 Channel 上是否有事件发生,如连接请求、数据到达等。Selector 可以让我们避免使用线程池或多线程来处理多个 Channel 的情况,从而提高系统的吞吐量和性能。

    在 Selector 中,我们可以注册感兴趣的事件,包括以下四种:

    • SelectionKey.OP_CONNECT:连接就绪事件
    • SelectionKey.OP_ACCEPT:接受连接就绪事件
    • SelectionKey.OP_READ:读数据就绪事件
    • SelectionKey.OP_WRITE:写数据就绪事件

    当一个 Channel 上的某个事件就绪时,Selector 将会把事件通知给应用程序,应用程序可以通过 SelectionKey 获取相应的 Channel 和事件类型,从而进行相应的处理。

    Selector 的使用需要遵循以下基本步骤:

    1. 创建一个 Selector 对象,可以通过 Selector.open() 方法创建;
    2. 将 Channel 注册到 Selector 中,可以通过 Channel.register() 方法实现;
    3. 在事件循环中等待事件发生,可以通过 Selector.select() 方法实现;
    4. 处理发生的事件,可以通过 SelectionKey 获取相应的 Channel 和事件类型,从而进行相应的处理。

    需要注意的是,Selector 是单线程的,因此我们需要合理地处理事件,避免一个事件处理过程过长导致其他事件被阻塞。

    4.2 Selector 的实现原理

    Java NIO中的Selector是实现非阻塞IO的关键组件之一。它允许一个线程同时监控多个Channel的状态,即在一个线程内可以轮询多个Channel,等待其中任意一个或多个Channel准备就绪后再对其进行操作。

    Selector的实现原理与操作系统的IO模型密切相关,它使用了操作系统的多路复用机制来实现非阻塞IO。在Linux和Unix系统中,多路复用机制主要有两种实现方式:select和epoll。

    当使用select实现多路复用时,每个Selector会对应一个底层的select集合,每个Channel在注册到Selector时会被包装为一个selectable对象,它会在select集合中占据一个位置,而select集合中的所有对象都会被同时监控。当调用Selector的select()方法时,它会阻塞并等待其中至少一个对象就绪,然后返回就绪对象的个数。然后可以通过selectedKeys()方法获取就绪的对象。

    当使用epoll实现多路复用时,每个Selector会对应一个底层的epoll对象,每个Channel在注册到Selector时会被包装为一个epoll_event结构体,它会被添加到epoll对象中,然后可以通过epoll_wait()函数等待其中一个或多个事件就绪。与select相比,epoll可以更高效地处理大量的并发连接,因为它避免了每次调用select()时需要重复向内核传递文件描述符集合的操作,而是将所有需要监控的文件描述符集中在一起,并通过epoll_ctl()函数将它们添加到内核的epoll事件表中。

    无论是select还是epoll,它们都是由操作系统内核提供的机制,而Java NIO的Selector只是对这些机制进行了封装和简化,使得Java程序员可以更方便地使用。

    4.3 理解Selector与Channel的关系及其作用

    在 Java NIO 中,Selector 与 Channel 通常是配合使用的。Selector 对象可以监视多个 Channel 对象的 IO 状态,当一个或多个 Channel 准备好执行 IO 操作时,Selector 可以进行通知,然后对这些 Channel 进行相应的 IO 操作。

    在使用 Selector 时,需要将一个或多个 Channel 注册到 Selector 中,然后调用 Selector 的 select() 方法进行监视。该方法会一直阻塞直到至少有一个 Channel 准备好 IO 操作,然后返回该 Channel 对应的 SelectionKey 对象集合。通过 SelectionKey 对象可以获取到该 Channel 对应的就绪 IO 事件类型,如可读、可写、连接就绪、接收就绪等,以及操作该 Channel 对象的 Selector 对象等信息。

    通过 Selector,可以将多个 Channel 的 IO 操作集中起来,避免了使用多线程进行 IO 操作的问题,同时也提高了 IO 操作的效率,因为 Selector 可以在底层进行优化和调度,从而最大限度地利用系统资源。同时,通过 Selector 的使用,可以实现非阻塞 IO,避免了阻塞 IO 在处理大量连接时的问题。