主题
界面
主题
IO 是程序与外部世界交换数据的方式:读配置、写日志、导入导出数据、处理上传的文件。
本节按**"现代写法优先"**的顺序讲:先用 java.nio.file 的 Files / Path 把常见需求解决掉,再回头解释字节流与字符流的原理,以及为什么需要缓冲。
完成本节后,你应该能够:
Path / Files 完成读、写、追加、复制、删除、遍历目录;BufferedReader 逐行处理大文件(不会一次载入内存);try-with-resources 保证资源释放;try-with-resources 吗?(《异常体系与处理》)List 与 String 的常用操作吗?(《List、Set、Map》)IO 有三个"看起来简单、写起来出错"的点:
第一,资源泄漏。 忘了关闭的文件流会占用系统句柄,积累到一定程度程序崩溃——而且这类问题在测试环境很难暴露。
第二,编码乱码。 读写不加编码时,JDK 用的是平台默认编码:Windows 中文环境是 GBK,Linux 服务器通常是 UTF-8。同一份代码在两个环境表现不同。
第三,大文件。 用"一次读全部内容"的方式处理 1GB 的日志文件,结果就是 OutOfMemoryError。
本节把这三件事讲清楚,并给出标准写法。
字节流(处理二进制,单位是 byte)
InputStream → FileInputStream、BufferedInputStream
OutputStream → FileOutputStream、BufferedOutputStream
字符流(处理文本,单位是 char,内部处理编码)
Reader → FileReader、BufferedReader、InputStreamReader
Writer → FileWriter、BufferedWriter、OutputStreamWriter怎么选:
| 数据类型 | 用什么 | 例子 |
|---|---|---|
| 文本(要按行读、按字符处理) | 字符流 | 日志、CSV、配置文件 |
| 二进制(图片、视频、压缩包、序列化数据) | 字节流 | 文件上传下载、图片处理 |
判断标准:需要"理解内容"就用字符流;只当作"一串字节"就用字节流。用字符流读图片会把数据搞坏。
本质关系
字符流 = 字节流 + 编码转换。FileReader 内部就是"读字节 → 按编码解码成字符"。
所以编码在哪一层指定,决定了字节怎么变成文字——这就是乱码的来源。
java.nio.file(Java 7 引入)把常见的文件操作变成了单行调用:
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 的 /。
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 逐行 | 一行 |
| 超大二进制 | 字节流 + 固定大小缓冲区 | 缓冲区大小 |
判断标准:文件大小不可控(用户上传、日志)→ 用流式;自己写的配置或测试数据 → 一次性读也无妨。
// 无缓冲:每次 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 会帮你做这件事。
写入时用编码 A(把字符转成字节) → 存储字节
读取时用编码 B(把字节还原成字符) → 如果 A ≠ B,就乱码规则:
StandardCharsets.UTF_8,不要依赖平台默认;| 需求 | 写法 |
|---|---|
| 创建目录(含父目录) | 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 包起来。
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()); // 2try (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);
}无论日志文件多大,内存里始终只有一行。
// 事故一:忘记关闭(资源泄漏)
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文件:labs/common-classes/src/main/java/cn/kb/learn/common/IoLesson.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 下,可安全删除)");
}
}运行命令:
cd labs
./mvnw -pl common-classes compile
java -cp common-classes/target/classes cn.kb.learn.common.IoLesson预期输出:
写入文件: 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 个字节 这个对比很直观地说明了"编码"在做什么:把字符转成字节时,长度变了。
Files.writeString(...) 处断点,执行后到文件系统里打开 target/io-demo/demo.txt,用十六进制查看器看字节内容——能看到中文被编码成 3 字节序列;try (BufferedReader reader = ...) 的结束处断点,观察 reader 对象的 closed 状态变为 true——try-with-resources 自动关闭的实证;input.readAllBytes() 处断点,观察返回的 byte[] 内容与写入的完全一致;StandardCharsets.UTF_8 改成 StandardCharsets.ISO_8859_1 再跑一次,观察中文变成 ??? 或乱码——编码不一致的现场。BufferedReader reader = Files.newBufferedReader(path, UTF_8);
String line = reader.readLine();
// 没有 close症状:文件被占用、无法删除、句柄耗尽。
修复:一律用 try-with-resources。
Files.writeString(path, "中文"); // 用平台默认编码修复:始终传 StandardCharsets.UTF_8。可以把编码定义成常量:
private static final Charset UTF8 = StandardCharsets.UTF_8;readAllLines 读大文件 List<String> lines = Files.readAllLines(path); // 1GB 文件 → OOM修复:改用 BufferedReader 逐行处理。
Path path = Path.of("data\\file.txt"); // 在 Linux 上会失败修复:用 Path.of("data", "file.txt"),或统一用 /(Java 在 Windows 上也能识别 /)。
Files.list / Files.walk 需要关闭 Files.list(dir).forEach(System.out::println); // 目录句柄泄漏修复:try (Stream<Path> entries = Files.list(dir)) { ... }。
try (Reader reader = new FileReader("photo.jpg")) { } // 图片会被破坏修复:二进制一律用字节流。
目标:写一个日志分析小工具,把本节知识串起来。
步骤:
ERROR;long countErrors(Path logPath):用 BufferedReader 逐行统计 ERROR 行数(不许用 readAllLines);void extractErrors(Path src, Path dest):把 ERROR 行抽取到新文件(用缓冲写入);readAllLines vs BufferedReader)在 10 万行文件上的内存占用(可以用 Runtime.getRuntime().totalMemory() - freeMemory() 粗略观察)。验收方式:能解释第 5 步的内存差异来源。
扩展任务:写一个 copyFile(Path src, Path dest) 方法,用固定大小的缓冲区(如 8KB)循环读写,说明为什么这种方式适合复制大文件(提示:避免一次性载入)。
基础题
代码阅读题
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));abc 是 3 个字节,中文 每个 3 字节)编程题
LogWriter 类: void append(Path path, String level, String message):向日志文件追加一行 [时间] 级别 - 消息;Files.writeString + StandardOpenOption.APPEND;List<String> readLast(Path path, int n):返回最后 n 行(提示:可以全部读入后取末尾,也可以用 Files.lines 结合流操作;对于大文件应该考虑更高效的方案)。综合任务
[时间] 级别 - 消息);INFO.log、WARN.log、ERROR.log 三个文件;BufferedWriter 写入;提示 1(方向):第 4 题的关键是"字符数"与"字节数"不是一回事。
提示 2(关键点):第 6 题在一次遍历里,根据每行的级别选择对应的 BufferedWriter 写出——三个 writer 都要用 try-with-resources 管理。
提示 3(接近答案):第 6 题的 BufferedWriter 可以放在一个 Map<String, BufferedWriter> 里,按级别取用。
Files 的字符 API),它们自动处理编码。Files.readString、Files.readAllLines;BufferedReader.readLine()。5 ← 字符数:a、b、c、中、文 = 5 个字符
9 ← 字节数:3(abc)+ 3 + 3(中文各 3 字节)= 9 字节这解释了"字符数 ≠ 字节数",也解释了为什么用错编码时文件名、中文内容会出错——它们都是字节序列,按不同规则解码结果就不同。
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,实现复杂得多。先选"够用且清晰"的方案,等真有性能问题再优化。
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));
}
}为什么"只遍历一次"更好:
这个例子也说明 try-with-resources 的局限:当资源数量动态(这里是按级别创建)时,无法写成 try (a; b; c),需要用 try-finally 手动关闭——关键是不能漏。
Path / Files 完成常见文件操作;BufferedReader 逐行处理大文件;try-with-resources 管理资源,并知道 Files.list 返回的 Stream 也要关闭;S1 的集合、常用类、异常与 IO 都完成了。后续按顺序推进 《反射基础》 与 《注解基础》——它们是理解 Spring 等框架的两把钥匙。