Skip to content

IO 与文件操作:数据的进进出出

难度:中级 | 预计时间:40 分钟 | 前置:异常处理


📖 日志文件太大打不开了

故事背景

运维发来一个昨天的应用日志——500MB。你需要提取其中所有 ERROR 级别的行并统计数量。Node.js 里你可能会 fs.readFileSync 一把梭——然后内存直接爆炸。Java 里有一套完整的流式 IO 体系,像水管一样让数据流过,从不断流,也不占满内存。作为前端你熟悉 fetch 的响应流和 fs.createReadStream——Java 的 IO 是更底层、更灵活、也更容易踩坑的版本。

💻 代码演示

对比三种读取文件的方式——全部加载 vs 逐行流读取 vs NIO Stream API:

java
import java.io.*;
import java.nio.file.*;

public class FileIODemo {
    public static void main(String[] args) throws IOException {
        // 方式1:NIO 一次性读(小文件 OK)
        String content = Files.readString(Path.of("small.txt"));
        System.out.println("小文件内容: " + content);

        // 方式2:带缓冲流逐行读(大文件推荐)
        try (BufferedReader reader = new BufferedReader(new FileReader("large.log"))) {
            String line;
            int errorCount = 0;
            while ((line = reader.readLine()) != null) {
                if (line.contains("ERROR")) {
                    System.out.println(line);
                    errorCount++;
                }
            }
            System.out.println("共 " + errorCount + " 条 ERROR");
        }

        // 方式3:NIO Files.lines() 配合 Stream API(最优雅)
        long count = Files.lines(Path.of("large.log"))
            .filter(line -> line.contains("ERROR"))
            .peek(System.out::println)
            .count();
        System.out.println("共 " + count + " 条 ERROR");

        // 写入文件
        Files.writeString(Path.of("output.txt"), "处理完成!");
    }
}

运行输出:

小文件内容: Hello, IO!
2026-05-20 10:00:01 ERROR 连接超时
2026-05-20 10:05:33 ERROR 磁盘满
共 2 条 ERROR
2026-05-20 10:00:01 ERROR 连接超时
2026-05-20 10:05:33 ERROR 磁盘满
共 2 条 ERROR

🎯 三个核心问题

这是什么?

Java IO 分两大体系:BIO(传统阻塞 IO,基于 Stream)和 NIO(New IO,基于 Channel 和 Buffer,非阻塞)。

按数据类型又分字节流(InputStream/OutputStream,处理二进制)和字符流(Reader/Writer,处理文本,自动编解码)。

Java 7 新增 NIO.2(Path/Files 工具类),把常用文件操作封装成一行代码。

为什么需要它?

为什么需要流(Stream)?因为文件可能比内存大。流不一次性加载全部数据,而是一点一点读取——就像用水管引水,不用先把整个水库搬过来。缓冲流(BufferedReader)减少磁盘 IO 次数,大幅提升性能。try-with-resources 确保流无论成功失败都能自动关闭——以前没有这个,finally 块里关闭流的代码占了半屏。

如果没有它会怎样?

如果没有流,所有文件操作只能一次性读入内存——500MB 的日志文件直接 OOM(OutOfMemoryError)。如果没有字符流(只有字节流),每次读文本都要手动处理编码——UTF-8、GBK 转换全得自己写,中文字符处理会成为噩梦。

📝 原理讲解

Java IO 速查表(前端视角):

  • Files.readString(Path) — 一次性读整个文本文件。≈ Node.js fs.readFileSync(path, 'utf8')。适合小文件(配置、JSON)。

  • BufferedReader + FileReader — 逐行读大文件。≈ Node.js readline 模块。千万注意用 try-with-resources 包裹。

  • Files.lines(Path) — 返回 Stream<String>,可以链式 filter/map。这是最接近前端数组操作的 IO 方式。

  • Files.writeString(Path, String) — 一次性写文本文件。简单直接。

  • InputStream / OutputStream — 字节流(读图片、视频等二进制文件)。前端类比:BlobArrayBuffer

核心原则读文本用 Reader,读二进制用 InputStream,大文件用流/缓冲,始终用 try-with-resources。

🎨 生活类比

类比理解

字节流(InputStream)像管道输送自来水——流出来的是最原始的水分子(byte),你不知道它是中文还是英文,得自己烧开(解码)才能喝。 字符流(Reader)像饮水机——已经帮你过滤并加热好了,出来的直接是能喝的文本(char)。 缓冲流(BufferedReader)像蓄水池——每次多取一些囤起来,避免你每次渴了都要跑去水库(磁盘)接水——跑一趟的成本远高于一次多接点。 try-with-resources 像自动关水龙头——你用完走人就行,不用记着关。以前没有这个,忘了关的水龙头可能流一个通宵(文件句柄泄漏)。

✏️ 动手练习

练习 1

写一个方法 countLines(String path),用 Files.lines() 统计文件总行数。

<details> <summary>💡 查看提示</summary>

Files.lines() 返回 Stream<String>,直接用 .count() 即可。记得用 try-catch 处理 IOException。

</details>

练习 2

BufferedReader 实现一个简单文件搜索工具:读取一个 CSV 文件,打印所有包含指定关键字的行。

<details> <summary>💡 查看提示</summary>

CSV 就是逗号分隔的文本文件,用 BufferedReader 逐行读取,用 String.contains() 匹配关键字。

</details>

练习 3

Files.walk() 遍历当前目录,列出所有 .java 文件的绝对路径和大小。

<details> <summary>💡 查看提示</summary>

Files.walk(Path.of(".")) 返回 Stream<Path>,用 .filter(p -> p.toString().endsWith(".java")) 过滤,再 forEach 打印。

</details>

✅ 自检站

<details> <summary><strong>字节流(InputStream)和字符流(Reader)有什么区别?各适合什么场景?</strong></summary>

字节流以 byte(8位)为单位,处理二进制数据——图片、视频、网络包。字符流以 char(16位 Unicode)为单位,自动处理编解码——文本文件、日志、JSON。简单判断:如果能用记事本打开看懂内容,就用字符流;否则用字节流。

</details>

<details> <summary><strong>为什么大文件读取要带缓冲(BufferedReader)?不用缓冲有什么后果?</strong></summary>

磁盘 IO 极其昂贵(一次读取是毫秒级,CPU 操作是纳秒级)。无缓冲时每次 read() 都直接访问磁盘——百万次调用就是百万次磁盘访问。缓冲流预先读入一块(默认 8KB)到内存,后续 read() 直接从内存取,磁盘访问减少约 8000 倍。类比:每次去超市买一粒米 vs 一次买一袋米。

</details>

<details> <summary><strong>try-with-resources 解决了什么问题?</strong></summary>

以前需要在 finally 块里逐个检查资源是否为 null、手动 close(),还要嵌套 try-catch 处理 close() 本身的异常——代码量 70% 都是资源管理。try-with-resources 在 try 括号里声明资源(必须实现 AutoCloseable),无论是否抛异常,退出时自动按声明逆序关闭。

</details>