Skip to content

文件与 IO 流 ​

S1-022必学进阶约 90 分钟JDK 21编程语言基础 · 常用类

1. 本节定位 ​

IO 是程序与外部世界交换数据的方式:读配置、写日志、导入导出数据、处理上传的文件。

本节按**"现代写法优先"**的顺序讲:先用 java.nio.file 的 Files / Path 把常见需求解决掉,再回头解释字节流与字符流的原理,以及为什么需要缓冲。

2. 学习目标 ​

完成本节后,你应该能够:

  • 用 Path / Files 完成读、写、追加、复制、删除、遍历目录;
  • 说清字节流与字符流的区别,并按场景选择;
  • 用 BufferedReader 逐行处理大文件(不会一次载入内存);
  • 用 try-with-resources 保证资源释放;
  • 解释乱码的成因,并知道怎么避免;
  • 说出"路径不要硬编码分隔符"的原因。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

IO 有三个"看起来简单、写起来出错"的点:

第一,资源泄漏。 忘了关闭的文件流会占用系统句柄,积累到一定程度程序崩溃——而且这类问题在测试环境很难暴露。

第二,编码乱码。 读写不加编码时,JDK 用的是平台默认编码:Windows 中文环境是 GBK,Linux 服务器通常是 UTF-8。同一份代码在两个环境表现不同。

第三,大文件。 用"一次读全部内容"的方式处理 1GB 的日志文件,结果就是 OutOfMemoryError。

本节把这三件事讲清楚,并给出标准写法。

5. 核心概念 ​

5.1 字节流 vs 字符流 ​

text
字节流(处理二进制,单位是 byte)
  InputStream  →  FileInputStream、BufferedInputStream
  OutputStream →  FileOutputStream、BufferedOutputStream

字符流(处理文本,单位是 char,内部处理编码)
  Reader →  FileReader、BufferedReader、InputStreamReader
  Writer →  FileWriter、BufferedWriter、OutputStreamWriter

怎么选:

数据类型用什么例子
文本(要按行读、按字符处理)字符流日志、CSV、配置文件
二进制(图片、视频、压缩包、序列化数据)字节流文件上传下载、图片处理

判断标准:需要"理解内容"就用字符流;只当作"一串字节"就用字节流。用字符流读图片会把数据搞坏。

本质关系

字符流 = 字节流 + 编码转换。FileReader 内部就是"读字节 → 按编码解码成字符"。

所以编码在哪一层指定,决定了字节怎么变成文字——这就是乱码的来源。

5.2 现代写法:Path 与 Files ​

java.nio.file(Java 7 引入)把常见的文件操作变成了单行调用:

java
Path path = Path.of("target", "demo.txt");        // 跨平台的路径构造

Files.writeString(path, "内容", StandardCharsets.UTF_8);      // 写文本
String content = Files.readString(path, StandardCharsets.UTF_8);   // 读文本
List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);  // 按行读
Files.writeString(path, "追加", StandardCharsets.UTF_8, StandardOpenOption.APPEND);  // 追加
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);     // 复制
Files.deleteIfExists(path);                                          // 删除
Files.exists(path);                                                  // 是否存在
Files.size(path);                                                    // 大小

优先用这些 API,它们比老的 File + 手工流式代码更短、更安全(内部处理了资源关闭与异常细节)。

路径构造用 Path.of("a", "b") 而不是 "a" + File.separator + "b"——前者自动适配 Windows 的 \ 与 Linux 的 /。

5.3 大文件:逐行流式读 ​

java
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        // 逐行处理,内存里始终只有一行
    }
}

为什么需要它:Files.readAllLines 会把整个文件读进内存变成一个 List<String>。1GB 的日志文件 → OutOfMemoryError。

场景写法内存占用
小文件(几 MB 内)readAllLines / readString文件大小
大文件BufferedReader 逐行一行
超大二进制字节流 + 固定大小缓冲区缓冲区大小

判断标准:文件大小不可控(用户上传、日志)→ 用流式;自己写的配置或测试数据 → 一次性读也无妨。

5.4 缓冲的意义 ​

java
// 无缓冲:每次 write 都可能触发一次系统调用
OutputStream out = new FileOutputStream(file);
out.write(1); out.write(2); ...

// 有缓冲:先写进内存缓冲区,满了再一次性刷盘
OutputStream out = new BufferedOutputStream(new FileOutputStream(file));

BufferedXxx 的价值在于减少系统调用次数——磁盘 IO 比内存慢几个数量级,攒够一批再写能显著提速。

注意:用缓冲流时,必须确保关闭或 flush,否则缓冲区里的数据可能没写进文件——try-with-resources 会帮你做这件事。

5.5 编码:乱码的唯一成因 ​

text
写入时用编码 A(把字符转成字节) → 存储字节
读取时用编码 B(把字节还原成字符) → 如果 A ≠ B,就乱码

规则:

  1. 每次读写都显式指定 StandardCharsets.UTF_8,不要依赖平台默认;
  2. 团队/系统统一用 UTF-8(《IDEA 与项目创建》 里配置 IDEA 编码就是这个目的);
  3. 遇到乱码先确认"写入时用什么编码、读取时用什么编码",而不是急着改代码。

5.6 常见操作速查 ​

需求写法
创建目录(含父目录)Files.createDirectories(path)
文件是否存在Files.exists(path)
读全部文本Files.readString(path, UTF_8)
读所有行Files.readAllLines(path, UTF_8)
写文本(覆盖)Files.writeString(path, text, UTF_8)
追加文本Files.writeString(path, text, UTF_8, StandardOpenOption.APPEND)
复制/移动Files.copy / Files.move
删除Files.deleteIfExists(path)
遍历目录try (Stream<Path> s = Files.list(dir)) { ... }
递归遍历Files.walk(dir)(用完要关闭)

Files.list / Files.walk 返回的 Stream 必须关闭

它们持有目录句柄。不关闭会导致"文件被占用、无法删除"这类问题。用 try-with-resources 包起来。

6. 递进示例 ​

6.1 最小示例:写与读 ​

java
Path path = Path.of("target", "demo.txt");
Files.writeString(path, "第一行\n第二行\n", StandardCharsets.UTF_8);

List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
System.out.println(lines.size());        // 2

6.2 业务示例:逐行处理日志 ​

java
try (BufferedReader reader = Files.newBufferedReader(logPath, StandardCharsets.UTF_8)) {
    String line;
    int errorCount = 0;
    while ((line = reader.readLine()) != null) {
        if (line.contains("ERROR")) {
            errorCount++;
        }
    }
    System.out.println("错误行数: " + errorCount);
}

无论日志文件多大,内存里始终只有一行。

6.3 反例:三个真实事故 ​

java
// 事故一:忘记关闭(资源泄漏)
BufferedReader reader = Files.newBufferedReader(path, UTF_8);
String line = reader.readLine();          // 之后没有 close
// 文件句柄一直被占用,直到 GC 才可能释放

// 事故二:不指定编码(跨平台乱码)
Files.writeString(path, "中文内容");        // 用平台默认编码写
// Windows 上写出 GBK,Linux 上读出乱码

// 事故三:一次性读大文件(内存溢出)
List<String> all = Files.readAllLines(hugeLogFile);    // 1GB 文件 → OutOfMemoryError

7. 完整可运行代码 ​

文件:labs/common-classes/src/main/java/cn/kb/learn/common/IoLesson.java

java
package cn.kb.learn.common;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.nio.file.StandardOpenOption;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class IoLesson {
    public static void main(String[] args) throws IOException {
        Path dir = Path.of("target", "io-demo");
        Files.createDirectories(dir);

        // 1. 写文本文件
        Path textFile = dir.resolve("demo.txt");
        Files.writeString(textFile, "第一行\n第二行\n第三行\n", StandardCharsets.UTF_8);
        System.out.println("写入文件: " + textFile);

        // 2. 一次性读全部内容
        String content = Files.readString(textFile, StandardCharsets.UTF_8);
        System.out.println("内容字符数: " + content.length());

        // 3. 按行读(适合小文件)
        List<String> lines = Files.readAllLines(textFile, StandardCharsets.UTF_8);
        System.out.println("行数: " + lines.size());
        for (String line : lines) {
            System.out.println("  行内容: " + line);
        }

        // 4. 逐行流式读(适合大文件,不会一次性载入内存)
        int lineCount = 0;
        try (BufferedReader reader = Files.newBufferedReader(textFile, StandardCharsets.UTF_8)) {
            while (reader.readLine() != null) {
                lineCount++;
            }
        }
        System.out.println("BufferedReader 读取行数: " + lineCount);

        // 5. 追加内容
        Files.writeString(textFile, "第四行\n", StandardCharsets.UTF_8, StandardOpenOption.APPEND);
        System.out.println("追加后行数: " + Files.readAllLines(textFile, StandardCharsets.UTF_8).size());

        // 6. 复制文件与目录操作
        Path copied = dir.resolve("copy.txt");
        Files.copy(textFile, copied, StandardCopyOption.REPLACE_EXISTING);
        try (Stream<Path> entries = Files.list(dir)) {
            System.out.println("目录内容: " + entries.map(path -> path.getFileName().toString()).sorted().toList());
        }

        // 7. 文件信息
        System.out.println("文件大小: " + Files.size(textFile) + " 字节");
        System.out.println("文件是否存在: " + Files.exists(textFile));

        // 8. 字节流:读写二进制数据
        Path binary = dir.resolve("data.bin");
        try (OutputStream output = new FileOutputStream(binary.toFile())) {
            output.write(new byte[]{1, 2, 3, 4, 5});
        }
        try (InputStream input = new FileInputStream(binary.toFile())) {
            System.out.println("二进制内容: " + Arrays.toString(input.readAllBytes()));
        }

        // 9. 清理
        Files.deleteIfExists(copied);
        Files.deleteIfExists(binary);
        Files.deleteIfExists(textFile);
        try (Stream<Path> entries = Files.list(dir)) {
            System.out.println("清理后目录内容: " + entries.map(path -> path.getFileName().toString()).toList());
        }
        System.out.println("(io-demo 目录保留在 target 下,可安全删除)");
    }
}

运行命令:

powershell
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.IoLesson

预期输出:

text
写入文件: target\io-demo\demo.txt
内容字符数: 12
行数: 3
  行内容: 第一行
  行内容: 第二行
  行内容: 第三行
BufferedReader 读取行数: 3
追加后行数: 4
目录内容: [copy.txt, demo.txt]
文件大小: 40 字节
文件是否存在: true
二进制内容: [1, 2, 3, 4, 5]
清理后目录内容: []
(io-demo 目录保留在 target 下,可安全删除)

几处值得注意:

输出说明
内容字符数: 12三行中文(每行 3 个字符 + 1 个换行)× 3 = 12
文件大小: 40 字节UTF-8 下每个中文 3 字节,加上换行符——字符数 ≠ 字节数
目录内容: [copy.txt, demo.txt]Files.list 用 sorted() 保证输出顺序可复现
二进制内容: [1, 2, 3, 4, 5]字节流原样读写,不经过编码转换

12 个字符 vs 40 个字节 这个对比很直观地说明了"编码"在做什么:把字符转成字节时,长度变了。

8. Debug 观察点 ​

  1. 在 Files.writeString(...) 处断点,执行后到文件系统里打开 target/io-demo/demo.txt,用十六进制查看器看字节内容——能看到中文被编码成 3 字节序列;
  2. 在 try (BufferedReader reader = ...) 的结束处断点,观察 reader 对象的 closed 状态变为 true——try-with-resources 自动关闭的实证;
  3. 在 input.readAllBytes() 处断点,观察返回的 byte[] 内容与写入的完全一致;
  4. 把 StandardCharsets.UTF_8 改成 StandardCharsets.ISO_8859_1 再跑一次,观察中文变成 ??? 或乱码——编码不一致的现场。

9. 常见错误 ​

错误 1:忘记关闭资源 ​

java
BufferedReader reader = Files.newBufferedReader(path, UTF_8);
String line = reader.readLine();
// 没有 close

症状:文件被占用、无法删除、句柄耗尽。

修复:一律用 try-with-resources。

错误 2:不指定编码 ​

java
Files.writeString(path, "中文");           // 用平台默认编码

修复:始终传 StandardCharsets.UTF_8。可以把编码定义成常量:

java
private static final Charset UTF8 = StandardCharsets.UTF_8;

错误 3:用 readAllLines 读大文件 ​

java
List<String> lines = Files.readAllLines(path);     // 1GB 文件 → OOM

修复:改用 BufferedReader 逐行处理。

错误 4:硬编码路径分隔符 ​

java
Path path = Path.of("data\\file.txt");      // 在 Linux 上会失败

修复:用 Path.of("data", "file.txt"),或统一用 /(Java 在 Windows 上也能识别 /)。

错误 5:忘了 Files.list / Files.walk 需要关闭 ​

java
Files.list(dir).forEach(System.out::println);      // 目录句柄泄漏

修复:try (Stream<Path> entries = Files.list(dir)) { ... }。

错误 6:用字符流处理二进制 ​

java
try (Reader reader = new FileReader("photo.jpg")) { }    // 图片会被破坏

修复:二进制一律用字节流。

10. 动手实验 ​

目标:写一个日志分析小工具,把本节知识串起来。

步骤:

  1. 运行第 7 节代码,核对输出(重点看"字符数 12"与"文件大小 40 字节");
  2. 生成一个测试日志文件:写入 10000 行,其中每 100 行有一条包含 ERROR;
  3. 写方法 long countErrors(Path logPath):用 BufferedReader 逐行统计 ERROR 行数(不许用 readAllLines);
  4. 写方法 void extractErrors(Path src, Path dest):把 ERROR 行抽取到新文件(用缓冲写入);
  5. 对比第 3 步的两种实现(readAllLines vs BufferedReader)在 10 万行文件上的内存占用(可以用 Runtime.getRuntime().totalMemory() - freeMemory() 粗略观察)。

验收方式:能解释第 5 步的内存差异来源。

扩展任务:写一个 copyFile(Path src, Path dest) 方法,用固定大小的缓冲区(如 8KB)循环读写,说明为什么这种方式适合复制大文件(提示:避免一次性载入)。

11. 练习题 ​

基础题

  1. 判断:读文本文件可以用字节流。(对 / 错)
  2. 填空:读文本优先用 ______ 与 ______ 方法;读大文件要逐行处理,用 ______。
  3. 简答:为什么读写文件时应该显式指定编码?

代码阅读题

java
Path path = Path.of("target", "count.txt");
Files.writeString(path, "abc中文", StandardCharsets.UTF_8);
System.out.println(Files.readString(path, StandardCharsets.UTF_8).length());
System.out.println(Files.size(path));
  1. 两行输出分别是什么?(提示:abc 是 3 个字节,中文 每个 3 字节)

编程题

  1. 写一个 LogWriter 类:
    • 方法 void append(Path path, String level, String message):向日志文件追加一行 [时间] 级别 - 消息;
    • 要求用 Files.writeString + StandardOpenOption.APPEND;
    • 方法 List<String> readLast(Path path, int n):返回最后 n 行(提示:可以全部读入后取末尾,也可以用 Files.lines 结合流操作;对于大文件应该考虑更高效的方案)。

综合任务

  1. 实现一个"日志按级别拆分"工具:
    • 输入:一个日志文件(每行格式 [时间] 级别 - 消息);
    • 输出:按级别拆分为 INFO.log、WARN.log、ERROR.log 三个文件;
    • 要求:只遍历一次源文件(不能读三遍);用 BufferedWriter 写入;
    • 统计:输出每个级别的行数;
    • 说明:为什么"只遍历一次"更好?
分级提示

提示 1(方向):第 4 题的关键是"字符数"与"字节数"不是一回事。

提示 2(关键点):第 6 题在一次遍历里,根据每行的级别选择对应的 BufferedWriter 写出——三个 writer 都要用 try-with-resources 管理。

提示 3(接近答案):第 6 题的 BufferedWriter 可以放在一个 Map<String, BufferedWriter> 里,按级别取用。

12. 参考答案 ​

基础题
  1. 对。技术上可以(字节流能读任何文件),但需要手动解码才能得到正确的字符。处理文本时应该用字符流(或 Files 的字符 API),它们自动处理编码。
  2. Files.readString、Files.readAllLines;BufferedReader.readLine()。
  3. 因为不指定时会用平台默认编码:Windows 中文环境是 GBK,Linux 服务器通常是 UTF-8。同一份代码在不同环境读写同一文件就会乱码。显式指定 UTF-8 才能保证跨平台一致。
代码阅读题(第 4 题)
text
5          ← 字符数:a、b、c、中、文 = 5 个字符
9          ← 字节数:3(abc)+ 3 + 3(中文各 3 字节)= 9 字节

这解释了"字符数 ≠ 字节数",也解释了为什么用错编码时文件名、中文内容会出错——它们都是字节序列,按不同规则解码结果就不同。

编程题(第 5 题)
java
package cn.kb.learn.common;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;

public class LogWriter {
    private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public void append(Path path, String level, String message) throws IOException {
        String line = String.format("[%s] %s - %s%n", LocalDateTime.now().format(FORMATTER), level, message);
        Files.writeString(path, line, StandardCharsets.UTF_8,
                StandardOpenOption.CREATE, StandardOpenOption.APPEND);
    }

    /** 读取最后 n 行:小文件直接全读,大文件应改为从末尾反向读取 */
    public List<String> readLast(Path path, int n) throws IOException {
        List<String> lines = Files.readAllLines(path, StandardCharsets.UTF_8);
        int from = Math.max(0, lines.size() - n);
        return lines.subList(from, lines.size());
    }

    public static void main(String[] args) throws IOException {
        Path path = Path.of("target", "io-demo", "app.log");
        Files.createDirectories(path.getParent());
        Files.deleteIfExists(path);

        LogWriter writer = new LogWriter();
        writer.append(path, "INFO", "应用启动");
        writer.append(path, "WARN", "配置缺失,使用默认值");
        writer.append(path, "ERROR", "数据库连接失败");

        System.out.println("最后 2 行:");
        writer.readLast(path, 2).forEach(line -> System.out.println("  " + line));
    }
}

注意 StandardOpenOption:写入时需要 CREATE(文件不存在就创建)与 APPEND(追加而不是覆盖)。

关于 readLast 的局限:它把整个文件读入内存,大文件场景不合适。更高效的做法是从文件末尾反向读取固定字节数——但那需要 RandomAccessFile,实现复杂得多。先选"够用且清晰"的方案,等真有性能问题再优化。

综合任务(第 6 题)
java
package cn.kb.learn.common;

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.Map;

public class LogSplitter {
    private static final String[] LEVELS = {"INFO", "WARN", "ERROR"};

    static Map<String, Integer> split(Path source, Path outputDir) throws IOException {
        Files.createDirectories(outputDir);
        Map<String, Integer> counters = new LinkedHashMap<>();
        Map<String, BufferedWriter> writers = new LinkedHashMap<>();

        try {
            // 为每个级别准备 writer
            for (String level : LEVELS) {
                writers.put(level, Files.newBufferedWriter(
                        outputDir.resolve(level + ".log"), StandardCharsets.UTF_8));
                counters.put(level, 0);
            }

            // 只遍历源文件一次
            try (Reader reader = Files.newBufferedReader(source, StandardCharsets.UTF_8);
                 java.io.BufferedReader buffered = new java.io.BufferedReader(reader)) {
                String line;
                while ((line = buffered.readLine()) != null) {
                    for (String level : LEVELS) {
                        if (line.contains("] " + level + " -")) {
                            writers.get(level).write(line);
                            writers.get(level).newLine();
                            counters.merge(level, 1, Integer::sum);
                            break;
                        }
                    }
                }
            }
        } finally {
            for (BufferedWriter writer : writers.values()) {
                writer.close();
            }
        }
        return counters;
    }

    public static void main(String[] args) throws IOException {
        Path dir = Path.of("target", "io-demo", "split");
        Files.createDirectories(dir);
        Path source = dir.resolve("source.log");
        Files.writeString(source, """
                [2026-09-21 09:00:00] INFO - 应用启动
                [2026-09-21 09:01:00] WARN - 配置缺失
                [2026-09-21 09:02:00] ERROR - 连接失败
                [2026-09-21 09:03:00] INFO - 重试成功
                """, StandardCharsets.UTF_8);

        System.out.println("统计结果: " + split(source, dir));
    }
}

为什么"只遍历一次"更好:

  1. IO 次数少:读三遍意味着三次完整的磁盘扫描,大文件时是几倍的耗时;
  2. 一致性问题:三遍之间文件可能被修改(日志还在写),导致统计口径不一致;
  3. 代码更清晰:一次遍历 + 分发,逻辑集中在循环体里。

这个例子也说明 try-with-resources 的局限:当资源数量动态(这里是按级别创建)时,无法写成 try (a; b; c),需要用 try-finally 手动关闭——关键是不能漏。

13. 掌握标准 ​

  • [ ] 能用 Path / Files 完成常见文件操作;
  • [ ] 能按数据类型选择字节流或字符流;
  • [ ] 会用 BufferedReader 逐行处理大文件;
  • [ ] 读写时始终显式指定编码;
  • [ ] 用 try-with-resources 管理资源,并知道 Files.list 返回的 Stream 也要关闭;
  • [ ] 能解释"字符数 ≠ 字节数"与乱码的成因。

14. 扩展资源 ​

15. 下一步学习 ​

S1 的集合、常用类、异常与 IO 都完成了。后续按顺序推进 《反射基础》 与 《注解基础》——它们是理解 Spring 等框架的两把钥匙。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引