This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
---
|
||||
title: Java IO 专题:BIO、NIO、AIO、IO 模型与设计模式
|
||||
description: Java IO 与 NIO 学习路线,涵盖 BIO、NIO、AIO、阻塞/非阻塞、同步/异步、I/O 多路复用、Reactor 模型和 IO 设计模式。
|
||||
category: Java
|
||||
tag:
|
||||
- Java
|
||||
- Java IO
|
||||
- Java面试
|
||||
sitemap:
|
||||
changefreq: weekly
|
||||
priority: 0.9
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java IO,Java NIO,BIO,NIO,AIO,IO模型,I/O多路复用,Reactor,Selector,Channel,Buffer,Java IO面试题
|
||||
---
|
||||
|
||||
Java IO 是理解文件读写、网络编程、Netty、RPC 框架和高性能服务端的重要基础。学习 IO 时,建议同时理解 Java API、操作系统 IO 模型和常见设计模式,这样才能把 BIO、NIO、AIO、Selector、Channel、Buffer、Reactor 串起来。
|
||||
|
||||
## 适合谁看
|
||||
|
||||
- 想系统学习 Java IO/NIO 的后端开发者。
|
||||
- 准备 BIO、NIO、AIO、IO 多路复用、Reactor 相关面试题的同学。
|
||||
- 想继续学习 Netty、RPC、消息队列、数据库驱动等网络通信框架的读者。
|
||||
- 对阻塞/非阻塞、同步/异步、Selector、Channel、Buffer 等概念容易混淆的工程师。
|
||||
|
||||
## 学习重点
|
||||
|
||||
- Java IO 流体系、字节流、字符流、缓冲流和常见文件操作。
|
||||
- 装饰器模式、适配器模式等设计模式在 IO 中的应用。
|
||||
- BIO、NIO、AIO 的模型差异、适用场景和优缺点。
|
||||
- 同步/异步、阻塞/非阻塞、I/O 多路复用、Reactor 和 Proactor。
|
||||
- Buffer、Channel、Selector 的协作关系,以及它们在网络编程中的作用。
|
||||
|
||||
## 建议阅读顺序
|
||||
|
||||
1. [Java IO 基础知识总结](./io-basis.md):先掌握 IO 流体系、常用类和文件读写基础。
|
||||
2. [Java IO 设计模式总结](./io-design-patterns.md):理解装饰器模式、适配器模式等设计模式如何落到 IO API 中。
|
||||
3. [Java IO 模型详解](./io-model.md):厘清 BIO、NIO、AIO、同步/异步、阻塞/非阻塞和多路复用。
|
||||
4. [Java NIO 核心知识总结](./nio-basis.md):深入学习 Buffer、Channel、Selector 和 NIO 编程模型。
|
||||
|
||||
## 核心文章
|
||||
|
||||
- [Java IO 基础知识总结](./io-basis.md):系统介绍字节流、字符流、缓冲流、随机访问文件和常见 IO 类。
|
||||
- [Java IO 设计模式总结](./io-design-patterns.md):讲解装饰器模式、适配器模式等设计模式在 IO 中的应用。
|
||||
- [Java IO 模型详解](./io-model.md):区分 BIO、NIO、AIO、同步/异步、阻塞/非阻塞和 I/O 多路复用。
|
||||
- [Java NIO 核心知识总结](./nio-basis.md):理解 Buffer、Channel、Selector、SelectionKey 和 NIO 服务端编程。
|
||||
|
||||
## 高频问题
|
||||
|
||||
- 字节流和字符流有什么区别?什么时候使用缓冲流?
|
||||
- Java IO 中为什么大量使用装饰器模式?
|
||||
- BIO、NIO、AIO 有什么区别?
|
||||
- 同步和异步、阻塞和非阻塞分别是什么意思?
|
||||
- I/O 多路复用解决了什么问题?
|
||||
- `select`、`poll`、`epoll` 有什么区别?
|
||||
- Reactor 模型是什么?和 Proactor 有什么区别?
|
||||
- NIO 中 Buffer、Channel、Selector 分别承担什么职责?
|
||||
- 为什么 Netty 基于 NIO 构建,而不是直接使用传统 BIO?
|
||||
|
||||
## 相关专题
|
||||
|
||||
- [Java 知识体系](../)
|
||||
- [Java 并发编程专题](../concurrent/)
|
||||
- [JVM 专题](../jvm/)
|
||||
- [计算机网络](../../cs-basics/network/)
|
||||
- [Netty](../../system-design/framework/netty.md)
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
Executable
+553
@@ -0,0 +1,553 @@
|
||||
---
|
||||
title: Java IO 基础知识总结
|
||||
description: Java IO基础知识全面总结:详解字节流与字符流区别、InputStream/OutputStream字节流、Reader/Writer字符流、缓冲流优化、文件读写操作。
|
||||
category: Java
|
||||
tag:
|
||||
- Java IO
|
||||
- Java基础
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java IO,字节流,字符流,InputStream,OutputStream,Reader,Writer,文件操作,缓冲流
|
||||
---
|
||||
|
||||
## IO 流简介
|
||||
|
||||
IO 即 `Input/Output`,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
|
||||
|
||||
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
|
||||
|
||||
- `InputStream`/`Reader`: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。
|
||||
- `OutputStream`/`Writer`: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
|
||||
|
||||
## 字节流
|
||||
|
||||
### InputStream(字节输入流)
|
||||
|
||||
`InputStream` 用于从源头(通常是文件)读取数据(字节信息)到内存中,`java.io.InputStream` 抽象类是所有字节输入流的父类。
|
||||
|
||||
`InputStream` 常用方法:
|
||||
|
||||
- `read()`:返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回 `-1`,表示文件结束。
|
||||
- `read(byte b[ ])` : 从输入流中读取一些字节存储到数组 `b` 中。如果数组 `b` 的长度为零,则不读取。如果没有可用字节读取,返回 `-1`。如果有可用字节读取,则最多读取的字节数最多等于 `b.length`, 返回读取的字节数。这个方法等价于 `read(b, 0, b.length)`。
|
||||
- `read(byte b[], int off, int len)`:在 `read(byte b[ ])` 方法的基础上增加了 `off` 参数(偏移量)和 `len` 参数(要读取的最大字节数)。
|
||||
- `skip(long n)`:忽略输入流中的 n 个字节 ,返回实际忽略的字节数。
|
||||
- `available()`:返回输入流中可以读取的字节数。
|
||||
- `close()`:关闭输入流释放相关的系统资源。
|
||||
|
||||
从 Java 9 开始,`InputStream` 新增加了多个实用的方法:
|
||||
|
||||
- `readAllBytes()`:读取输入流中的所有字节,返回字节数组。
|
||||
- `readNBytes(byte[] b, int off, int len)`:阻塞直到读取 `len` 个字节。
|
||||
- `transferTo(OutputStream out)`:将所有字节从一个输入流传递到一个输出流。
|
||||
|
||||
`FileInputStream` 是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。
|
||||
|
||||
`FileInputStream` 代码示例:
|
||||
|
||||
```java
|
||||
try (InputStream fis = new FileInputStream("input.txt")) {
|
||||
System.out.println("Number of remaining bytes:"
|
||||
+ fis.available());
|
||||
int content;
|
||||
long skip = fis.skip(2);
|
||||
System.out.println("The actual number of bytes skipped:" + skip);
|
||||
System.out.print("The content read from file:");
|
||||
while ((content = fis.read()) != -1) {
|
||||
System.out.print((char) content);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
`input.txt` 文件内容:
|
||||
|
||||

|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
Number of remaining bytes:11
|
||||
The actual number of bytes skipped:2
|
||||
The content read from file:JavaGuide
|
||||
```
|
||||
|
||||
不过,一般我们是不会直接单独使用 `FileInputStream`,通常会配合 `BufferedInputStream`(字节缓冲输入流,后文会讲到)来使用。
|
||||
|
||||
像下面这段代码在我们的项目中就比较常见,我们通过 `readAllBytes()` 读取输入流所有字节并将其直接赋值给一个 `String` 对象。
|
||||
|
||||
```java
|
||||
// 新建一个 BufferedInputStream 对象
|
||||
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
|
||||
// 读取文件的内容并复制到 String 对象中
|
||||
String result = new String(bufferedInputStream.readAllBytes());
|
||||
System.out.println(result);
|
||||
```
|
||||
|
||||
`DataInputStream` 用于读取指定类型数据,不能单独使用,必须结合其它流,比如 `FileInputStream`。
|
||||
|
||||
```java
|
||||
FileInputStream fileInputStream = new FileInputStream("input.txt");
|
||||
//必须将fileInputStream作为构造参数才能使用
|
||||
DataInputStream dataInputStream = new DataInputStream(fileInputStream);
|
||||
//可以读取任意具体的类型数据
|
||||
dataInputStream.readBoolean();
|
||||
dataInputStream.readInt();
|
||||
dataInputStream.readUTF();
|
||||
```
|
||||
|
||||
`ObjectInputStream` 用于从输入流中读取 Java 对象(反序列化),`ObjectOutputStream` 用于将对象写入到输出流(序列化)。
|
||||
|
||||
```java
|
||||
ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
|
||||
MyClass object = (MyClass) input.readObject();
|
||||
input.close();
|
||||
```
|
||||
|
||||
另外,用于序列化和反序列化的类必须实现 `Serializable` 接口,对象中如果有属性不想被序列化,使用 `transient` 修饰。
|
||||
|
||||
### OutputStream(字节输出流)
|
||||
|
||||
`OutputStream` 用于将数据(字节信息)写入到目的地(通常是文件),`java.io.OutputStream` 抽象类是所有字节输出流的父类。
|
||||
|
||||
`OutputStream` 常用方法:
|
||||
|
||||
- `write(int b)`:将特定字节写入输出流。
|
||||
- `write(byte b[ ])` : 将数组 `b` 写入到输出流,等价于 `write(b, 0, b.length)`。
|
||||
- `write(byte[] b, int off, int len)` : 在 `write(byte b[ ])` 方法的基础上增加了 `off` 参数(偏移量)和 `len` 参数(要读取的最大字节数)。
|
||||
- `flush()`:刷新此输出流并强制写出所有缓冲的输出字节。
|
||||
- `close()`:关闭输出流释放相关的系统资源。
|
||||
|
||||
`FileOutputStream` 是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。
|
||||
|
||||
`FileOutputStream` 代码示例:
|
||||
|
||||
```java
|
||||
try (FileOutputStream output = new FileOutputStream("output.txt")) {
|
||||
byte[] array = "JavaGuide".getBytes();
|
||||
output.write(array);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
运行结果:
|
||||
|
||||

|
||||
|
||||
类似于 `FileInputStream`,`FileOutputStream` 通常也会配合 `BufferedOutputStream`(字节缓冲输出流,后文会讲到)来使用。
|
||||
|
||||
```java
|
||||
FileOutputStream fileOutputStream = new FileOutputStream("output.txt");
|
||||
BufferedOutputStream bos = new BufferedOutputStream(fileOutputStream)
|
||||
```
|
||||
|
||||
**`DataOutputStream`** 用于写入指定类型数据,不能单独使用,必须结合其它流,比如 `FileOutputStream`。
|
||||
|
||||
```java
|
||||
// 输出流
|
||||
FileOutputStream fileOutputStream = new FileOutputStream("out.txt");
|
||||
DataOutputStream dataOutputStream = new DataOutputStream(fileOutputStream);
|
||||
// 输出任意数据类型
|
||||
dataOutputStream.writeBoolean(true);
|
||||
dataOutputStream.writeByte(1);
|
||||
```
|
||||
|
||||
`ObjectInputStream` 用于从输入流中读取 Java 对象(反序列化),`ObjectOutputStream` 将对象写入到输出流(序列化)。
|
||||
|
||||
```java
|
||||
ObjectOutputStream output = new ObjectOutputStream(new FileOutputStream("file.txt")
|
||||
Person person = new Person("Guide哥", "JavaGuide作者");
|
||||
output.writeObject(person);
|
||||
```
|
||||
|
||||
## 字符流
|
||||
|
||||
不管是文件读写还是网络发送接收,信息的最小存储单元都是字节。 **那为什么 I/O 流操作要分为字节流操作和字符流操作呢?**
|
||||
|
||||
个人认为主要有两点原因:
|
||||
|
||||
- 字符流是由 Java 虚拟机将字节转换得到的,这个过程还算是比较耗时。
|
||||
- 如果我们不知道编码类型就很容易出现乱码问题。
|
||||
|
||||
乱码问题这个很容易就可以复现,我们只需要将上面提到的 `FileInputStream` 代码示例中的 `input.txt` 文件内容改为中文即可,原代码不需要改动。
|
||||
|
||||

|
||||
|
||||
输出:
|
||||
|
||||
```java
|
||||
Number of remaining bytes:9
|
||||
The actual number of bytes skipped:2
|
||||
The content read from file:§å®¶å¥½
|
||||
```
|
||||
|
||||
可以很明显地看到读取出来的内容已经变成了乱码。
|
||||
|
||||
因此,I/O 流就干脆提供了一个直接操作字符的接口,方便我们平时对字符进行流操作。如果音频文件、图片等媒体文件用字节流比较好,如果涉及到字符的话使用字符流比较好。
|
||||
|
||||
字符流默认采用的是 `Unicode` 编码,我们可以通过构造方法自定义编码。
|
||||
|
||||
Unicode 本身只是一种字符集,它为每个字符分配一个唯一的数字编号,并没有规定具体的存储方式。UTF-8、UTF-16、UTF-32 都是 Unicode 的编码方式,它们使用不同的字节数来表示 Unicode 字符。例如,UTF-8 :英文占 1 字节,中文占 3 字节。
|
||||
|
||||
### Reader(字符输入流)
|
||||
|
||||
`Reader` 用于从源头(通常是文件)读取数据(字符信息)到内存中,`java.io.Reader` 抽象类是所有字符输入流的父类。
|
||||
|
||||
`Reader` 用于读取文本, `InputStream` 用于读取原始字节。
|
||||
|
||||
`Reader` 常用方法:
|
||||
|
||||
- `read()` : 从输入流读取一个字符。
|
||||
- `read(char[] cbuf)` : 从输入流中读取一些字符,并将它们存储到字符数组 `cbuf` 中,等价于 `read(cbuf, 0, cbuf.length)`。
|
||||
- `read(char[] cbuf, int off, int len)`:在 `read(char[] cbuf)` 方法的基础上增加了 `off` 参数(偏移量)和 `len` 参数(要读取的最大字符数)。
|
||||
- `skip(long n)`:忽略输入流中的 n 个字符 ,返回实际忽略的字符数。
|
||||
- `close()` : 关闭输入流并释放相关的系统资源。
|
||||
|
||||
`InputStreamReader` 是字节流转换为字符流的桥梁,其子类 `FileReader` 是基于该基础上的封装,可以直接操作字符文件。
|
||||
|
||||
```java
|
||||
// 字节流转换为字符流的桥梁
|
||||
public class InputStreamReader extends Reader {
|
||||
}
|
||||
// 用于读取字符文件
|
||||
public class FileReader extends InputStreamReader {
|
||||
}
|
||||
```
|
||||
|
||||
`FileReader` 代码示例:
|
||||
|
||||
```java
|
||||
try (FileReader fileReader = new FileReader("input.txt");) {
|
||||
int content;
|
||||
long skip = fileReader.skip(3);
|
||||
System.out.println("The actual number of characters skipped:" + skip);
|
||||
System.out.print("The content read from file:");
|
||||
while ((content = fileReader.read()) != -1) {
|
||||
System.out.print((char) content);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
`input.txt` 文件内容:
|
||||
|
||||

|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
The actual number of characters skipped:3
|
||||
The content read from file:我是Guide。
|
||||
```
|
||||
|
||||
### Writer(字符输出流)
|
||||
|
||||
`Writer` 用于将数据(字符信息)写入到目的地(通常是文件),`java.io.Writer` 抽象类是所有字符输出流的父类。
|
||||
|
||||
`Writer` 常用方法:
|
||||
|
||||
- `write(int c)` : 写入单个字符。
|
||||
- `write(char[] cbuf)`:写入字符数组 `cbuf`,等价于 `write(cbuf, 0, cbuf.length)`。
|
||||
- `write(char[] cbuf, int off, int len)`:在 `write(char[] cbuf)` 方法的基础上增加了 `off` 参数(偏移量)和 `len` 参数(要读取的最大字符数)。
|
||||
- `write(String str)`:写入字符串,等价于 `write(str, 0, str.length())`。
|
||||
- `write(String str, int off, int len)`:在 `write(String str)` 方法的基础上增加了 `off` 参数(偏移量)和 `len` 参数(要读取的最大字符数)。
|
||||
- `append(CharSequence csq)`:将指定的字符序列附加到指定的 `Writer` 对象并返回该 `Writer` 对象。
|
||||
- `append(char c)`:将指定的字符附加到指定的 `Writer` 对象并返回该 `Writer` 对象。
|
||||
- `flush()`:刷新此输出流并强制写出所有缓冲的输出字符。
|
||||
- `close()`:关闭输出流释放相关的系统资源。
|
||||
|
||||
`OutputStreamWriter` 是字符流转换为字节流的桥梁,其子类 `FileWriter` 是基于该基础上的封装,可以直接将字符写入到文件。
|
||||
|
||||
```java
|
||||
// 字符流转换为字节流的桥梁
|
||||
public class OutputStreamWriter extends Writer {
|
||||
}
|
||||
// 用于写入字符到文件
|
||||
public class FileWriter extends OutputStreamWriter {
|
||||
}
|
||||
```
|
||||
|
||||
`FileWriter` 代码示例:
|
||||
|
||||
```java
|
||||
try (Writer output = new FileWriter("output.txt")) {
|
||||
output.write("你好,我是Guide。");
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
输出结果:
|
||||
|
||||

|
||||
|
||||
## 字节缓冲流
|
||||
|
||||
IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。
|
||||
|
||||
字节缓冲流这里采用了装饰器模式来增强 `InputStream` 和 `OutputStream` 子类对象的功能。
|
||||
|
||||
举个例子,我们可以通过 `BufferedInputStream`(字节缓冲输入流)来增强 `FileInputStream` 的功能。
|
||||
|
||||
```java
|
||||
// 新建一个 BufferedInputStream 对象
|
||||
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
|
||||
```
|
||||
|
||||
字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 `write(int b)` 和 `read()` 这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。
|
||||
|
||||
我使用 `write(int b)` 和 `read()` 方法,分别通过字节流和字节缓冲流复制一个 `524.9 mb` 的 PDF 文件耗时对比如下:
|
||||
|
||||
```plain
|
||||
使用缓冲流复制PDF文件总耗时:15428 毫秒
|
||||
使用普通字节流复制PDF文件总耗时:2555062 毫秒
|
||||
```
|
||||
|
||||
两者耗时差别非常大,缓冲流耗费的时间是字节流的 1/165。
|
||||
|
||||
测试代码如下:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void copy_pdf_to_another_pdf_buffer_stream() {
|
||||
// 记录开始时间
|
||||
long start = System.currentTimeMillis();
|
||||
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解计算机操作系统.pdf"));
|
||||
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解计算机操作系统-副本.pdf"))) {
|
||||
int content;
|
||||
while ((content = bis.read()) != -1) {
|
||||
bos.write(content);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// 记录结束时间
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("使用缓冲流复制PDF文件总耗时:" + (end - start) + " 毫秒");
|
||||
}
|
||||
|
||||
@Test
|
||||
void copy_pdf_to_another_pdf_stream() {
|
||||
// 记录开始时间
|
||||
long start = System.currentTimeMillis();
|
||||
try (FileInputStream fis = new FileInputStream("深入理解计算机操作系统.pdf");
|
||||
FileOutputStream fos = new FileOutputStream("深入理解计算机操作系统-副本.pdf")) {
|
||||
int content;
|
||||
while ((content = fis.read()) != -1) {
|
||||
fos.write(content);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// 记录结束时间
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("使用普通流复制PDF文件总耗时:" + (end - start) + " 毫秒");
|
||||
}
|
||||
```
|
||||
|
||||
如果是调用 `read(byte b[])` 和 `write(byte b[], int off, int len)` 这两个写入一个字节数组的方法的话,只要字节数组的大小合适,两者的性能差距其实不大,基本可以忽略。
|
||||
|
||||
这次我们使用 `read(byte b[])` 和 `write(byte b[], int off, int len)` 方法,分别通过字节流和字节缓冲流复制一个 524.9 mb 的 PDF 文件耗时对比如下:
|
||||
|
||||
```plain
|
||||
使用缓冲流复制PDF文件总耗时:695 毫秒
|
||||
使用普通字节流复制PDF文件总耗时:989 毫秒
|
||||
```
|
||||
|
||||
两者耗时差别不是很大,缓冲流的性能要略微好一点点。
|
||||
|
||||
测试代码如下:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void copy_pdf_to_another_pdf_with_byte_array_buffer_stream() {
|
||||
// 记录开始时间
|
||||
long start = System.currentTimeMillis();
|
||||
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("深入理解计算机操作系统.pdf"));
|
||||
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("深入理解计算机操作系统-副本.pdf"))) {
|
||||
int len;
|
||||
byte[] bytes = new byte[4 * 1024];
|
||||
while ((len = bis.read(bytes)) != -1) {
|
||||
bos.write(bytes, 0, len);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// 记录结束时间
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("使用缓冲流复制PDF文件总耗时:" + (end - start) + " 毫秒");
|
||||
}
|
||||
|
||||
@Test
|
||||
void copy_pdf_to_another_pdf_with_byte_array_stream() {
|
||||
// 记录开始时间
|
||||
long start = System.currentTimeMillis();
|
||||
try (FileInputStream fis = new FileInputStream("深入理解计算机操作系统.pdf");
|
||||
FileOutputStream fos = new FileOutputStream("深入理解计算机操作系统-副本.pdf")) {
|
||||
int len;
|
||||
byte[] bytes = new byte[4 * 1024];
|
||||
while ((len = fis.read(bytes)) != -1) {
|
||||
fos.write(bytes, 0, len);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
// 记录结束时间
|
||||
long end = System.currentTimeMillis();
|
||||
System.out.println("使用普通流复制PDF文件总耗时:" + (end - start) + " 毫秒");
|
||||
}
|
||||
```
|
||||
|
||||
### BufferedInputStream(字节缓冲输入流)
|
||||
|
||||
`BufferedInputStream` 从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。
|
||||
|
||||
`BufferedInputStream` 内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组,通过阅读 `BufferedInputStream` 源码即可得到这个结论。
|
||||
|
||||
```java
|
||||
public
|
||||
class BufferedInputStream extends FilterInputStream {
|
||||
// 内部缓冲区数组
|
||||
protected volatile byte buf[];
|
||||
// 缓冲区的默认大小
|
||||
private static int DEFAULT_BUFFER_SIZE = 8192;
|
||||
// 使用默认的缓冲区大小
|
||||
public BufferedInputStream(InputStream in) {
|
||||
this(in, DEFAULT_BUFFER_SIZE);
|
||||
}
|
||||
// 自定义缓冲区大小
|
||||
public BufferedInputStream(InputStream in, int size) {
|
||||
super(in);
|
||||
if (size <= 0) {
|
||||
throw new IllegalArgumentException("Buffer size <= 0");
|
||||
}
|
||||
buf = new byte[size];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
缓冲区的大小默认为 **8192** 字节,当然了,你也可以通过 `BufferedInputStream(InputStream in, int size)` 这个构造方法来指定缓冲区的大小。
|
||||
|
||||
### BufferedOutputStream(字节缓冲输出流)
|
||||
|
||||
`BufferedOutputStream` 将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了效率
|
||||
|
||||
```java
|
||||
try (BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("output.txt"))) {
|
||||
byte[] array = "JavaGuide".getBytes();
|
||||
bos.write(array);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
类似于 `BufferedInputStream`,`BufferedOutputStream` 内部也维护了一个缓冲区,并且,这个缓存区的大小也是 **8192** 字节。
|
||||
|
||||
## 字符缓冲流
|
||||
|
||||
`BufferedReader`(字符缓冲输入流)和 `BufferedWriter`(字符缓冲输出流)类似于 `BufferedInputStream`(字节缓冲输入流)和 `BufferedOutputStream`(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息。
|
||||
|
||||
## 打印流
|
||||
|
||||
下面这段代码大家经常使用吧?
|
||||
|
||||
```java
|
||||
System.out.print("Hello!");
|
||||
System.out.println("Hello!");
|
||||
```
|
||||
|
||||
`System.out` 实际是用于获取一个 `PrintStream` 对象,`print` 方法实际调用的是 `PrintStream` 对象的 `write` 方法。
|
||||
|
||||
`PrintStream` 属于字节打印流,与之对应的是 `PrintWriter`(字符打印流)。`PrintStream` 是 `OutputStream` 的子类,`PrintWriter` 是 `Writer` 的子类。
|
||||
|
||||
```java
|
||||
public class PrintStream extends FilterOutputStream
|
||||
implements Appendable, Closeable {
|
||||
}
|
||||
public class PrintWriter extends Writer {
|
||||
}
|
||||
```
|
||||
|
||||
## 随机访问流
|
||||
|
||||
这里要介绍的随机访问流指的是支持随意跳转到文件的任意位置进行读写的 `RandomAccessFile`。
|
||||
|
||||
`RandomAccessFile` 的构造方法如下,我们可以指定 `mode`(读写模式)。
|
||||
|
||||
```java
|
||||
// openAndDelete 参数默认为 false 表示打开文件并且这个文件不会被删除
|
||||
public RandomAccessFile(File file, String mode)
|
||||
throws FileNotFoundException {
|
||||
this(file, mode, false);
|
||||
}
|
||||
// 私有方法
|
||||
private RandomAccessFile(File file, String mode, boolean openAndDelete) throws FileNotFoundException{
|
||||
// 省略大部分代码
|
||||
}
|
||||
```
|
||||
|
||||
读写模式主要有下面四种:
|
||||
|
||||
- `r` : 只读模式。
|
||||
- `rw`: 读写模式
|
||||
- `rws`: 相对于 `rw`,`rws` 同步更新对“文件的内容”或“元数据”的修改到外部存储设备。
|
||||
- `rwd` : 相对于 `rw`,`rwd` 同步更新对“文件的内容”的修改到外部存储设备。
|
||||
|
||||
文件内容指的是文件中实际保存的数据,元数据则是用来描述文件属性比如文件的大小信息、创建和修改时间。
|
||||
|
||||
`RandomAccessFile` 中有一个文件指针用来表示下一个将要被写入或者读取的字节所处的位置。我们可以通过 `RandomAccessFile` 的 `seek(long pos)` 方法来设置文件指针的偏移量(距文件开头 `pos` 个字节处)。如果想要获取文件指针当前的位置的话,可以使用 `getFilePointer()` 方法。
|
||||
|
||||
`RandomAccessFile` 代码示例:
|
||||
|
||||
```java
|
||||
RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw");
|
||||
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());
|
||||
// 指针当前偏移量为 6
|
||||
randomAccessFile.seek(6);
|
||||
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());
|
||||
// 从偏移量 7 的位置开始往后写入字节数据
|
||||
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});
|
||||
// 指针当前偏移量为 0,回到起始位置
|
||||
randomAccessFile.seek(0);
|
||||
System.out.println("读取之前的偏移量:" + randomAccessFile.getFilePointer() + ",当前读取到的字符" + (char) randomAccessFile.read() + ",读取之后的偏移量:" + randomAccessFile.getFilePointer());
|
||||
```
|
||||
|
||||
`input.txt` 文件内容:
|
||||
|
||||

|
||||
|
||||
输出:
|
||||
|
||||
```plain
|
||||
读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1
|
||||
读取之前的偏移量:6,当前读取到的字符G,读取之后的偏移量:7
|
||||
读取之前的偏移量:0,当前读取到的字符A,读取之后的偏移量:1
|
||||
```
|
||||
|
||||
`input.txt` 文件内容变为 `ABCDEFGHIJK`。
|
||||
|
||||
`RandomAccessFile` 的 `write` 方法在写入对象的时候如果对应的位置已经有数据的话,会将其覆盖掉。
|
||||
|
||||
```java
|
||||
RandomAccessFile randomAccessFile = new RandomAccessFile(new File("input.txt"), "rw");
|
||||
randomAccessFile.write(new byte[]{'H', 'I', 'J', 'K'});
|
||||
```
|
||||
|
||||
假设运行上面这段程序之前 `input.txt` 文件内容变为 `ABCD`,运行之后则变为 `HIJK`。
|
||||
|
||||
`RandomAccessFile` 比较常见的一个应用就是实现大文件的 **断点续传**。何谓断点续传?简单来说就是上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。分片(先将文件切分成多个文件分片)上传是断点续传的基础。
|
||||
|
||||
`RandomAccessFile` 可以帮助我们合并文件分片,示例代码如下:
|
||||
|
||||

|
||||
|
||||
我在[《Java 面试指北》](https://javaguide.cn/zhuanlan/java-mian-shi-zhi-bei.html)中详细介绍了大文件的上传问题。
|
||||
|
||||

|
||||
|
||||
`RandomAccessFile` 的实现依赖于 `FileDescriptor`(文件描述符) 和 `FileChannel`(内存映射文件)。
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,326 @@
|
||||
---
|
||||
title: Java IO 设计模式总结
|
||||
description: Java IO设计模式深度解析:详解装饰器模式在BufferedInputStream中应用、适配器模式InputStreamReader实现、模板方法模式InputStream设计,理解Java IO类库架构。
|
||||
category: Java
|
||||
tag:
|
||||
- Java IO
|
||||
- Java基础
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java IO设计模式,装饰器模式,适配器模式,模板方法模式,FilterInputStream,IO流设计
|
||||
---
|
||||
|
||||
这篇文章我们简单来看看我们从 IO 中能够学习到哪些设计模式的应用。
|
||||
|
||||
## 装饰器模式
|
||||
|
||||
**装饰器(Decorator)模式** 可以在不改变原有对象的情况下拓展其功能。
|
||||
|
||||
装饰器模式通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。
|
||||
|
||||
对于字节流来说, `FilterInputStream`(对应输入流)和 `FilterOutputStream`(对应输出流)是装饰器模式的核心,分别用于增强 `InputStream` 和 `OutputStream` 子类对象的功能。
|
||||
|
||||
我们常见的 `BufferedInputStream`(字节缓冲输入流)、`DataInputStream` 等等都是 `FilterInputStream` 的子类,`BufferedOutputStream`(字节缓冲输出流)、`DataOutputStream` 等等都是 `FilterOutputStream` 的子类。
|
||||
|
||||
举个例子,我们可以通过 `BufferedInputStream`(字节缓冲输入流)来增强 `FileInputStream` 的功能。
|
||||
|
||||
`BufferedInputStream` 构造函数如下:
|
||||
|
||||
```java
|
||||
public BufferedInputStream(InputStream in) {
|
||||
this(in, DEFAULT_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
public BufferedInputStream(InputStream in, int size) {
|
||||
super(in);
|
||||
if (size <= 0) {
|
||||
throw new IllegalArgumentException("Buffer size <= 0");
|
||||
}
|
||||
buf = new byte[size];
|
||||
}
|
||||
```
|
||||
|
||||
可以看出,`BufferedInputStream` 的构造函数其中的一个参数就是 `InputStream`。
|
||||
|
||||
`BufferedInputStream` 代码示例:
|
||||
|
||||
```java
|
||||
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("input.txt"))) {
|
||||
int content;
|
||||
long skip = bis.skip(2);
|
||||
while ((content = bis.read()) != -1) {
|
||||
System.out.print((char) content);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
```
|
||||
|
||||
这个时候,你可能会想了:**为啥我们不直接弄一个 `BufferedFileInputStream`(字符缓冲文件输入流)呢?**
|
||||
|
||||
```java
|
||||
BufferedFileInputStream bfis = new BufferedFileInputStream("input.txt");
|
||||
```
|
||||
|
||||
如果 `InputStream` 的子类比较少的话,这样做是没问题的。不过, `InputStream` 的子类实在太多,继承关系也太复杂了。如果我们为每一个子类都定制一个对应的缓冲输入流,那岂不是太麻烦了。
|
||||
|
||||
如果你对 IO 流比较熟悉的话,你会发现 `ZipInputStream` 和 `ZipOutputStream` 还可以分别增强 `BufferedInputStream` 和 `BufferedOutputStream` 的能力。
|
||||
|
||||
```java
|
||||
BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName));
|
||||
ZipInputStream zis = new ZipInputStream(bis);
|
||||
|
||||
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream(fileName));
|
||||
ZipOutputStream zipOut = new ZipOutputStream(bos);
|
||||
```
|
||||
|
||||
`ZipInputStream` 和 `ZipOutputStream` 分别继承自 `InflaterInputStream` 和 `DeflaterOutputStream`。
|
||||
|
||||
```java
|
||||
public
|
||||
class InflaterInputStream extends FilterInputStream {
|
||||
}
|
||||
|
||||
public
|
||||
class DeflaterOutputStream extends FilterOutputStream {
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
这也是装饰器模式很重要的一个特征,那就是可以对原始类嵌套使用多个装饰器。
|
||||
|
||||
为了实现这一效果,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。上面介绍到的这些 IO 相关的装饰类和原始类共同的父类是 `InputStream` 和 `OutputStream`。
|
||||
|
||||
对于字符流来说,`BufferedReader` 可以用来增加 `Reader`(字符输入流)子类的功能,`BufferedWriter` 可以用来增加 `Writer`(字符输出流)子类的功能。
|
||||
|
||||
```java
|
||||
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(fileName), "UTF-8"));
|
||||
```
|
||||
|
||||
IO 流中的装饰器模式应用的例子实在是太多了,不需要特意记忆,完全没必要哈!搞清了装饰器模式的核心之后,你在使用的时候自然就会知道哪些地方运用到了装饰器模式。
|
||||
|
||||
## 适配器模式
|
||||
|
||||
**适配器(Adapter Pattern)模式** 主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常经常使用的电源适配器。
|
||||
|
||||
适配器模式中存在被适配的对象或者类称为 **适配者(Adaptee)**,作用于适配者的对象或者类称为**适配器(Adapter)**。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
|
||||
|
||||
IO 流中的字符流和字节流的接口不同,它们之间可以协调工作就是基于适配器模式来做的,更准确点来说是对象适配器。通过适配器,我们可以将字节流对象适配成一个字符流对象,这样我们可以直接通过字节流对象来读取或者写入字符数据。
|
||||
|
||||
`InputStreamReader` 和 `OutputStreamWriter` 就是两个适配器(Adapter), 同时,它们两个也是字节流和字符流之间的桥梁。`InputStreamReader` 使用 `StreamDecoder`(流解码器)对字节进行解码,**实现字节流到字符流的转换,** `OutputStreamWriter` 使用 `StreamEncoder`(流编码器)对字符进行编码,实现字符流到字节流的转换。
|
||||
|
||||
`InputStream` 和 `OutputStream` 的子类是被适配者, `InputStreamReader` 和 `OutputStreamWriter` 是适配器。
|
||||
|
||||
```java
|
||||
// InputStreamReader 是适配器,FileInputStream 是被适配的类
|
||||
InputStreamReader isr = new InputStreamReader(new FileInputStream(fileName), "UTF-8");
|
||||
// BufferedReader 增强 InputStreamReader 的功能(装饰器模式)
|
||||
BufferedReader bufferedReader = new BufferedReader(isr);
|
||||
```
|
||||
|
||||
`java.io.InputStreamReader` 部分源码:
|
||||
|
||||
```java
|
||||
public class InputStreamReader extends Reader {
|
||||
//用于解码的对象
|
||||
private final StreamDecoder sd;
|
||||
public InputStreamReader(InputStream in) {
|
||||
super(in);
|
||||
try {
|
||||
// 获取 StreamDecoder 对象
|
||||
sd = StreamDecoder.forInputStreamReader(in, this, (String)null);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
// 使用 StreamDecoder 对象做具体的读取工作
|
||||
public int read() throws IOException {
|
||||
return sd.read();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`java.io.OutputStreamWriter` 部分源码:
|
||||
|
||||
```java
|
||||
public class OutputStreamWriter extends Writer {
|
||||
// 用于编码的对象
|
||||
private final StreamEncoder se;
|
||||
public OutputStreamWriter(OutputStream out) {
|
||||
super(out);
|
||||
try {
|
||||
// 获取 StreamEncoder 对象
|
||||
se = StreamEncoder.forOutputStreamWriter(out, this, (String)null);
|
||||
} catch (UnsupportedEncodingException e) {
|
||||
throw new Error(e);
|
||||
}
|
||||
}
|
||||
// 使用 StreamEncoder 对象做具体的写入工作
|
||||
public void write(int c) throws IOException {
|
||||
se.write(c);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**适配器模式和装饰器模式有什么区别呢?**
|
||||
|
||||
**装饰器模式** 更侧重于动态地增强原始类的功能,装饰器类需要跟原始类继承相同的抽象类或者实现相同的接口。并且,装饰器模式支持对原始类嵌套使用多个装饰器。
|
||||
|
||||
**适配器模式** 更侧重于让接口不兼容而不能交互的类可以一起工作,当我们调用适配器对应的方法时,适配器内部会调用适配者类或者和适配类相关的类的方法,这个过程透明的。就比如说 `StreamDecoder`(流解码器)和 `StreamEncoder`(流编码器)就是分别基于 `InputStream` 和 `OutputStream` 来获取 `FileChannel` 对象并调用对应的 `read` 方法和 `write` 方法进行字节数据的读取和写入。
|
||||
|
||||
```java
|
||||
StreamDecoder(InputStream in, Object lock, CharsetDecoder dec) {
|
||||
// 省略大部分代码
|
||||
// 根据 InputStream 对象获取 FileChannel 对象
|
||||
ch = getChannel((FileInputStream)in);
|
||||
}
|
||||
```
|
||||
|
||||
适配器和适配者两者不需要继承相同的抽象类或者实现相同的接口。
|
||||
|
||||
另外,`FutureTask` 类使用了适配器模式,`Executors` 的内部类 `RunnableAdapter` 实现属于适配器,用于将 `Runnable` 适配成 `Callable`。
|
||||
|
||||
`FutureTask` 参数包含 `Runnable` 的一个构造方法:
|
||||
|
||||
```java
|
||||
public FutureTask(Runnable runnable, V result) {
|
||||
// 调用 Executors 类的 callable 方法
|
||||
this.callable = Executors.callable(runnable, result);
|
||||
this.state = NEW;
|
||||
}
|
||||
```
|
||||
|
||||
`Executors` 中对应的方法和适配器:
|
||||
|
||||
```java
|
||||
// 实际调用的是 Executors 的内部类 RunnableAdapter 的构造方法
|
||||
public static <T> Callable<T> callable(Runnable task, T result) {
|
||||
if (task == null)
|
||||
throw new NullPointerException();
|
||||
return new RunnableAdapter<T>(task, result);
|
||||
}
|
||||
// 适配器
|
||||
static final class RunnableAdapter<T> implements Callable<T> {
|
||||
final Runnable task;
|
||||
final T result;
|
||||
RunnableAdapter(Runnable task, T result) {
|
||||
this.task = task;
|
||||
this.result = result;
|
||||
}
|
||||
public T call() {
|
||||
task.run();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工厂模式
|
||||
|
||||
工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 `Files` 类的 `newInputStream` 方法用于创建 `InputStream` 对象(静态工厂)、 `Paths` 类的 `get` 方法创建 `Path` 对象(静态工厂)、`ZipFileSystem` 类(`sun.nio` 包下的类,属于 `java.nio` 相关的一些内部实现)的 `getPath` 的方法创建 `Path` 对象(简单工厂)。
|
||||
|
||||
```java
|
||||
InputStream is = Files.newInputStream(Paths.get(generatorLogoPath))
|
||||
```
|
||||
|
||||
## 观察者模式
|
||||
|
||||
NIO 中的文件目录监听服务使用到了观察者模式。
|
||||
|
||||
NIO 中的文件目录监听服务基于 `WatchService` 接口和 `Watchable` 接口。`WatchService` 属于观察者,`Watchable` 属于被观察者。
|
||||
|
||||
`Watchable` 接口定义了一个用于将对象注册到 `WatchService`(监控服务) 并绑定监听事件的方法 `register`。
|
||||
|
||||
```java
|
||||
public interface Path
|
||||
extends Comparable<Path>, Iterable<Path>, Watchable{
|
||||
}
|
||||
|
||||
public interface Watchable {
|
||||
WatchKey register(WatchService watcher,
|
||||
WatchEvent.Kind<?>[] events,
|
||||
WatchEvent.Modifier... modifiers)
|
||||
throws IOException;
|
||||
}
|
||||
```
|
||||
|
||||
`WatchService` 用于监听文件目录的变化,同一个 `WatchService` 对象能够监听多个文件目录。
|
||||
|
||||
```java
|
||||
// 创建 WatchService 对象
|
||||
WatchService watchService = FileSystems.getDefault().newWatchService();
|
||||
|
||||
// 初始化一个被监控文件夹的 Path 类:
|
||||
Path path = Paths.get("workingDirectory");
|
||||
// 将这个 path 对象注册到 WatchService(监控服务) 中去
|
||||
WatchKey watchKey = path.register(
|
||||
watchService, StandardWatchEventKinds...);
|
||||
```
|
||||
|
||||
`Path` 类 `register` 方法的第二个参数 `events`(需要监听的事件)为可变长参数,也就是说我们可以同时监听多种事件。
|
||||
|
||||
```java
|
||||
WatchKey register(WatchService watcher,
|
||||
WatchEvent.Kind<?>... events)
|
||||
throws IOException;
|
||||
```
|
||||
|
||||
常用的监听事件有 3 种:
|
||||
|
||||
- `StandardWatchEventKinds.ENTRY_CREATE`:文件创建。
|
||||
- `StandardWatchEventKinds.ENTRY_DELETE` : 文件删除。
|
||||
- `StandardWatchEventKinds.ENTRY_MODIFY` : 文件修改。
|
||||
|
||||
`register` 方法返回 `WatchKey` 对象,通过 `WatchKey` 对象可以获取事件的具体信息比如文件目录下是创建、删除还是修改了文件、创建、删除或者修改的文件的具体名称是什么。
|
||||
|
||||
```java
|
||||
WatchKey key;
|
||||
while ((key = watchService.take()) != null) {
|
||||
for (WatchEvent<?> event : key.pollEvents()) {
|
||||
// 可以调用 WatchEvent 对象的方法做一些事情比如输出事件的具体上下文信息
|
||||
}
|
||||
key.reset();
|
||||
}
|
||||
```
|
||||
|
||||
`WatchService` 内部是通过一个 daemon thread(守护线程)采用定期轮询的方式来检测文件的变化,简化后的源码如下所示。
|
||||
|
||||
```java
|
||||
class PollingWatchService
|
||||
extends AbstractWatchService
|
||||
{
|
||||
// 定义一个 daemon thread(守护线程)轮询检测文件变化
|
||||
private final ScheduledExecutorService scheduledExecutor;
|
||||
|
||||
PollingWatchService() {
|
||||
scheduledExecutor = Executors
|
||||
.newSingleThreadScheduledExecutor(new ThreadFactory() {
|
||||
@Override
|
||||
public Thread newThread(Runnable r) {
|
||||
Thread t = new Thread(r);
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}});
|
||||
}
|
||||
|
||||
void enable(Set<? extends WatchEvent.Kind<?>> events, long period) {
|
||||
synchronized (this) {
|
||||
// 更新监听事件
|
||||
this.events = events;
|
||||
|
||||
// 开启定期轮询
|
||||
Runnable thunk = new Runnable() { public void run() { poll(); }};
|
||||
this.poller = scheduledExecutor
|
||||
.scheduleAtFixedRate(thunk, period, period, TimeUnit.SECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- Patterns in Java APIs:<http://cecs.wright.edu/~tkprasad/courses/ceg860/paper/node26.html>
|
||||
- 装饰器模式:通过剖析 Java IO 类库源码学习装饰器模式:<https://time.geekbang.org/column/article/204845>
|
||||
- sun.nio 包是什么,是 java 代码么? - RednaxelaFX <https://www.zhihu.com/question/29237781/answer/43653953>
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,140 @@
|
||||
---
|
||||
title: Java IO 模型详解
|
||||
description: Java IO模型详解:深入剖析BIO阻塞IO、NIO非阻塞IO、AIO异步IO三种模型、多路复用机制、Reactor/Proactor模式、同步异步阻塞非阻塞概念辨析。
|
||||
category: Java
|
||||
tag:
|
||||
- Java IO
|
||||
- Java基础
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java IO模型,BIO,NIO,AIO,阻塞IO,非阻塞IO,多路复用,Reactor模式,Proactor模式
|
||||
---
|
||||
|
||||
IO 模型这块确实挺难理解的,需要太多计算机底层知识。写这篇文章用了挺久,就非常希望能把我所知道的讲出来吧!希望朋友们能有收获!为了写这篇文章,还翻看了一下《UNIX 网络编程》这本书,太难了,我滴乖乖!心痛~
|
||||
|
||||
_个人能力有限。如果文章有任何需要补充/完善/修改的地方,欢迎在评论区指出,共同进步!_
|
||||
|
||||
## 前言
|
||||
|
||||
I/O 一直是很多小伙伴难以理解的一个知识点,这篇文章我会将我所理解的 I/O 讲给你听,希望可以对你有所帮助。
|
||||
|
||||
## I/O
|
||||
|
||||
### 何为 I/O?
|
||||
|
||||
I/O(**I**nput/**O**utput) 即**输入/输出**。
|
||||
|
||||
**我们先从计算机结构的角度来解读一下 I/O。**
|
||||
|
||||
根据冯.诺依曼结构,计算机结构分为 5 大部分:运算器、控制器、存储器、输入设备、输出设备。
|
||||
|
||||

|
||||
|
||||
输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
|
||||
|
||||
输入设备向计算机输入数据,输出设备接收计算机输出的数据。
|
||||
|
||||
**从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。**
|
||||
|
||||
**我们再先从应用程序的角度来解读一下 I/O。**
|
||||
|
||||
根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 **用户空间(User space)** 和 **内核空间(Kernel space)**。
|
||||
|
||||
像我们平常运行的应用程序都是运行在用户空间,只有内核空间才能进行系统态级别的资源有关的操作,比如文件管理、进程通信、内存管理等等。也就是说,我们想要进行 IO 操作,一定是要依赖内核空间的能力。
|
||||
|
||||
并且,用户空间的程序不能直接访问内核空间。
|
||||
|
||||
当想要执行 IO 操作时,由于没有执行这些操作的权限,只能发起系统调用请求操作系统帮忙完成。
|
||||
|
||||
因此,用户进程想要执行 IO 操作的话,必须通过 **系统调用** 来间接访问内核空间
|
||||
|
||||
我们在平常开发过程中接触最多的就是 **磁盘 IO(读写文件)** 和 **网络 IO(网络请求和响应)**。
|
||||
|
||||
**从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。**
|
||||
|
||||
当应用程序发起 I/O 调用后,会经历两个步骤:
|
||||
|
||||
1. 内核等待 I/O 设备准备好数据
|
||||
2. 内核将数据从内核空间拷贝到用户空间。
|
||||
|
||||
### 有哪些常见的 IO 模型?
|
||||
|
||||
UNIX 系统下, IO 模型一共有 5 种:**同步阻塞 I/O**、**同步非阻塞 I/O**、**I/O 多路复用**、**信号驱动 I/O** 和**异步 I/O**。
|
||||
|
||||
这也是我们经常提到的 5 种 IO 模型。
|
||||
|
||||
## Java 中 3 种常见 IO 模型
|
||||
|
||||
### BIO (Blocking I/O)
|
||||
|
||||
**BIO 属于同步阻塞 IO 模型**。
|
||||
|
||||
同步阻塞 IO 模型中,应用程序发起 read 调用后,会一直阻塞,直到内核把数据拷贝到用户空间。
|
||||
|
||||

|
||||
|
||||
在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。因此,我们需要一种更高效的 I/O 处理模型来应对更高的并发量。
|
||||
|
||||
### NIO (Non-blocking/New I/O)
|
||||
|
||||
Java 中的 NIO 于 Java 1.4 中引入,对应 `java.nio` 包,提供了 `Channel` , `Selector`,`Buffer` 等抽象。NIO 中的 N 可以理解为 Non-blocking,不单纯是 New。它是支持面向缓冲的,基于通道的 I/O 操作方法。 对于高负载、高并发的(网络)应用,应使用 NIO。
|
||||
|
||||
Java 中的 NIO 可以看作是 **I/O 多路复用模型**。也有很多人认为,Java 中的 NIO 属于同步非阻塞 IO 模型。
|
||||
|
||||
跟着我的思路往下看看,相信你会得到答案!
|
||||
|
||||
我们先来看看 **同步非阻塞 IO 模型**。
|
||||
|
||||

|
||||
|
||||
同步非阻塞 IO 模型中,应用程序会一直发起 read 调用,等待数据从内核空间拷贝到用户空间的这段时间里,线程依然是阻塞的,直到在内核把数据拷贝到用户空间。
|
||||
|
||||
相比于同步阻塞 IO 模型,同步非阻塞 IO 模型确实有了很大改进。通过轮询操作,避免了一直阻塞。
|
||||
|
||||
> 同步非阻塞 IO,发起一个 read 调用,如果数据没有准备好,这个时候应用程序可以不阻塞等待,而是切换去做一些小的计算任务,然后很快回来继续发起 read 调用,也就是轮询。这个
|
||||
> 轮询不是持续不断发起的,会有间隙, 这个间隙的利用就是同步非阻塞 IO 比同步阻塞 IO 高效的地方。
|
||||
|
||||
但是,这种 IO 模型同样存在问题:**应用程序不断进行 I/O 系统调用轮询数据是否已经准备好的过程是十分消耗 CPU 资源的。**
|
||||
|
||||
这个时候,**I/O 多路复用模型** 就上场了。
|
||||
|
||||

|
||||
|
||||
IO 多路复用模型中,线程首先发起 select 调用,询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。read 调用的过程(数据从内核空间 -> 用户空间)还是阻塞的。
|
||||
|
||||
> 目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。
|
||||
>
|
||||
> - **select 调用**:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
|
||||
> - **epoll 调用**:linux 2.6 内核,属于 select 调用的增强版本,优化了 IO 的执行效率。
|
||||
|
||||
**IO 多路复用模型,通过减少无效的系统调用,减少了对 CPU 资源的消耗。**
|
||||
|
||||
Java 中的 NIO,有一个非常重要的**选择器 ( Selector )** 的概念,也可以被称为 **多路复用器**。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。
|
||||
|
||||

|
||||
|
||||
### AIO (Asynchronous I/O)
|
||||
|
||||
AIO 也就是 NIO 2。Java 7 中引入了 NIO 的改进版 NIO 2,它是异步 IO 模型。
|
||||
|
||||
异步 IO 是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。
|
||||
|
||||

|
||||
|
||||
目前来说 AIO 的应用还不是很广泛。Netty 之前也尝试使用过 AIO,不过又放弃了。这是因为,Netty 使用了 AIO 之后,在 Linux 系统上的性能并没有多少提升。
|
||||
|
||||
最后,来一张图,简单总结一下 Java 中的 BIO、NIO、AIO。
|
||||
|
||||

|
||||
|
||||
## 参考
|
||||
|
||||
- 《深入拆解 Tomcat & Jetty》
|
||||
- 如何完成一次 IO:<https://llc687.top/126.html>
|
||||
- 程序员应该这样理解 IO:[https://www.jianshu.com/p/fa7bdc4f3de7](https://www.jianshu.com/p/fa7bdc4f3de7)
|
||||
- 10 分钟看懂, Java NIO 底层原理:<https://www.cnblogs.com/crazymakercircle/p/10225159.html>
|
||||
- IO 模型知多少 | 理论篇:<https://www.cnblogs.com/sheng-jie/p/how-much-you-know-about-io-models.html>
|
||||
- 《UNIX 网络编程 卷 1;套接字联网 API》6.2 节 IO 模型
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
@@ -0,0 +1,397 @@
|
||||
---
|
||||
title: Java NIO 核心知识总结
|
||||
description: Java NIO核心知识全面总结:详解Channel通道、Buffer缓冲区、Selector选择器三大核心组件、非阻塞IO实现、零拷贝技术、与传统IO性能对比。
|
||||
category: Java
|
||||
tag:
|
||||
- Java IO
|
||||
- Java基础
|
||||
head:
|
||||
- - meta
|
||||
- name: keywords
|
||||
content: Java NIO,Channel,Buffer,Selector,非阻塞IO,多路复用,零拷贝,NIO核心组件
|
||||
---
|
||||
|
||||
在学习 NIO 之前,需要先了解一下计算机 I/O 模型的基础理论知识。还不了解的话,可以参考我写的这篇文章:[Java IO 模型详解](https://javaguide.cn/java/io/io-model.html)。
|
||||
|
||||
## 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 效率和并发。
|
||||
|
||||
下图是 BIO、NIO 和 AIO 处理客户端请求的简单对比图(关于 AIO 的介绍,可以看我写的这篇文章:[Java IO 模型详解](https://javaguide.cn/java/io/io-model.html),不是重点,了解即可)。
|
||||
|
||||

|
||||
|
||||
⚠️需要注意:使用 NIO 并不一定意味着高性能,它的性能优势主要体现在高并发和高延迟的网络环境下。当连接数较少、并发程度较低或者网络传输速度较快时,NIO 的性能并不一定优于传统的 BIO。
|
||||
|
||||
## NIO 核心组件
|
||||
|
||||
NIO 主要包括以下三个核心组件:
|
||||
|
||||
- **Buffer(缓冲区)**:NIO 读写数据都是通过缓冲区进行操作的。读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
|
||||
- **Channel(通道)**:Channel 是一个双向的、可读可写的数据传输通道,NIO 通过 Channel 来实现数据的输入输出。通道是一个抽象的概念,它可以代表文件、套接字或者其他数据源之间的连接。
|
||||
- **Selector(选择器)**:允许一个线程处理多个 Channel,基于事件驱动的 I/O 多路复用模型。所有的 Channel 都可以注册到 Selector 上,由 Selector 来分配线程来处理事件。
|
||||
|
||||
三者的关系如下图所示(暂时不理解没关系,后文会详细介绍):
|
||||
|
||||

|
||||
|
||||
下面详细介绍一下这三个组件。
|
||||
|
||||
### Buffer(缓冲区)
|
||||
|
||||
在传统的 BIO 中,数据的读写是面向流的, 分为字节流和字符流。
|
||||
|
||||
在 Java 1.4 的 NIO 库中,所有数据都是用缓冲区处理的,这是新库和之前的 BIO 的一个重要区别,有点类似于 BIO 中的缓冲流。NIO 在读取数据时,它是直接读到缓冲区中的。在写入数据时,写入到缓冲区中。 使用 NIO 在读写数据时,都是通过缓冲区进行操作。
|
||||
|
||||
`Buffer` 的子类如下图所示。其中,最常用的是 `ByteBuffer`,它可以用来存储和操作字节数据。
|
||||
|
||||

|
||||
|
||||
你可以将 Buffer 理解为一个数组,`IntBuffer`、`FloatBuffer`、`CharBuffer` 等分别对应 `int[]`、`float[]`、`char[]` 等。
|
||||
|
||||
为了更清晰地认识缓冲区,我们来简单看看 `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` 可以存储的最大数据量,`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 中读取数据或者向 Buffer 中写入数据。Buffer 被创建之后默认是写模式,调用 `flip()` 可以切换到读模式。如果要再次切换回写模式,可以调用 `clear()` 或者 `compact()` 方法。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
`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");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
输出:
|
||||
|
||||
```bash
|
||||
初始状态:
|
||||
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
|
||||
```
|
||||
|
||||
为了帮助理解,我绘制了一张图片展示 `capacity`、`limit` 和 `position` 每一阶段的变化。
|
||||
|
||||

|
||||
|
||||
### Channel(通道)
|
||||
|
||||
Channel 是一个通道,它建立了与数据源(如文件、网络套接字等)之间的连接。我们可以利用它来读取和写入数据,就像打开了一条自来水管,让数据在 Channel 中自由流动。
|
||||
|
||||
BIO 中的流是单向的,分为各种 `InputStream`(输入流)和 `OutputStream`(输出流),数据只是在一个方向上传输。通道与流的不同之处在于通道是双向的,它可以用于读、写或者同时用于读写。
|
||||
|
||||
Channel 与前面介绍的 Buffer 打交道,读操作的时候将 Channel 中的数据填充到 Buffer 中,而写操作时将 Buffer 中的数据写入到 Channel 中。
|
||||
|
||||

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

|
||||
|
||||
其中,最常用的是以下几种类型的通道:
|
||||
|
||||
- `FileChannel`:文件访问通道;
|
||||
- `SocketChannel`、`ServerSocketChannel`:TCP 通信通道;
|
||||
- `DatagramChannel`:UDP 通信通道;
|
||||
|
||||

|
||||
|
||||
Channel 最核心的两个方法:
|
||||
|
||||
1. `read`:读取数据并写入到 Buffer 中。
|
||||
2. `write`:将 Buffer 中的数据写入到 Channel 中。
|
||||
|
||||
这里我们以 `FileChannel` 为例演示一下是读取文件数据的。
|
||||
|
||||
```java
|
||||
RandomAccessFile reader = new RandomAccessFile("/Users/guide/Documents/test_read.in", "r");
|
||||
FileChannel channel = reader.getChannel();
|
||||
ByteBuffer buffer = ByteBuffer.allocate(1024);
|
||||
channel.read(buffer);
|
||||
```
|
||||
|
||||
### 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 可以监听以下四种事件类型:
|
||||
|
||||
1. `SelectionKey.OP_ACCEPT`:表示通道接受连接的事件,这通常用于 `ServerSocketChannel`。
|
||||
2. `SelectionKey.OP_CONNECT`:表示通道完成连接的事件,这通常用于 `SocketChannel`。
|
||||
3. `SelectionKey.OP_READ`:表示通道准备好进行读取的事件,即有数据可读。
|
||||
4. `SelectionKey.OP_WRITE`:表示通道准备好进行写入的事件,即可以写入数据。
|
||||
|
||||
`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` 会被彻底删除,程序通常无须直接访问该集合,也没有暴露访问的方法。
|
||||
|
||||
简单演示一下如何遍历被选择的 `SelectionKey` 集合并进行处理:
|
||||
|
||||
```java
|
||||
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 实现网络读写的简单示例:
|
||||
|
||||
```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 零拷贝
|
||||
|
||||
零拷贝是提升 IO 操作性能的一个常用手段,像 ActiveMQ、Kafka、RocketMQ、QMQ、Netty 等顶级开源项目都用到了零拷贝。
|
||||
|
||||
零拷贝是指计算机执行 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()` 是 NIO 基于发送文件(`sendfile`)这种零拷贝方式的提供的一种实现,底层实际是调用了 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();
|
||||
// FileChannel.map() 将文件映射到直接内存并返回 MappedByteBuffer 对象
|
||||
MappedByteBuffer mmb = fc.map(FileChannel.MapMode.READ_ONLY, 0, fc.size());
|
||||
xmlFileBuffer = new byte[(int)fc.size()];
|
||||
mmb.get(xmlFileBuffer);
|
||||
fis.close();
|
||||
}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
这篇文章我们主要介绍了 NIO 的核心知识点,包括 NIO 的核心组件和零拷贝。
|
||||
|
||||
如果我们需要使用 NIO 构建网络程序的话,不建议直接使用原生 NIO,编程复杂且功能性太弱,推荐使用一些成熟的基于 NIO 的网络编程框架比如 Netty。Netty 在 NIO 的基础上进行了一些优化和扩展比如支持多种协议、支持 SSL/TLS 等等。
|
||||
|
||||
## 参考
|
||||
|
||||
- Java NIO 浅析:<https://tech.meituan.com/2016/11/04/nio.html>
|
||||
|
||||
- 面试官:Java NIO 了解?<https://mp.weixin.qq.com/s/mZobf-U8OSYQfHfYBEB6KA>
|
||||
|
||||
- Java NIO:Buffer、Channel 和 Selector:<https://www.javadoop.com/post/java-nio>
|
||||
|
||||
<!-- @include: @article-footer.snippet.md -->
|
||||
Reference in New Issue
Block a user