15.1 I/O 流与持久化边界
学过异常、集合、泛型和资源关闭后,为本篇预留约两小时,并使用 Java 17 运行两个临时目录实验。读完应能为二进制与文本选择正确 API,流式处理大输入,并明确资源由谁关闭。
进程内存不会自动持久化
对象只存在于进程内存时,程序退出后状态就会丢失。若下一次启动还要恢复它,程序必须把数据写入外部目的地,并约定可再次读取的格式。
I/O 指程序从来源读取数据,或把数据写到目的地。来源和目的地可以是文件、网络连接、进程管道,也可以是内存缓冲区;内存流本身并不提供持久化。本页用文件演示,因为文件容易重复验证。
阅读路线
- 先判断数据是字节还是文本;
- 理解一次
read可能只返回一部分数据; - 用缓冲与流式处理控制内存和调用成本;
- 用 try-with-resources 明确资源所有权;
- 区分
flush、关闭和持久化; - 认识原生 Java 序列化的兼容性与安全边界;
- 完成一个遇到
FATAL就停止的日志分析器。
1. 流是有方向的数据通道
来源 -> InputStream / Reader -> 程序
程序 -> OutputStream / Writer -> 目的地InputStream 和 OutputStream 传输字节。Reader 和 Writer 传输 UTF-16 代码单元。与字节流衔接时,InputStreamReader、OutputStreamWriter 等桥接器负责按字符集解码或编码;StringReader 这样的内存字符流不需要字节转换。
| 数据 | 入口/出口 | 常见类型 |
|---|---|---|
| 图片、压缩包、音频、协议帧 | 字节流 | InputStream、OutputStream |
| UTF-8 日志、CSV、配置文本 | 字符流 | Reader、Writer |
| 大小已知且很小的文件 | 一次性便捷方法 | Files.readString、readAllBytes |
| 大文件或大小由外部决定 | 流式循环 | BufferedReader、缓冲字节数组 |
如果不确定文件类型,先把它当字节。随意解码再编码会改变无法按该编码表示的二进制内容。
2. 字节流:复制二进制图片
try (InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)) {
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
}这里要求 source 与 target 是不同文件,包括不能通过硬链接或符号链接指向同一文件;否则打开输出就可能截断输入。
read(buffer) 返回本次实际放入数组的字节数。最后一次读取往往小于缓冲区长度,所以写入时必须使用 output.write(buffer, 0, read)。把整个数组都写出会把上一次读取残留的字节带到文件尾部。
-1 表示输入结束。一次读取没有义务填满数组;网络流尤其常返回较短片段。长度为 0 的缓冲区是一个无用特例,应避免。
对普通 OutputStream,write(buffer, offset, length) 要么写完指定范围,要么抛异常;抛异常前仍可能已经写出部分数据。FileChannel、套接字通道等 NIO channel 可能只处理一部分缓冲区,使用它们时要检查 ByteBuffer.hasRemaining()。
便捷复制
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);标准库适合常规整文件复制,也能处理流到文件或文件到流。手写循环仍值得掌握,因为校验和、限速、进度统计、协议解析都需要在数据块之间插入逻辑。
3. 字符流:UTF-8 文本报告
文本文件仍由字节组成。字符编码规定字节如何对应字符。
try (BufferedReader reader = Files.newBufferedReader(
Path.of("dispatch.txt"), StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
}readLine() 返回一行内容并移除行尾的 \n、\r 或 \r\n。如果输出要保留原始行尾,就不能只用 readLine();需要按字符或字节保留分隔符。
写文本时同样明确编码:
try (BufferedWriter writer = Files.newBufferedWriter(
Path.of("dispatch-copy.txt"), StandardCharsets.UTF_8)) {
writer.write("一名开发者借出 BK-15");
writer.newLine();
}newLine() 使用当前平台的行分隔符。协议要求固定 \n 时,应直接写协议规定的字符。
哪些 API 默认 UTF-8
以下方法未传 charset 时使用 UTF-8。readString/writeString 从 Java 11 提供,不带 charset 的 buffered 工厂从 Java 8 提供:
Files.readString、Files.writeString;Files.newBufferedReader、Files.newBufferedWriter。
Java 17 中,不带 charset 的 FileReader/FileWriter 构造器使用默认字符集。Java 11+ 已提供带 Charset 的构造器,但新代码用 Files 工厂更容易同时表达路径、编码和打开选项。
new FileReader("dispatch.txt", StandardCharsets.UTF_8); // Java 11+明确写出 UTF-8 的价值不只在兼容旧 API。它还让文件格式契约直接出现在代码里。
非法字节怎么办
new String(bytes, UTF_8) 会替换错误输入,Files.newBufferedReader(path, UTF_8) 则严格解码并报告错误。下面把严格策略写到 CharsetDecoder 上,保存为 StrictDecodeDemo.java 可单独运行:
import java.nio.ByteBuffer;
import java.nio.charset.*;
public class StrictDecodeDemo {
public static void main(String[] args) throws CharacterCodingException {
CharsetDecoder decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try {
decoder.decode(ByteBuffer.wrap(new byte[]{(byte) 0xC3, 0x28}));
throw new AssertionError("Invalid UTF-8 was accepted");
} catch (MalformedInputException expected) {
System.out.println("Invalid UTF-8 rejected");
}
}
}输入字节 C3 28 不是合法 UTF-8;程序捕获 MalformedInputException 后打印 Invalid UTF-8 rejected。
4. 完整实验:二进制复制与文本读取
程序自行创建临时目录,不依赖手工准备的 map.jpg 或 dispatch.txt。TemporaryFiles 通过 try-with-resources 尝试清理本次创建的文件,清理失败会保留为主异常或 suppressed;进程被强制终止时仍可能留下临时项。
import java.io.BufferedReader;
import java.io.BufferedWriter;
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.util.ArrayList;
import java.util.List;
public class StreamDemo {
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("stream-demo-");
Path binary = root.resolve("map.bin");
Path copy = root.resolve("map-copy.bin");
Path dispatch = root.resolve("dispatch.txt");
TemporaryFiles cleanup = new TemporaryFiles(copy, dispatch, binary, root);
try (cleanup) {
byte[] payload = {0, 1, 2, 127, (byte) 128, (byte) 255};
try (OutputStream output = Files.newOutputStream(binary)) {
output.write(payload);
}
try (InputStream input = Files.newInputStream(binary);
OutputStream output = Files.newOutputStream(copy)) {
byte[] buffer = new byte[4];
int read;
while ((read = input.read(buffer)) != -1) {
output.write(buffer, 0, read);
}
}
List<String> expectedLines = List.of("一名开发者借出 BK-15", "库存:1");
try (BufferedWriter writer = Files.newBufferedWriter(
dispatch, StandardCharsets.UTF_8)) {
for (String line : expectedLines) {
writer.write(line);
writer.newLine();
}
}
List<String> actualLines = new ArrayList<>();
try (BufferedReader reader = Files.newBufferedReader(
dispatch, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
actualLines.add(line);
}
}
boolean identical = Files.mismatch(binary, copy) == -1;
check(actualLines.equals(expectedLines), "文本内容不一致");
check(identical, "二进制副本不一致");
System.out.println("二进制字节:" + Files.size(binary));
System.out.println("文本行:" + actualLines);
System.out.println("复制一致:" + identical);
System.out.println("流检查通过");
}
}
private record TemporaryFiles(Path... paths) implements AutoCloseable {
@Override
public void close() throws IOException {
IOException failure = null;
for (Path path : paths) {
try {
Files.deleteIfExists(path);
} catch (IOException error) {
if (failure == null) failure = error;
else failure.addSuppressed(error);
}
}
if (failure != null) throw failure;
}
}
private static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}运行:
chapter15_stream_root=$(mktemp -d)
mkdir -p "$chapter15_stream_root/out"
# 将上面的代码保存为 "$chapter15_stream_root/StreamDemo.java"
javac --release 17 -Xlint:all -Werror \
-d "$chapter15_stream_root/out" \
"$chapter15_stream_root/StreamDemo.java"
java -ea -cp "$chapter15_stream_root/out" StreamDemo
rm -r -- "$chapter15_stream_root"输出:
二进制字节:6
文本行:[一名开发者借出 BK-15, 库存:1]
复制一致:true
流检查通过Files.mismatch(a, b) 返回第一个不同字节的位置,内容相同则返回 -1。它从 Java 12 起可用,本章以 Java 17 为基线。
5. 缓冲解决调用粒度,不给固定倍数
try (BufferedInputStream input = new BufferedInputStream(
Files.newInputStream(path))) {
// 读取
}缓冲器把较多数据放进内存,再满足多次小读取或写入。这样通常减少进入底层 I/O 栈的调用次数,但无法承诺“几十倍”或“减少 10,000 次系统调用”。结果受这些因素影响:
- 存储介质与文件系统缓存;
- 顺序访问还是随机访问;
- 单次业务处理成本;
- 库内部是否已经缓冲;
- 缓冲区大小和并发负载。
如果已经用 read(byte[]) 批量读取,再包一层 BufferedInputStream 未必带来可测收益。先写正确的流式代码,再用 JMH 或贴近生产的端到端测试测量。不要用一次 System.currentTimeMillis() 结果下结论;预热、缓存和后台负载都会污染数据。
6. 小文件可以整读,大文件必须有上限
String config = Files.readString(path, StandardCharsets.UTF_8);
byte[] icon = Files.readAllBytes(path);这类方法简洁,但会把全部内容放进内存。只有当文件大小由程序或可信配置限制时才使用。
对于 10 KB 到 2 GB 的日志,应逐行处理:
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
consume(line);
}
}Files.lines(path, charset) 也返回惰性 Stream<String>:
try (Stream<String> lines = Files.lines(path, StandardCharsets.UTF_8)) {
long errors = lines.filter(line -> line.startsWith("ERROR ")).count();
}终止操作完成并不会自动关闭底层文件资源。把 Files.lines、Files.list、Files.walk 和 Files.find 放进 try-with-resources。
readLine() 仍会把一整行装入内存;若没有单行长度约束,一个没有换行的巨大文件仍可能耗尽内存。真正的读取上限要在字符或字节分块阶段实施,读完后检查 line.length() 已经太晚。
“流式”只控制读取方式。若你把所有行收集到 List,内存仍随文件增长。聚合计数时只保留计数器;需要输出错误行时边读边写。
7. try-with-resources 是所有权声明
try (InputStream input = Files.newInputStream(source);
OutputStream output = Files.newOutputStream(target)) {
// 使用资源
}资源按声明的逆序关闭:先关 output,再关 input。Java 9+ 允许引用已声明且为 final 或 effectively final(赋值后不再改变)的变量:
InputStream input = Files.newInputStream(source);
try (input) {
// 使用 input
}一个组件创建资源,通常也由它关闭。方法若接收调用者提供的流,契约要说明是否取得关闭所有权。标准库常见约定是“谁打开,谁关闭”,装饰流例外:关闭最外层 BufferedWriter 会连带关闭它包装的 writer。
主异常与受抑制异常
若 try 主体和 close() 都抛异常,Java 保留主体异常,把关闭失败挂在 getSuppressed():
catch (IOException e) {
for (Throwable suppressed : e.getSuppressed()) {
System.err.println("关闭资源时又失败:" + suppressed.getMessage());
}
throw e;
}手写 finally 容易让关闭异常覆盖真正失败,也容易出现空的 catch (IOException e) {}。try-with-resources 已处理这套优先级。
8. flush()、close() 与数据落盘
BufferedWriter.flush() 把 Java 缓冲区内容交给下层目标。close() 会先刷新再关闭。两者都不等于“断电后一定存在”。操作系统、文件系统、磁盘控制器和硬件缓存仍可能保留数据。
需要更强持久化请求时,可使用:
byte[] content = "report complete\n".getBytes(StandardCharsets.UTF_8);
try (FileChannel channel = FileChannel.open(path,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING,
StandardOpenOption.WRITE)) {
java.nio.ByteBuffer buffer = java.nio.ByteBuffer.wrap(content);
while (buffer.hasRemaining()) {
channel.write(buffer);
}
channel.force(true);
}force(true) 请求把内容及相关元数据写到存储设备。这里先经同一 FileChannel 写入,再调用 force。API 对本地设备上的这类更新提供保证,对非本地设备不提供同样保证;不能据此承诺重新打开的通道能同步任意先前写入。数据库、事务日志等高要求系统还要设计写入顺序、校验和、目录同步和崩溃恢复,不能把一次 force 当完整事务。
普通文本导出通常只需可靠关闭。根据数据丢失代价选择强度。
9. 打开选项决定覆盖还是追加
Files.newOutputStream(path); // CREATE + TRUNCATE_EXISTING + WRITE
Files.newOutputStream(path,
StandardOpenOption.CREATE,
StandardOpenOption.APPEND);默认写入会创建文件或清空已有内容。追加适合某些日志,但多个进程或线程并发追加的原子边界取决于 API、提供者和写入大小。需要严格记录边界时,使用单写者、文件锁协议或专用日志系统。
常用选项:
| 选项 | 行为 |
|---|---|
CREATE | 不存在时创建,存在时继续 |
CREATE_NEW | 原子地要求原先不存在,否则抛 FileAlreadyExistsException |
TRUNCATE_EXISTING | 打开写入时把现有文件截为 0 |
APPEND | 每次写到文件末尾,组合限制依提供者而定 |
SYNC / DSYNC | 请求更强同步语义,成本和支持依文件系统而定 |
10. I/O 失败应带着操作语境
IOException 是一个家族:
| 异常 | 常见含义 |
|---|---|
NoSuchFileException | 路径不存在或中间目录缺失 |
FileAlreadyExistsException | CREATE_NEW 或创建操作遇到已有目标 |
AccessDeniedException | 权限、ACL、只读挂载或安全策略拒绝 |
FileSystemException | 文件系统报告的其他路径级失败 |
MalformedInputException | 字节不能按指定 charset 严格解码 |
在边界层翻译异常时保留 cause:
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new IllegalStateException("读取图书目录失败:" + path.getFileName(), e);
}服务端响应不要把用户目录的绝对路径直接回传给外部客户端。内部日志可记录受控路径、操作、异常类型和关联请求 id。
11. 记录对象与原生 Java 序列化
final class SessionSnapshot implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private String status;
private int count;
private transient String sessionToken;
}Serializable 是标记接口。ObjectOutputStream 会遍历可达对象图;默认序列化遍历非 static、非 transient 实例字段;其中非空对象引用也必须可序列化。自定义序列化方法可以改变默认行为。serialVersionUID 参与兼容性检查,但不能自动完成字段迁移。
transient 只表示该字段不进入默认序列化数据。它不是加密、访问控制或内存擦除。令牌若出现在别的字段、日志或备份里,仍会泄漏。
安全边界
不要对网络上传、消息队列或用户文件执行原生 Java 反序列化。构造对象图时可能触发类路径中的危险行为,攻击者会利用 gadget chain 执行非预期代码。
ObjectInputFilter 可以限制类、数组大小、深度和引用数,适合作为旧系统的纵深防御。它不把未知输入变成可信输入。长期数据和跨服务协议应选择显式、版本化格式,验证字段范围,并只构造允许的领域对象。
封闭二进制格式可以用 DataOutputStream/DataInputStream,但你必须定义:
- magic number 与版本;
- 字段顺序、整数宽度和字节序;
- 字符串字节编码与最大长度;
- 未知版本与截断输入的失败方式。
writeUTF 使用 modified UTF-8,不等同于普通 UTF-8 文本字段。跨语言协议应自行写 UTF-8 字节长度和内容,或使用成熟格式。
12. 标准输入、输出与错误
System.in // InputStream
System.out // PrintStream
System.err // PrintStream命令行程序应把正常机器可读结果写到 out,诊断写到 err。shell 可以分别重定向两条通道。
不要在库代码里随意关闭 System.in/out/err,因为进程其他组件仍可能使用它们。若用 BufferedReader 包装 System.in,通常让应用最外层统一管理生命周期。
PrintWriter 和 PrintStream 的部分写入方法会记录错误状态而不直接抛 IOException。需要可靠检测时检查 checkError(),或使用会抛异常的 BufferedWriter。
13. 终局挑战:流式日志分析器
应用的 server.log 可能只有 10 KB,也可能达到 2 GB。程序必须:
- 按 UTF-8 逐行读取;
- 统计 INFO、WARN、ERROR、DEBUG;
- 把无法识别的行写入
error.log; - 遇到 FATAL 立即停止业务处理,不统计后续行;
- 把小型汇总写入
report.txt。
下面的完整程序创建一组固定输入,并核对汇总、错误行和停止位置。命令行版本可以从参数取得路径,但必须保证输入、报告和错误日志是三个不同文件。示例在打开输出前检查路径和现有文件身份;它假设目录由程序控制,不能防止外部进程随后换链。
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
public class LogAnalyzerDemo {
private enum Level {
INFO, WARN, ERROR, DEBUG
}
private record Result(Map<Level, Long> counts, boolean fatal, long invalid) { }
public static void main(String[] args) throws IOException {
Path root = Files.createTempDirectory("log-demo-");
Path input = root.resolve("server.log");
Path report = root.resolve("report.txt");
Path errors = root.resolve("error.log");
TemporaryFiles cleanup = new TemporaryFiles(errors, report, input, root);
try (cleanup) {
Files.write(input, List.of(
"INFO server started",
"DEBUG catalog loaded",
"BROKEN",
"WARN inventory low",
"ERROR receipt failed",
"INFO retry accepted",
"FATAL database unavailable",
"INFO ignored"), StandardCharsets.UTF_8);
Result result = analyze(input, report, errors);
List<String> reportLines = Files.readAllLines(report, StandardCharsets.UTF_8);
List<String> errorLines = Files.readAllLines(errors, StandardCharsets.UTF_8);
check(result.counts().equals(Map.of(
Level.INFO, 2L,
Level.WARN, 1L,
Level.ERROR, 1L,
Level.DEBUG, 1L)), "统计错误");
check(result.fatal(), "没有在 FATAL 停止");
check(result.invalid() == 1, "无效行数量错误");
check(reportLines.equals(List.of(
"INFO=2", "WARN=1", "ERROR=1", "DEBUG=1", "FATAL=true")), "报告错误");
check(errorLines.equals(List.of("line 3: BROKEN")), "错误日志不一致");
System.out.println("统计:" + result.counts());
System.out.println("遇到 FATAL:" + result.fatal());
System.out.println("无效行:" + result.invalid());
System.out.println("报告:" + report.getFileName());
System.out.println("日志检查通过");
}
}
private static Result analyze(Path input, Path report, Path errors) throws IOException {
requireDistinct(input, report, errors);
EnumMap<Level, Long> counts = new EnumMap<>(Level.class);
for (Level level : Level.values()) {
counts.put(level, 0L);
}
boolean fatal = false;
long invalid = 0;
long lineNumber = 0;
try (BufferedReader reader = Files.newBufferedReader(input, StandardCharsets.UTF_8);
BufferedWriter errorWriter = Files.newBufferedWriter(errors, StandardCharsets.UTF_8)) {
String line;
while ((line = reader.readLine()) != null) {
lineNumber++;
if (line.startsWith("FATAL ") || line.equals("FATAL")) {
fatal = true;
break;
}
int separator = line.indexOf(' ');
String token = separator < 0 ? line : line.substring(0, separator);
try {
Level level = Level.valueOf(token);
counts.merge(level, 1L, Math::addExact);
} catch (IllegalArgumentException e) {
invalid++;
errorWriter.write("line " + lineNumber + ": " + line);
errorWriter.newLine();
}
}
}
try (BufferedWriter writer = Files.newBufferedWriter(report, StandardCharsets.UTF_8)) {
for (Level level : Level.values()) {
writer.write(level + "=" + counts.get(level));
writer.newLine();
}
writer.write("FATAL=" + fatal);
writer.newLine();
}
Map<Level, Long> snapshot = Collections.unmodifiableMap(new EnumMap<>(counts));
return new Result(snapshot, fatal, invalid);
}
private record TemporaryFiles(Path... paths) implements AutoCloseable {
@Override
public void close() throws IOException {
IOException failure = null;
for (Path path : paths) {
try {
Files.deleteIfExists(path);
} catch (IOException error) {
if (failure == null) failure = error;
else failure.addSuppressed(error);
}
}
if (failure != null) throw failure;
}
}
private static void requireDistinct(Path... paths) throws IOException {
for (int i = 0; i < paths.length; i++) {
for (int j = i + 1; j < paths.length; j++) {
boolean sameName = paths[i].toAbsolutePath().normalize()
.equals(paths[j].toAbsolutePath().normalize());
boolean sameFile = Files.exists(paths[i]) && Files.exists(paths[j])
&& Files.isSameFile(paths[i], paths[j]);
if (sameName || sameFile) {
throw new IllegalArgumentException("Input and outputs must be distinct files");
}
}
}
}
private static void check(boolean condition, String message) {
if (!condition) {
throw new AssertionError(message);
}
}
}输出:
统计:{INFO=2, WARN=1, ERROR=1, DEBUG=1}
遇到 FATAL:true
无效行:1
报告:report.txt
日志检查通过日志格式规定级别大小写敏感,使用空格分隔;单独的 INFO 也算有效,制表符不会充当分隔符。程序只把小报告读回内存用于测试,输入逐行处理,计数使用 long。BufferedReader 可能预读后续字节,因此 FATAL 保证停止统计,而非保证底层恰好读取到这一行。第八行 INFO ignored 位于 FATAL 之后,所以不会计数。
生产版本还要确定:输入文件在分析期间是否可能增长或轮转、单行最大长度、错误日志的容量、报告替换是否需要原子性,以及中断或磁盘满时如何保留旧报告。下一页处理这些文件系统层面的选择。
14. 常见故障定位
| 现象 | 先检查 |
|---|---|
| 中文出现乱码 | 写入和读取是否使用同一明确 charset |
| 文件尾部多出旧字节 | 是否写了整个 buffer,而不是 read 返回的有效范围 |
大文件导致 OutOfMemoryError | 是否使用 readAllBytes、readString 或收集全部行 |
| 程序运行一段时间后无法打开文件 | 文件流或 Files.lines 是否未关闭 |
| 写入成功但断电后数据缺失 | flush/close 不等于持久化;重新评估耐久需求 |
| 追加日志互相穿插 | 多写者是否缺少明确的记录边界和协调方式 |
| 主异常看不到 | 手写 finally 是否被关闭异常覆盖;查看 suppressed exceptions |
| 反序列化触发意外类行为 | 不要接受不可信原生对象流,改用允许列表和显式格式 |
15. 练习
- 把
StreamDemo的缓冲区改成 1、4 和 64 字节。结果必须相同;不要用一次计时比较性能。 - 为日志分析器增加单行长度上限。超过上限时记录错误并继续,明确长度按字符还是字节计算。
- 用
Files.lines重写只统计 ERROR 的版本,确认 try-with-resources 仍然存在,也不调用toList()。 - 设计一个记录的二进制格式:写出 magic number、版本、UTF-8 名称长度、名称字节、状态和计数。读取前验证 magic number、长度上限与版本。
- 人为让 try 主体和
close()同时失败,打印主异常和getSuppressed(),观察关闭顺序。
把同一份数据换三种边界再跑
先把二进制复制缓冲区依次改成 1、4 和 64 字节,副本都必须与原文件逐字节一致。再用严格 CharsetDecoder 读取非法 UTF-8 序列 C3 28,确认它抛出 MalformedInputException,而不是悄悄替换字符。
给日志分析器生成远大于内存里那组夹具的输入,但处理过程只保留四个计数器、当前行和错误输出流。若中间调用 toList(),代码虽使用了“流”,内存仍会随文件增长。FATAL 后的行不得计数,错误文件也要有容量或轮转政策。
最后让 try 主体和两个 close() 同时失败,核对主体异常仍是主异常、资源按声明逆序关闭,关闭失败进入 suppressed。做到这里,资源所有权、读取方式和失败链才真正连在一起。
查阅 Java 17 的正式契约
InputStream与Reader:部分读取、EOF 与字符边界。Files:文本编码、整读、流式行读取和文件复制。- JLS 14.20.3:try-with-resources:关闭顺序与 suppressed exception 的语言规则。
ObjectInputFilter:旧对象流的过滤边界;它不能让未知输入自动可信。
下一步:决定数据放在哪里
流 API 负责数据传输:二进制副本保留字节,文本报告按 UTF-8 读写。下一篇用文件系统 API 决定路径如何组合、目录如何遍历,以及报告怎样替换。