Java(四):4.0 [核心] Java I/O 流体系与实战

4.0 [核心] Java I/O 流体系与实战

本章将深入Java的I/O(输入/输出)世界。I/O是程序与外部世界(如文件、网络、控制台)沟通的桥梁。我们将从I/O的“四大家族”和装饰器设计模式入手,理解其核心设计思想,然后深入文件操作的现代实践(NIO.2),并最终探讨如何在真实项目中,利用强大的第三方库来告别繁琐的I/O样板代码。

4.1 I/O 核心概念与设计模式

4.1.1 面试题引入

“请谈谈你对Java I/O的理解。字节流和字符流有什么区别?节点流和处理流呢?”

4.1.2 流的“四大家族”与核心区别

I/O的本质是程序与外部数据源之间的数据传输通道。Java通过流(Stream)这一抽象概念来表示这个通道,并根据数据传输单位方向的不同,提供了“四大家族”作为所有I/O操作的基石:

分类维度方向字节流 (处理一切数据,如图片、视频、文本)字符流 (专为处理文本数据优化)
输入(读)数据源 -> 程序InputStream (抽象基类)Reader (抽象基类)
输出(写)程序 -> 数据源OutputStream (抽象基类)Writer (抽象基类)
字节流 vs. 字符流

这是I/O体系中最根本的区别,也是面试中的高频考点。

  • 字节流 (InputStream/OutputStream)字节(byte,
    8-bit)为单位进行读写。它是最原始、最通用的流,可以处理任何类型的二进制数据,如图片、音频、视频文件等。但它的缺点在于,在处理文本时,它不关心字符编码

  • 字符流 (Reader/Writer)字符(char,
    16-bit)为单位进行读写。它是在字节流的基础上构建的,专门用于处理文本数据。其内部封装了字节到字符的解码
    和字符到字节的编码过程。因此,字符流能够正确地处理包含各种语言(如中文)的文本,有效避免乱码问题。

代码示例:乱码问题的产生与解决

场景:我们有一个UTF-8编码的文本文件test.txt,内容为“你好Java”。

  • 错误演示:使用字节流读取文本

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    package com.example;

    import java.io.FileInputStream;
    import java.io.IOException;

    public class Main {
    public static void main(String[] args) throws IOException {
    FileInputStream fis = new FileInputStream("test.txt");
    System.out.println("--- 使用字节流逐字节读取 ---");
    int byteData;
    while ((byteData = fis.read()) != -1) {
    System.out.print((char) byteData);
    }
    System.out.println("\n结果:出现了乱码。");
    }
    }

    原因分析:在UTF-8编码中,一个中文字符通常由3个字节表示。上述代码一次只读一个字节,并试图将其强转为字符,自然无法正确还原“你”和“好”这两个字,导致乱码。

  • 正确方式:使用字符流读取文本

    1
    2
    3
    4
    package com.example;

    import java.io.FileInputStream;
    import java.io.FileReader;

import java.io.IOException;

public class Main {
    public static void main(String[] args) throws IOException {
        FileReader reader = new FileReader("test.txt");
        System.out.println("--- 使用字符流逐字符读取 ---");
        int byteData;
        while ((byteData = reader.read()) != -1) {
            System.out.print((char) byteData);
        }
        System.out.println("\n结果:乱码没了");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

#### **4.1.3 节点流 vs. 处理流**

这是从功能层面对流的另一种划分方式。

- **节点流 (Node
Stream)**:也被称为“低级流”。它们是直接与数据源(如文件、网络套接字、内存数组)相连接的“管道”,负责实际的数据传输。例如
`FileInputStream`、`ByteArrayInputStream`。
- **处理流 (Processing
Stream)**:也被称为“高级流”。它们**不直接连接数据源**,而是“套”在已存在的流(节点流或其他处理流)之上,像一个“过滤器”或“增强器”,为原始的流增加额外的功能。例如
`BufferedInputStream`(增加缓冲功能)、`ObjectInputStream`(增加对象反序列化功能)。

* **缓冲功能**:`BufferedInputStream`
通过内部缓存机制,一次性读取较多数据,减少与底层数据源(如文件、网络)的交互次数,从而提高读取性能。
* **反序列化功能**:`ObjectInputStream` 可以将之前通过 `ObjectOutputStream`
写入的 Java 对象恢复为内存中的对象,实现对象的持久化和传输。

为了最清晰地展示两者的区别与关系,我们设定一个共同的、非常常见的开发任务:**读取一个文本文件的内容,并将其逐行打印到控制台**。

假设我们有一个名为 `poem.txt` 的文件,内容如下:

床前明月光,疑是地上霜。举头望明月,低头思故乡。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

##### **场景一:仅使用节点流(低级、繁琐的方式)**

如果我们只使用节点流(如`FileInputStream`),意味着我们需要亲自处理最原始的字节数据,并手动管理缓冲和字符转换。

```java
package com.example;

import java.io.FileInputStream;
import java.io.IOException;

public class Main {
public static void main(String[] args) {
// 使用 try-with-resources 确保流被关闭
try (
// 1. 创建一个节点流,直接连接到数据源(文件)
FileInputStream fis = new FileInputStream("poem.txt")
) {
System.out.println("--- 仅使用节点流 FileInputStream 读取 ---");

// 2. 我们必须自己创建一个字节数组作为“缓冲区”
byte[] buffer = new byte[1024];
int bytesRead;

// 3. 手动循环读取字节块
while ((bytesRead = fis.read(buffer)) != -1) {
// 4. 手动将读取到的字节块,按指定编码转换为字符串
// 这里的操作非常底层,且没有方便的按行读取功能
String chunk = new String(buffer, 0, bytesRead, "UTF-8");
System.out.print(chunk);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

分析这种方式的痛点:

  • 操作底层:我们直接和原始的 byte[] 打交道。
  • 功能有限FileInputStream 本身不提供按行读取 (readLine)
    这样的便捷功能。
  • 编码繁琐:需要手动处理字节到字符串的转换。
场景二:使用处理流包装节点流(高级、推荐的方式)

现在,我们引入处理流 BufferedReader 来“装饰”或“增强”节点流 FileReader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package com.example;

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class Main {
public static void main(String[] args) {
try (
// 1. 节点流 FileReader: 仍然是直接连接到文件的管道,它负责基础的字符读取
FileReader fileReader = new FileReader("poem.txt");

// 2. 处理流 BufferedReader: “套”在节点流之上,为其增加强大的功能
BufferedReader bufferedReader = new BufferedReader(fileReader)
) {
System.out.println("--- 使用处理流 BufferedReader + 节点流 FileReader 读取 ---");
String line;

// 3. 直接使用处理流提供的、便捷的 readLine() 方法
// BufferedReader 在内部帮我们处理好了缓冲和按行读取的所有细节
while ((line = bufferedReader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
两种方式对比总结
对比维度仅使用节点流 (FileInputStream)处理流 + 节点流 (BufferedReader + FileReader)
职责负责连接数据源,进行最基础的字节读写。节点流负责连接数据源,处理流负责功能增强。
易用性差,需要手动处理缓冲、编码、按行读取等。,提供了readLine()等便捷API。
性能低,每次read()都可能是一次物理磁盘I/O。,内部缓冲机制大大减少了物理I/O次数。
代码量繁琐,样板代码多。简洁,代码意图清晰。

通过这个对比,我们可以清晰地看到处理流的价值:它将开发者从复杂的底层I/O细节中解放出来,让我们能更专注于业务逻辑本身,同时还能获得更好的性能。

在实际开发中,我们几乎总是使用处理流包装节点流的方式来进行I/O操作,这正是装饰器模式在Java
I/O中应用的精髓。

4.1.4 [设计模式] java.io 的灵魂:装饰器模式

理解Java I/O体系的关键,在于理解其背后优美的装饰器设计模式(Decorator
Pattern)
。该模式允许我们向一个现有对象动态地添加新的功能,同时又不改变其结构。

在I/O中,FileInputStream等节点流是我们的基础组件(ConcreteComponent),而BufferedInputStream等处理流则是装饰器(Decorator)。我们可以像搭积木一样,自由地将这些装饰器“套”在基础组件上,按需组合出强大的功能。

代码示例:装饰器的层层嵌套

场景:从一个文件中,以高效缓冲的方式,读取一个被序列化后的Java对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example;

import java.io.*;

public class Main {
public static void main(String[] args) {
// 假设我们有一个名为 "user.ser" 的文件,存储了一个User对象

// 这是一个典型的装饰器模式应用
try (
// 1. 最内层:节点流,直接连接数据源文件
FileInputStream fileIn = new FileInputStream("user.ser");

// 2. 中间层:处理流,为文件流增加“缓冲”功能,提升性能
BufferedInputStream bufferedIn = new BufferedInputStream(fileIn);

// 3. 最外层:处理流,为缓冲流增加“对象反序列化”功能
ObjectInputStream objectIn = new ObjectInputStream(bufferedIn)
) {
// 最终,我们通过功能最强大的最外层流进行操作
// User user = (User) objectIn.readObject();
System.out.println("I/O流已成功按装饰器模式构建。");
System.out.println("操作顺序: ObjectInputStream -> BufferedInputStream -> FileInputStream -> 文件");

} catch (IOException e) {
// try-with-resources 语句会自动按相反的顺序关闭所有流,无需手动操作
e.printStackTrace();
}
}
}

这种设计使得Java
I/O体系既灵活又可扩展。当需要新功能时,只需创建一个新的处理流(装饰器)即可,而无需修改现有的任何流类。


4.2 文件操作:从 传统File 到 现代NIO.2

在理解了I/O流的分类和装饰器设计模式后,我们将聚焦于I/O操作的一个核心应用——文件操作。这包括如何创建、删除、重命名文件和目录,以及如何读取它们的属性。Java为此提供了两代API,我们将对比学习,并重点掌握现代化的解决方案。

4.2.1 传统方式:java.io.File

File类是Java早期用于表示文件系统中的一个文件或目录路径的抽象。它可以用于执行创建、删除、重命名等操作,但其设计存在一些固有缺陷,因此在现代Java开发中已不被推荐作为首选。

核心用途与场景

在维护旧项目或使用一些尚未升级到NIO.2的老旧第三方库时,我们仍然会遇到File类。

常用方法速查表
方法签名功能描述
boolean exists()检查文件或目录是否存在。
boolean createNewFile()创建一个新文件。
boolean mkdir() / mkdirs()创建单级/多级目录。
boolean delete()删除文件或目录。
boolean renameTo(File dest)重命名或移动文件。
String getName() / getAbsolutePath()获取名称/绝对路径。
long length()获取文件大小(字节)。
boolean isDirectory() / isFile()判断是目录还是文件。
File[] listFiles()列出目录下的文件和子目录。
设计缺陷与痛点
  1. 错误处理不友好:许多关键操作(如delete(),
    renameTo())在失败时仅返回false,而不会抛出异常。这使得我们无法得知失败的具体原因(是权限不足?文件被占用?还是其他问题?),给可靠的错误处理带来了巨大困难。
  2. 功能有限:原生不支持符号链接等现代文件系统特性,也无法方便地访问和修改文件元数据(如权限、所有者等)。
  3. 无力处理非空目录delete()方法只能删除文件或空目录,要删除整个目录树需要自己编写复杂的递归逻辑。

4.2.2 [现代实践] java.nio.file 包 (“NIO.2”)

自Java
7起,NIO.2的引入为文件系统操作带来了革命性的变化。它以Path接口为核心,通过PathsFiles两个强大的工具类,提供了功能更全面、设计更优良、错误处理更明确的现代文件操作API。

  • 核心优势
    • 明确的异常处理:所有操作在失败时都会抛出具体的IOException,让问题无处遁形。
    • 强大的功能:原生支持符号链接、文件属性视图、文件系统监视等高级功能。
    • 高效的API:提供了大量便捷、高效的静态方法,可以用一行代码完成过去需要一个方法块才能实现的功能。
代码实战:Files 工具类的强大功能

下面通过几个核心场景,对比展示NIO.2相比于传统File类的巨大优势。

场景一:文件的创建与读写

需求:创建一个文本文件,向其中写入内容,然后再读取出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
package com.example;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;

public class Main {
public static void main(String[] args) {
Path filepath = Paths.get("poem.txt");
try {
// 1. 使用Paths.get()创建Path对象,这是现代文件路径的表示方式
Path filePath = Paths.get("poem.txt");
// 2. 写入文件(如果文件不存在则创建,如果存在则追加内容)
// Files.writeString() 是Java 11+的方法,极其方便
String contentToWrite = "这是用NIO.2写入的第一行。\n";
// 1.READ:以只读方式打开文件。
// 2.WRITE:以写入方式打开文件。
// 3.APPEND:如果文件已存在,则将数据追加到文件末尾(而不是覆盖)。
// 4.TRUNCATE_EXISTING:如果文件已存在,则将其长度截断为 0(即清空文件内容)。
// 5.CREATE:如果文件不存在,则创建新文件。
// 6.CREATE_NEW:如果文件已存在,则抛出异常;否则创建新文件。
// 7.DELETE_ON_CLOSE:在关闭文件时尝试删除该文件。
// 8.SPARSE:提示系统创建一个稀疏文件(仅在支持稀疏文件的文件系统上有效)。
// 9.SYNC:每次更新文件内容或元数据时都同步写入磁盘。
// 10.DSYNC:每次更新文件内容时都同步写入磁盘,但不包括元数据。
Files.writeString(filePath, contentToWrite, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
String secondLine = "这是追加的第二行。\n";
Files.writeString(filePath, secondLine, StandardOpenOption.APPEND);
System.out.println("文件 '" + filePath.getFileName() + "' 写入完成。");
// 3. 读取文件所有行到List中
System.out.println("\n--- 读取文件内容 ---");
List<String> lines = Files.readAllLines(filePath);
lines.forEach(System.out::println);
} catch (IOException e) {
// NIO.2的错误处理非常明确
System.err.println("文件操作失败: " + e.getMessage());
}
}
}
场景二:文件与目录的复制

需求:将一个文件复制到另一个位置,并将一个完整的目录(包含子目录和文件)复制到新位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example;

import java.io.IOException;
import java.nio.file.*;
import java.util.stream.Stream;

public class Main {
public static void main(String[] args) throws IOException {
// --- 1. 复制单个文件 ---
Path sourceFile = Paths.get("source.txt");
Path destFile = Paths.get("dest_folder/source_copy.txt");

// 准备源文件和目标目录
Files.createDirectories(destFile.getParent());
Files.writeString(sourceFile, "一些源数据");

// 使用Files.copy,如果目标文件已存在则替换
Files.copy(sourceFile, destFile, StandardCopyOption.REPLACE_EXISTING);
System.out.println("单个文件复制完成!");


// --- 2. 递归复制整个目录 ---
Path sourceDir = Paths.get("my_app_v1");
Path destDir = Paths.get("my_app_v2");

// 准备源目录
Files.createDirectories(sourceDir.resolve("config"));
Files.writeString(sourceDir.resolve("main.conf"), "data");

// 使用Stream API和Files.walk来递归复制
try (Stream<Path> stream = Files.walk(sourceDir)) {
stream.forEach(sourcePath -> {
try {
Path targetPath = destDir.resolve(sourceDir.relativize(sourcePath));
Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
System.err.println("无法复制: " + sourcePath);
}
});
}
System.out.println("整个目录已成功复制!");
}
}

结论:在所有新的Java项目中,都应优先并坚持使用java.nio.file进行文件和目录操作。它更安全、功能更强大、代码也更现代化。


4.2.3 java.nio.file.Files 核心方法速查表

好的,明白了。为了让速查表更加简洁、一目了然,我将移除“方法签名”列中的返回类型和修饰符,只保留核心的方法名和参数示意。

1. 文件和目录检查
方法名功能描述注意事项 / 最佳实践
exists(...)检查文件或目录是否存在。最常用的检查方法。
notExists(...)检查文件或目录是否存在。!Files.exists(path) 的一个更具可读性的替代方案。
isDirectory(...)判断路径是否为目录。
isRegularFile(...)判断路径是否为普通文件。
isReadable(...)判断文件是否可读。
isWritable(...)判断文件是否可写。
isExecutable(...)判断文件是否可执行。
isSameFile(...)判断两个Path是否指向同一个文件。p1.equals(p2)更可靠,因为它会处理符号链接等情况。
2. 文件和目录创建
方法名功能描述注意事项 / 最佳实践
createFile(...)创建一个新文件。如果文件已存在,会抛出FileAlreadyExistsException
createDirectory(...)创建一个新目录。只能创建单级目录,如果父目录不存在会抛出异常。
createDirectories(...)强烈推荐。创建多级目录。如果父目录不存在,会自动一并创建,非常方便。
createTempFile(...)在默认或指定位置创建一个临时文件。常用于需要临时存储数据的场景。
createTempDirectory(...)创建一个临时目录。
3. 文件和目录删除
方法名功能描述注意事项 / 最佳实践
delete(...)删除一个文件或目录。如果路径不存在,抛出NoSuchFileException。如果目录非空,抛出DirectoryNotEmptyException
deleteIfExists(...)如果文件或目录存在,则删除它。推荐使用。比delete()更安全,因为它不会在文件不存在时抛出异常,只会返回false
4. 文件读/写操作(小文件)

这些方法会将文件的全部内容一次性读入内存,非常便捷,但只适用于小文件。

方法名功能描述注意事项 / 最佳实践
readAllBytes(...)将文件的所有内容读取为一个字节数组。注意内存溢出(OOM)风险,不适用于大文件。
readAllLines(...)将文件的所有行读取到一个字符串列表中。注意内存溢出风险。默认使用UTF-8编码。
write(...)将一个字节数组或字符串集合写入文件。默认会覆盖已有文件。可使用StandardOpenOption指定追加、创建等。
writeString(...)[Java 11+] 将字符串写入文件。写入文本最简单的方式。
readString(...)[Java 11+] 读取文件内容为字符串。读取小文本文件最简单的方式。
5. 文件读/写操作(大文件/流式处理)

当处理大文件时,应使用流式API,避免一次性将所有内容加载到内存。

方法名功能描述注意事项 / 最佳实践
newInputStream(...)打开一个文件,返回一个用于读取的InputStream处理大文件的标准方式。获取流之后,需配合try-with-resources使用。
newOutputStream(...)打开或创建一个文件,返回一个用于写入的OutputStream同上。
newBufferedReader(...)打开一个文件,返回一个用于读取文本的BufferedReader提供了高效的readLine()方法,是读取大文本文件的首选。
newBufferedWriter(...)打开或创建一个文件,返回一个BufferedWriter写入大文本文件的首选。
lines(...)[Java 8+] 返回一个由文件所有行组成的Stream懒加载,非常适合用函数式编程风格处理大文本文件,内存占用极小。
6. 复制与移动
方法名功能描述注意事项 / 最佳实践
copy(...)复制一个文件或目录。默认情况下,如果目标文件已存在会失败。需使用StandardCopyOption.REPLACE_EXISTING来覆盖。复制目录时,只复制目录本身,不复制其内容。
move(...)移动或重命名一个文件。可以指定StandardCopyOption.ATOMIC_MOVE来保证操作的原子性。
7. 目录遍历 (Stream API)
方法名功能描述注意事项 / 最佳实践
list(...)[Java 8+] 返回一个表示目录下所有条目(不含子目录)的Stream非递归,只遍历当前层级。
walk(...)[Java 8+] 返回一个从指定路径开始、递归遍历所有文件和目录的Stream功能强大,可以配合filter等操作轻松实现文件查找等功能。
find(...)[Java 8+] 功能类似walk,但可以额外传入一个匹配器来筛选路径。walk后接filter更高效。

4.3 核心I/O处理流搭配使用

在掌握了文件和目录的表示方法后,我们回到流本身,聚焦于如何通过组合不同的处理流,来高效、灵活地读写文件内容。

4.3.1 缓冲流 (BufferedInputStream / BufferedReader): 性能优化的基石

面试题引入

“为什么我们在进行文件IO时,总是推荐使用缓冲流?它的原理是什么?”

核心原理解析

计算机中,对磁盘或网络的I/O操作(系统调用)相比于内存操作,是极其缓慢的。如果我们直接使用FileInputStreamread()方法逐字节读取文件,那么每读取一个字节,就可能触发一次昂贵的物理磁盘访问。

缓冲流(Buffered
Stream)正是为解决这一性能瓶颈而生。它的原理是在内部维护一个内存缓冲区
(一个字节或字符数组,默认大小通常为8192)。

  • 读取时BufferedInputStream会一次性从磁盘读取一大块数据(例如8KB)填充到内部缓冲区。之后你再调用read()方法时,它会直接从高速的内存缓冲区中返回数据,直到缓冲区耗尽,才会再次触发下一次对磁盘的大块读取。
  • 写入时BufferedOutputStream会先将你写入的数据存放在缓冲区,直到缓冲区满了,或者你手动调用flush()方法,它才会将整个缓冲区的数据一次性写入磁盘。

结论:缓冲流通过**“化零为整”**的策略,用一次大的I/O操作替代了无数次小的I/O操作,极大地减少了与底层物理设备的交互次数,从而实现性能的飞跃。

代码实战:文件复制性能对比

场景:复制一个文件,对比使用缓冲流和不使用缓冲流的耗时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package com.example;

import java.io.*;

public class Main {

// 为了测试,先创建一个较大的文件
static {
try (FileWriter writer = new FileWriter("source_file.txt")) {
for (int i = 0; i < 100000; i++) {
writer.write("abcdefghij\n");
}
} catch (IOException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
// 方案一:不使用缓冲流,逐字节复制
long start1 = System.currentTimeMillis();
copyFileWithoutBuffer();
long end1 = System.currentTimeMillis();
System.out.println("不使用缓冲流耗时: " + (end1 - start1) + " ms");

// 方案二:使用缓冲流
long start2 = System.currentTimeMillis();
copyFileWithBuffer();
long end2 = System.currentTimeMillis();
System.out.println("使用缓冲流耗时: " + (end2 - start2) + " ms");
}

public static void copyFileWithoutBuffer() {
try (FileInputStream fis = new FileInputStream("source_file.txt");
FileOutputStream fos = new FileOutputStream("copy_no_buffer.txt")) {
int byteData;
while ((byteData = fis.read()) != -1) {
fos.write(byteData);
}
} catch (IOException e) {
e.printStackTrace();
}
}

public static void copyFileWithBuffer() {
try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream("source_file.txt"));
BufferedOutputStream bos = new BufferedOutputStream(new FileOutputStream("copy_with_buffer.txt"))) {
int byteData;
while ((byteData = bis.read()) != -1) {
bos.write(byteData);
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

运行结果(示例)

1
2
不使用缓冲流耗时: 12629 ms
使用缓冲流耗时: 60 ms

可以看到,性能差异是数量级的。因此,在进行任何文件I/O时,使用缓冲流包装节点流都应成为一种编程习惯

4.3.2 转换流 (InputStreamReader / OutputStreamWriter): 字节与字符的桥梁

核心用途

转换流的核心作用是适配器,它能将字节流转换为字符流,并在转换过程中处理字符编码

应用场景

当你需要读写的文本文件编码与当前操作系统的默认编码不一致时,必须使用转换流来显式指定正确的编码,否则就会产生乱码。

  • 读取:例如,在UTF-8的服务器上读取一个由Windows记事本(默认GBK编码)生成的中文文件。
  • 写入:例如,无论程序运行在什么系统上,都希望统一生成UTF-8编码的配置文件。
代码示例:读取GBK文件并转存为UTF-8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
package com.example;

import java.io.*;
import java.nio.charset.StandardCharsets;

public class Main {
public static void main(String[] args) {
// 假设 gbk_file.txt 是一个GBK编码的文件,内容为“你好,世界”
// 我们可以先手动创建一个这样的文件用于测试

try (
// 1. 创建一个输入字节流连接到源文件
FileInputStream fis = new FileInputStream("gbk_file.txt");
// 2. 使用转换流InputStreamReader,指定用GBK编码来解码字节流
InputStreamReader isr = new InputStreamReader(fis, "GBK");
// 3. 为了效率,再套上一个BufferedReader
BufferedReader br = new BufferedReader(isr);

// 4. 创建一个输出字节流连接到目标文件
FileOutputStream fos = new FileOutputStream("utf8_file.txt");
// 5. 使用转换流OutputStreamWriter,指定用UTF-8编码来编码字符流
OutputStreamWriter osw = new OutputStreamWriter(fos, StandardCharsets.UTF_8);
// 6. 同样,套上BufferedWriter
BufferedWriter bw = new BufferedWriter(osw)
) {
String line;
while ((line = br.readLine()) != null) {
bw.write(line);
bw.newLine();
}
System.out.println("编码转换完成!");

} catch (IOException e) {
e.printStackTrace();
}
}
}

4.3.3 对象流 (ObjectInputStream / ObjectOutputStream): 对象的序列化与反序列化

面试题引入

“什么是Java的序列化?transient关键字和serialVersionUID有什么作用?”

核心概念
  • 序列化 (Serialization):将一个Java对象的状态转换为字节序列的过程。
  • 反序列化 (Deserialization):从字节序列中恢复并重建Java对象的过程。
  • 用途:实现对象的持久化(保存到文件或数据库)和网络传输
关键知识点
  1. Serializable
    接口
    :一个类必须实现这个标记接口(没有任何方法),其对象才能被序列化。它像一个“通行证”,告诉JVM这个类的对象可以被“扁平化”为字节。
  2. transient
    关键字
    :用于修饰字段。被transient修饰的字段将被排除在序列化过程之外,不会被写入字节流。反序列化后,该字段的值会是其类型的默认值(如对象为nullint为0)。常用于密码、安全令牌、缓存数据等敏感或无需持久化的字段。
  3. serialVersionUID
    (面试核心)
    :这是一个用于标识可序列化类版本的long型常量。
    • 作用:在反序列化时,JVM会比较字节流中的serialVersionUID和当前加载的类中的serialVersionUID。如果两者不一致,会抛出InvalidClassException,以防止因类版本不兼容导致的数据错乱。
    • 最佳实践强烈建议所有可序列化类都显式声明一个private static final long serialVersionUID。如果不声明,编译器会自动生成一个,但这个自动生成的值对类的结构非常敏感(如增删字段),稍有改动就会变化,导致旧的序列化数据无法被新版类反序列化。
代码实战:将User对象持久化到文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example;

import java.io.*;

// 1. User类必须实现Serializable接口
class User implements Serializable {
// 2. 强烈建议显式声明serialVersionUID
private static final long serialVersionUID = 1L;

private String name;
private int age;
// 3. 使用transient关键字,密码将不会被序列化
private transient String password;

public User(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}

@Override
public String toString() {
return "User{name='" + name + "', age=" + age + ", password='" + password + "'}";
}
}

public class Main {
public static void main(String[] args) {
File userFile = new File("user.ser");
User userToWrite = new User("Alice", 25, "mySecret123");

// --- 序列化过程 ---
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream(userFile))) {
oos.writeObject(userToWrite);
System.out.println("原始对象: " + userToWrite);
System.out.println("对象已成功序列化到文件 " + userFile.getName());
} catch (IOException e) {
e.printStackTrace();
}

System.out.println("\n--- 反序列化过程 ---");
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(userFile))) {
User userToRead = (User) ois.readObject();
System.out.println("从文件反序列化出的对象: " + userToRead);
// 注意:password字段因为是transient,所以变成了null
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}

4.4 [实战进阶] 简化I/O操作的第三方库

4.4.1 面试题引入与核心思想

面试题引入

“在实际项目中,你还会频繁地手动编写try-with-resources和循环来复制一个文件吗?有没有更便捷、更专业的处理方式?”

核心思想:为什么需要第三方库?

虽然我们必须深入理解JDK原生I/O流的体系,因为它是所有I/O操作的基础,也是排查底层问题的关键。但在真实的、快节奏的商业项目开发中,对于那些反复出现的通用I/O任务(如读写文件、复制目录等),如果每次都手动编写原始的流处理代码,会存在几个明显的问题:

  1. 代码冗长:完成一个简单的任务需要创建多个流对象、编写循环、处理异常,代码显得非常臃肿。
  2. 容易出错:手动管理资源和编写逻辑,稍有不慎就可能导致资源未关闭、缓冲区处理不当等难以察觉的BUG。
  3. 效率低下:重复编写同样功能的代码,是对开发时间的浪费。

因此,在专业开发领域,我们遵循“不重复造轮子 (Don’t Reinvent the
Wheel)
”的原则。对于I/O操作,社区已经为我们提供了极其优秀、经过数万个项目验证的“轮子”——第三方工具库

4.4.2 主流库介绍:Apache Commons IO

Apache Commons IO
是Java生态中处理I/O的事实上的行业标准。它是一个稳定、可靠、功能极其丰富的工具包,几乎是所有Java项目的必备依赖之一。它的核心价值在于,将那些繁琐的I/O样板代码封装成了简单、强大的一行代码。

  • 如何引入: 在Maven项目中,只需在pom.xml中添加以下依赖即可:
    1
    2
    3
    4
    5
    <dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.14.0</version>
    </dependency>
  • 核心工具类
    • FileUtils:面向文件(File对象)和目录的操作。
    • IOUtils:面向流(InputStream, OutputStream等)的操作。

4.4.3 代码实战:JDK原生写法 vs. Commons IO 一行代码

下面,我们将通过几个鲜明的“之前 vs. 之后”的对比,来感受Commons IO的威力。

场景一:读取整个文件到字符串
  • JDK 原生写法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    package com.example;

    import java.io.BufferedReader;
    import java.io.FileReader;
    import java.io.IOException;

    public class Main {
    public static String readFileWithJDK(String filePath) throws IOException {
    StringBuilder content = new StringBuilder();
    try (BufferedReader reader = new BufferedReader(new FileReader(filePath))) {
    String line;
    while ((line = reader.readLine()) != null) {
    content.append(line).append(System.lineSeparator());
    }
    }
    return content.toString();
    }
    // ... main方法调用 ...
    }
  • Commons IO 写法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    package com.example;

    import org.apache.commons.io.FileUtils;
    import java.io.File;
    import java.io.IOException;
    import java.nio.charset.StandardCharsets;

    public class Main {
    public static String readFileWithCommonsIO(String filePath) throws IOException {
    // 一行代码,搞定!内部已处理好所有流的打开和关闭。
    return FileUtils.readFileToString(new File(filePath), StandardCharsets.UTF_8);
    }
    // ... main方法调用 ...
    }
场景二:复制文件
  • JDK 原生写法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    package com.example;

    import java.io.*;

    public class Main {
    public static void copyFileWithJDK(String src, String dest) throws IOException {
    try (InputStream in = new BufferedInputStream(new FileInputStream(src));
    OutputStream out = new BufferedOutputStream(new FileOutputStream(dest))) {
    byte[] buffer = new byte[1024];
    int length;
    while ((length = in.read(buffer)) > 0) {
    out.write(buffer, 0, length);
    }
    }
    }
    // ... main方法调用 ...
    }
  • Commons IO 写法
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    package com.example;

    import org.apache.commons.io.FileUtils;
    import java.io.File;
    import java.io.IOException;

    public class Main {
    public static void copyFileWithCommonsIO(String src, String dest) throws IOException {
    // 同样是一行代码
    FileUtils.copyFile(new File(src), new File(dest));
    }
    // ... main方法调用 ...
    }
场景三:递归删除目录
  • JDK 原生写法
    JDK的File.delete()无法删除非空目录。要实现此功能,必须自己编写一个递归方法,先删除目录下的所有文件和子目录,最后再删除该目录本身,过程复杂且容易出错。

  • Commons IO 写法

    1
      

package com.example;

import org.apache.commons.io.FileUtils;
import java.io.File;

import java.io.IOException;

public class Main {
    public static void deleteDirWithCommonsIO(String dirPath) throws IOException {
        // 无论目录是否为空,一行代码安全删除
        FileUtils.deleteDirectory(new File(dirPath));
    }
    // ... main方法调用 ...
}
```

4.5 Apache Commons IO 核心方法速查表

FileUtils 类:面向文件和目录的操作

1. 读操作 (Read Operations)
方法名功能描述
String readFileToString(File file, Charset cs)**(最常用)**将整个文件内容读取为一个字符串。
List<String> readLines(File file, Charset cs)将文件的每一行读取到一个字符串列表 (List<String>) 中。
byte[] readFileToByteArray(File file)将整个文件内容读取为一个字节数组 (byte[])。
2. 写操作 (Write Operations)
方法名功能描述
void writeStringToFile(File file, String data, ...)**(最常用)**将一个字符串写入文件(会覆盖或追加)。
void writeLines(File file, Collection<?> lines, ...)将一个字符串集合(如List)逐行写入文件。
void writeByteArrayToFile(File file, byte[] data)将一个字节数组写入文件。
3. 复制与移动 (Copy & Move Operations)
方法名功能描述
void copyFile(File src, File dest)**(常用)**复制一个文件到新位置。
void copyDirectory(File src, File dest)**(强大)**递归复制整个目录及其所有内容。
void copyFileToDirectory(File src, File destDir)将一个文件复制到指定的目录下。
void moveFile(File src, File dest)移动一个文件(本质上是“复制后删除”)。
void moveDirectory(File src, File dest)移动整个目录。
void moveToDirectory(File src, File destDir, ...)将文件或目录移动到指定的目录下。
4. 删除与清空 (Delete & Clean Operations)
方法名功能描述
void deleteDirectory(File dir)**(强大)**递归删除整个目录,无论其是否为空。
void cleanDirectory(File dir)清空一个目录下的所有内容,但不删除目录本身。
boolean forceDelete(File file)强制删除一个文件或目录。如果删除失败,会尝试多次。
5. 状态检查与其它
方法名功能描述
long sizeOf(File file)获取一个文件的大小。
long sizeOfDirectory(File dir)**(常用)**递归计算整个目录的大小。
boolean isFileNewer(File file, ...)判断一个文件是否比另一个文件或指定时间戳更新。

IOUtils 类:面向流的操作

1. 读/转换操作 (Read / Convert Operations)
方法名功能描述
String toString(InputStream in, Charset cs)**(最常用)**将一个InputStreamReader的内容读取为一个字符串。
byte[] toByteArray(InputStream in)**(常用)**将一个InputStreamReader的内容读取为一个字节数组。
InputStream toInputStream(String input, Charset cs)将一个CharSequence(如String)转换为一个InputStream
List<String> readLines(InputStream in, Charset cs)将一个InputStreamReader的内容按行读取到一个List<String>中。
2. 写操作 (Write Operations)
方法名功能描述
void write(String data, OutputStream out, ...)将一个Stringbyte[]的内容写入到一个OutputStreamWriter中。
3. 复制操作 (Copy Operations)
方法名功能描述
int copy(InputStream in, OutputStream out)**(最常用)**将一个InputStream的内容复制到一个OutputStream中,或将Reader复制到Writer。返回复制的字节/字符数。
long copyLarge(InputStream in, OutputStream out)用于复制大于2GB的超大流。
4. 关闭操作 (Close Operations)
方法名功能描述
void closeQuietly(Closeable c)(历史著名)安静地关闭一个Closeable(如流),忽略所有异常。注意:在Java 7及之后,官方推荐使用try-with-resources语句来自动管理资源,此方法的必要性已大大降低,被视为一种过时的模式。