-
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 IO Java 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 中的核心组件,它们之间的关系如下:
- Channel:表示一个连接到实体(如硬件设备、文件、网络套接字等)的通道,可以进行读、写或者同时进行读写操作。Channel 可以分为两类:一类是可读可写的,例如 SocketChannel 和 DatagramChannel;另一类是只读的,例如 FileChannel。
- Buffer:缓冲区,用于存储数据。Buffer 本质上是一个数组,通过 limit、position、capacity 等属性来控制数据的读写操作。
- Selector:选择器,用于监听多个 Channel 上的事件,当某个 Channel 上发生事件(如可读、可写等)时,Selector 就会得到通知。使用 Selector 可以使单个线程处理多个 Channel。
- 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 released2.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类型:- FileChannel
FileChannel用于从文件中读取数据或将数据写入文件。它是唯一可用于读写文件的Channel类型。 - DatagramChannel
DatagramChannel用于通过UDP协议读取和写入网络数据包。 - SocketChannel
SocketChannel用于通过TCP协议读取和写入网络数据。 - 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 的使用需要遵循以下基本步骤:
- 创建一个 Selector 对象,可以通过 Selector.open() 方法创建;
- 将 Channel 注册到 Selector 中,可以通过 Channel.register() 方法实现;
- 在事件循环中等待事件发生,可以通过 Selector.select() 方法实现;
- 处理发生的事件,可以通过 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 在处理大量连接时的问题。