跳到内容

第 1 卷 / 第 15 章 / 第 1 课

15.1 I/O 流与持久化边界 ​

学过异常、集合、泛型和资源关闭后,为本篇预留约两小时,并使用 Java 17 运行两个临时目录实验。读完应能为二进制与文本选择正确 API,流式处理大输入,并明确资源由谁关闭。

进程内存不会自动持久化 ​

对象只存在于进程内存时,程序退出后状态就会丢失。若下一次启动还要恢复它,程序必须把数据写入外部目的地,并约定可再次读取的格式。

I/O 指程序从来源读取数据,或把数据写到目的地。来源和目的地可以是文件、网络连接、进程管道,也可以是内存缓冲区;内存流本身并不提供持久化。本页用文件演示,因为文件容易重复验证。

阅读路线 ​

  1. 先判断数据是字节还是文本;
  2. 理解一次 read 可能只返回一部分数据;
  3. 用缓冲与流式处理控制内存和调用成本;
  4. 用 try-with-resources 明确资源所有权;
  5. 区分 flush、关闭和持久化;
  6. 认识原生 Java 序列化的兼容性与安全边界;
  7. 完成一个遇到 FATAL 就停止的日志分析器。

1. 流是有方向的数据通道 ​

text
来源 -> 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. 字节流:复制二进制图片 ​

java
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()。

便捷复制 ​

java
Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);

标准库适合常规整文件复制,也能处理流到文件或文件到流。手写循环仍值得掌握,因为校验和、限速、进度统计、协议解析都需要在数据块之间插入逻辑。

3. 字符流:UTF-8 文本报告 ​

文本文件仍由字节组成。字符编码规定字节如何对应字符。

java
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();需要按字符或字节保留分隔符。

写文本时同样明确编码:

java
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 工厂更容易同时表达路径、编码和打开选项。

java
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 可单独运行:

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;进程被强制终止时仍可能留下临时项。

java
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);
        }
    }
}

运行:

bash
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"

输出:

text
二进制字节:6
文本行:[一名开发者借出 BK-15, 库存:1]
复制一致:true
流检查通过

Files.mismatch(a, b) 返回第一个不同字节的位置,内容相同则返回 -1。它从 Java 12 起可用,本章以 Java 17 为基线。

5. 缓冲解决调用粒度,不给固定倍数 ​

java
try (BufferedInputStream input = new BufferedInputStream(
        Files.newInputStream(path))) {
    // 读取
}

缓冲器把较多数据放进内存,再满足多次小读取或写入。这样通常减少进入底层 I/O 栈的调用次数,但无法承诺“几十倍”或“减少 10,000 次系统调用”。结果受这些因素影响:

  • 存储介质与文件系统缓存;
  • 顺序访问还是随机访问;
  • 单次业务处理成本;
  • 库内部是否已经缓冲;
  • 缓冲区大小和并发负载。

如果已经用 read(byte[]) 批量读取,再包一层 BufferedInputStream 未必带来可测收益。先写正确的流式代码,再用 JMH 或贴近生产的端到端测试测量。不要用一次 System.currentTimeMillis() 结果下结论;预热、缓存和后台负载都会污染数据。

6. 小文件可以整读,大文件必须有上限 ​

java
String config = Files.readString(path, StandardCharsets.UTF_8);
byte[] icon = Files.readAllBytes(path);

这类方法简洁,但会把全部内容放进内存。只有当文件大小由程序或可信配置限制时才使用。

对于 10 KB 到 2 GB 的日志,应逐行处理:

java
try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
    String line;
    while ((line = reader.readLine()) != null) {
        consume(line);
    }
}

Files.lines(path, charset) 也返回惰性 Stream<String>:

java
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 是所有权声明 ​

java
try (InputStream input = Files.newInputStream(source);
     OutputStream output = Files.newOutputStream(target)) {
    // 使用资源
}

资源按声明的逆序关闭:先关 output,再关 input。Java 9+ 允许引用已声明且为 final 或 effectively final(赋值后不再改变)的变量:

java
InputStream input = Files.newInputStream(source);
try (input) {
    // 使用 input
}

一个组件创建资源,通常也由它关闭。方法若接收调用者提供的流,契约要说明是否取得关闭所有权。标准库常见约定是“谁打开,谁关闭”,装饰流例外:关闭最外层 BufferedWriter 会连带关闭它包装的 writer。

主异常与受抑制异常 ​

若 try 主体和 close() 都抛异常,Java 保留主体异常,把关闭失败挂在 getSuppressed():

java
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() 会先刷新再关闭。两者都不等于“断电后一定存在”。操作系统、文件系统、磁盘控制器和硬件缓存仍可能保留数据。

需要更强持久化请求时,可使用:

java
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. 打开选项决定覆盖还是追加 ​

java
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路径不存在或中间目录缺失
FileAlreadyExistsExceptionCREATE_NEW 或创建操作遇到已有目标
AccessDeniedException权限、ACL、只读挂载或安全策略拒绝
FileSystemException文件系统报告的其他路径级失败
MalformedInputException字节不能按指定 charset 严格解码

在边界层翻译异常时保留 cause:

java
try {
    return Files.readString(path, StandardCharsets.UTF_8);
} catch (IOException e) {
    throw new IllegalStateException("读取图书目录失败:" + path.getFileName(), e);
}

服务端响应不要把用户目录的绝对路径直接回传给外部客户端。内部日志可记录受控路径、操作、异常类型和关联请求 id。

11. 记录对象与原生 Java 序列化 ​

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. 标准输入、输出与错误 ​

java
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。

下面的完整程序创建一组固定输入,并核对汇总、错误行和停止位置。命令行版本可以从参数取得路径,但必须保证输入、报告和错误日志是三个不同文件。示例在打开输出前检查路径和现有文件身份;它假设目录由程序控制,不能防止外部进程随后换链。

java
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);
        }
    }
}

输出:

text
统计:{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. 练习 ​

  1. 把 StreamDemo 的缓冲区改成 1、4 和 64 字节。结果必须相同;不要用一次计时比较性能。
  2. 为日志分析器增加单行长度上限。超过上限时记录错误并继续,明确长度按字符还是字节计算。
  3. 用 Files.lines 重写只统计 ERROR 的版本,确认 try-with-resources 仍然存在,也不调用 toList()。
  4. 设计一个记录的二进制格式:写出 magic number、版本、UTF-8 名称长度、名称字节、状态和计数。读取前验证 magic number、长度上限与版本。
  5. 人为让 try 主体和 close() 同时失败,打印主异常和 getSuppressed(),观察关闭顺序。

把同一份数据换三种边界再跑 ​

先把二进制复制缓冲区依次改成 1、4 和 64 字节,副本都必须与原文件逐字节一致。再用严格 CharsetDecoder 读取非法 UTF-8 序列 C3 28,确认它抛出 MalformedInputException,而不是悄悄替换字符。

给日志分析器生成远大于内存里那组夹具的输入,但处理过程只保留四个计数器、当前行和错误输出流。若中间调用 toList(),代码虽使用了“流”,内存仍会随文件增长。FATAL 后的行不得计数,错误文件也要有容量或轮转政策。

最后让 try 主体和两个 close() 同时失败,核对主体异常仍是主异常、资源按声明逆序关闭,关闭失败进入 suppressed。做到这里,资源所有权、读取方式和失败链才真正连在一起。

查阅 Java 17 的正式契约 ​

下一步:决定数据放在哪里 ​

流 API 负责数据传输:二进制副本保留字节,文本报告按 UTF-8 读写。下一篇用文件系统 API 决定路径如何组合、目录如何遍历,以及报告怎样替换。

继续学习文件、目录与路径

Built with VitePress | Software Systems Atlas