IO 与文件操作:数据的进进出出
难度:中级 | 预计时间:40 分钟 | 前置:异常处理
📖 日志文件太大打不开了
故事背景
运维发来一个昨天的应用日志——500MB。你需要提取其中所有 ERROR 级别的行并统计数量。Node.js 里你可能会 fs.readFileSync 一把梭——然后内存直接爆炸。Java 里有一套完整的流式 IO 体系,像水管一样让数据流过,从不断流,也不占满内存。作为前端你熟悉 fetch 的响应流和 fs.createReadStream——Java 的 IO 是更底层、更灵活、也更容易踩坑的版本。
💻 代码演示
对比三种读取文件的方式——全部加载 vs 逐行流读取 vs NIO Stream API:
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.jsfs.readFileSync(path, 'utf8')。适合小文件(配置、JSON)。BufferedReader + FileReader— 逐行读大文件。≈ Node.jsreadline模块。千万注意用 try-with-resources 包裹。Files.lines(Path)— 返回Stream<String>,可以链式 filter/map。这是最接近前端数组操作的 IO 方式。Files.writeString(Path, String)— 一次性写文本文件。简单直接。InputStream / OutputStream— 字节流(读图片、视频等二进制文件)。前端类比:Blob和ArrayBuffer。
核心原则:读文本用 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>