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

434 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

在学习 `NIO` 之前,需要先了解一下计算机 `I/O`模型的基础理论知识。还不了解的话,可以参考这篇文章:[Java IO 模型详解](https://javaguide.cn/java/io/io-model.html)。
## `NIO` 简介
在传统的 `Java I/O` 模型(`BIO`)中,`I/O` 操作是以阻塞的方式进行的。也就是说,当一个线程执行一个 `I/O` 操作时,它会被阻塞直到操作完成。这种阻塞模型在处理多个并发连接时可能会导致性能瓶颈。
为了解决这个问题,在` Java `1.4 版本引入了一种新的` I/O` 模型 —— `Java NIO` `New IO`,也称为 `Non-blocking IO``Java NIO` 弥补了原来同步阻塞`I/O`的不足,它在标准 `Java` 代码中提供了高速的、面向块的 `I/O`。定义包含数据的类,并通过以块的形式处理这些数据。`NIO` 包含三个核心的组件:`Buffer`(缓冲区)、`Channel`(通道)、`Selector`(多路复用器)。
![图片来着《面试官Java NIO 了解?》](.\images\NIO\640.jpg)
在开始介绍 `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 家族"](.\images\NIO\image-20230618202236755.png)
`Java` 1.4 的 `NIO` 库中,所有数据都是用缓冲区处理的,这是新库和之前的 `BIO` 的一个重要区别。与 `BIO` 流式处理的 `Stream` 家族相对应,新库引入了 `Buffer` 对象。见名思义:`Buffer` 是一个缓存区,在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 `NIO` 在读写数据时,都是通过缓冲区进行操作。
◼ Buffer是抽象类它有如下子类
`ByteBuffer``CharBuffer``ShortBuffer``IntBuffer``LongBuffer``FloatBuffer``DoubleBuffer` ;通过名称就可以看出来,这些子类用来存储对应类型的数据。
![NIO ”Buffer 家族“](.\images\NIO\image-20230620235630296.png)
`Buffer` 中的四个成员变量:
~~~java
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`可以存储的最大数据量,该值不可改变;
2. 界限(`limit``Buffer` 中可以读/写数据的边界,`limit` 之后的数据不能访问;
3. 位置(`position`):下一个可以被读写的数据的位置(索引);
4. 标记(`mark``Buffer`允许将位置直接定位到该标记处,这是一个可选属性;
并且上述变量满足如下的关系0 <= mark <= position <= limit <= capacity 。
![Buffer 成员变量关系](.\images\image-20230615221211005.png)
`Buffer` 对象不能通过 `new` 调用构造方法创建对象 ,只能通过静态方法实例化 `Buffer`。下面是 `ByteBuffer `类中创建示例提供的静态方法:
~~~java
public static ByteBuffer allocate(int capacity); // 分配堆内存
public static ByteBuffer allocateDirect(int capacity); // 分配直接内存
~~~
`Buffer` 最核心的两个方法:
1. `get` : 读取缓冲区的数据
2. `put` :向缓冲区写入数据
除上述两个方法之外,其他的重要方法:
- `flip` :将缓冲区从写模式切换到读模式,它会将 `limit` 的值设置为当前 `position` 的值,将 `position` 的值设置为 0。
- `clear`: 清空缓冲区,将缓冲区从读模式切换到写模式,并将 `position` 的值设置为 0`limit` 的值设置为 `capacity` 的值。
Buffer中数据变化的过程
~~~java
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");
}
}
~~~
打印结果:
~~~tex
初始状态:
capacity: 8, limit: 8, position: 0, mark 开始读取的字符:8个空字符
~~~
![初始状态](.\images\NIO\image-20230618215703502.png)
~~~tex
写入3个字符后的状态
capacity: 8, limit: 8, position: 3, mark 开始读取的字符:5个空字符
~~~
![写入3个字符后的状态](.\images\NIO\image-20230618215834167.png)
~~~tex
调用flip()方法后的状态:
capacity: 8, limit: 3, position: 0, mark 开始读取的字符: abc
~~~
![调用flip方法后的状态](.\images\NIO\image-20230618220051437.png)
~~~tex
调用clear()方法后的状态:
capacity: 8, limit: 8, position: 0, mark 开始读取的字符: abc5个空字符
~~~
![调用clear方法后的状态](.\images\NIO\image-20230618220217580.png)
### `Channel`(通道)
`Channel` 是一个通道,它代表着与数据源(如文件、网络套接字等)之间的连接。可以通过它读取和写入数据,它就像自来水管一样,网络数据通过 `Channel` 读取和写入。
`BIO` 中的 `Stream` 是单向的,分为各种 `InputStream`(输入流)和 `OutputStream`(输出流),数据只是在一个方向上传输。 `Channel`(通道)与流的不同之处在于`Channel`是双向的,它可以用于读、写或者同时用于读写。
因为 `Channel` 是全双工的,所以它可以比流更好地映射底层操作系统的 `API` 。特别是在 `UNIX` 网络编程模型中,底层操作系统的通道都是全双工的,同时支持读写操作。
`Java NIO` 中,主要有以下几种类型的通道:
◼ Channel的实现类
1. `FileChannel`:文件访问通道;
2. `SocketChannel``ServerSocketChannel``TCP`通信通道;
3. `DatagramChannel``UDP`通信通道;
4. `Pipe.SourceChannel``Pipe.SinkChannel`:线程通信通道。
![channel 类继承图](.\images\NIO\image-20230620115120520.png)
`Channel` 的实例化:
1. 各个 `Channel` 类提供的 `open()` 方法;
- 打开文件的 `FileChannel`
~~~java
FileChannel channel = FileChannel.open(filePath, StandardOpenOption.READ);
~~~
- 建立网络连接的 `ServerSocketChannel`
~~~java
ServerSocketChannel serverChannel = ServerSocketChannel.open();
~~~
2. `BIO` 的各种`Stream`提供了`getChannel()`方法,可以直接返回 `FileChannel`
~~~java
FileInputStream inputStream = new FileInputStream("source.txt");
FileChannel inputChannel = inputStream.getChannel();
~~~
`Channel` 的方法:
1. `map()`方法用于将 `Channel` 对应的数据映射成 `ByteBuffer`
2. `read()` 方法有一系列重载的形式,用于从 `Buffer` 中读取数据;
3. `write()` 方法有一系列重载的形式,用于向 `Buffer` 中写入数据。
◼ 使用`Channel`复制数据的代码示例:
~~~java
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`中的一个关键组件,它允许一个线程处理多个 `Channel``Selector` 是基于事件驱动的 `I/O` 多路复用模型,主要运作原理是:通过 `Selector` 注册通道的事件,`Selector` 会不断地轮询注册在其上的 `Channel`。当事件发生时,比如:某个 `Channel` 上面有新的 TCP 连接接入、读和写事件,这个 Channel 就处于就绪状态,会被 Selector 轮询出来。`Selector` 会将相关的 `Channel` 加入到就绪集合中。通过 `SelectionKey` 可以获取就绪 `Channel` 的集合,然后对这些就绪的 `Channel` 进行响应的 `I/O` 操作。
![IO多路复用模型](.\images\NIO\image-20230621004820823.png)
一个多路复用器 `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` 可以同时监控多个 `SelectableChannel``IO` 状况,是非阻塞 `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 实现网络读写的简单示例:
~~~java
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 操作性能的一个常用手段 ActiveMQKafka RocketMQQMQNetty 等顶级开源项目都用到了零拷贝
零拷贝是指计算机执行 IO 操作时CPU 不需要将数据从一个存储区域复制到另一个存储区域从而可以减少上下文切换以及 CPU 的拷贝时间也就是说零拷贝主主要解决操作系统在处理 I/O 操作时频繁复制数据的问题零拷贝的常见实现技术有 `mmap+write``sendfile` `sendfile + 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`
- `FileChannel` `transferTo()/transferFrom()`调用了 Linux 内核的 `sendfile`关于`FileChannel`的用法可以看看这篇文章[Java NIO 文件通道 FileChannel 用法](https://www.cnblogs.com/robothy/p/14235598.html)
代码示例
```java
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();
}
```
## 参考
- Java NIO浅析<https://tech.meituan.com/2016/11/04/nio.html>
- 面试官Java NIO 了解https://mp.weixin.qq.com/s/mZobf-U8OSYQfHfYBEB6KA