开始前,会这些就够了
- 能读懂 Java 类、继承、try
/finally和 IOException。 - 已安装完整 JDK 17 或更新版本,能运行 java --version。
- 知道 write 是写入、flush 是把缓冲内容向下一层推进,无需准备真实故障磁盘。
一个导出任务走到了最后一行,没有进入异常分支,于是页面提示“导出成功”。但如果中间使用 PrintWriter,只看代码有没有抛出 IOException,可能漏掉已经被记录在对象内部的输出错误。成功条件需要符合所选 API 的错误报告方式。
本篇让同一个内存 Writer 在指定位置抛出普通 IOException,分别直接调用它和交给真实 PrintWriter。七组对照会生成小型 CSV 与 JSON,把“方法返回了”“接收了字符”“发生了刷新”“错误标记是什么”分开记录。
把例子带到自己的电脑上
下载完整练习包
需要 JDK 17 或更新版本,无第三方依赖;制作时实测 openjdk 17.0.20.1 2026-08-18 LTS。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- PrintWriterBoundary.java:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
java --version
java --source 17 PrintWriterBoundary.java解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 PrintWriter 与可控内存 Writer 故障;矩阵在关闭前采样,未单独验收关闭阶段,不代表实际磁盘、网络或断电持久性。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先确定错误从哪里交给调用方
Writer 的写入与刷新接口允许通过 IOException 报告 I/O 失败。PrintWriter 则提供另一种约定:其输出方法不向调用方抛出这类异常,调用方可以用 checkError 查询错误状态。包装之后,不能直接沿用只等待异常的成功判定。
这不意味着所有 Writer 都会吞掉异常,也不意味着 PrintWriter 永远不会报任何错误。部分构造器可能在打开文件时抛出异常;错误参数和格式等问题还可能通过其他异常表现出来。本实验只研究对象创建之后的普通 I/O 故障。
负责输出的一层需要同时负责结果判断。如果工具方法内部创建 PrintWriter,外层又只收到一个“已执行”状态,两层之间就可能无人检查错误标记。先画出创建、写入、检查和关闭由谁负责,再决定成功状态应该从哪里返回。
用同一个故障点,对比直接写与包装后写
ProbeWriter 是本篇自己编写的测试替身。正常时,它把字符加入 StringBuilder;开启 failWrite 后,它在接收任何字符之前抛出 IOException。这样不用拔设备、填满磁盘或访问系统目录,也能精确知道失败发生在哪一步。
直接调用 ProbeWriter.write,得到 IOException,接收字符数为零。把同一类故障放到 PrintWriter 下面,write 却正常返回;随后 checkError 为 true,接收字符数仍为零。两个分支的输出目标都失败了,区别在于错误怎样被带回来。
这是可控的内存故障注入,外层 PrintWriter 使用的是实际 JDK 类。它验证包装边界和调用方的判断方式,没有证明某块真实磁盘、网络连接或权限故障已经复现。脚本也没有覆盖 InterruptedIOException 的特殊处理分支。
测试替身没有额外缓冲,接收字符数就是它已加入字符串的长度。真实项目若再包一层 BufferedWriter,错误出现时间还可能后移。增加缓冲不一定改变最终失败的性质,却会改变哪一个调用首次碰到故障,因此应把完整包装链纳入项目验证。
checkError 会推进刷新,不能当作纯读取
另一组先接受 hello 的五个字符,只在 flush 时抛出异常。调用 checkError 之前,刷新次数为零;调用之后变成一,错误标记为 true。这个错误是在检查所触发的刷新中发现的,不能简单写成“之前的 write 肯定没有成功返回”。
因此记录里同时保留 flushes_before_check 与 flushes_after_check。假如只看最终刷新次数,就可能把检查本身做的工作错算到 write 或自动刷新上。这种观察动作会改变对象状态,设计实验时应先记录观察之前的值。
健康对照同样接受五个字符,checkError 也执行一次刷新,但返回 false。它帮助我们确认 true 来自指定故障,而不是每次查询都会报错。false 表示检查时没有报告该类错误,并不替你确认内容符合业务要求。
字符串里有换行,不代表这个调用会自动刷新
把 autoFlush 设为 true,再分别调用 write("hello\n")、println("hello") 和 printf("%s", "hello")。在 checkError 之前,三组的刷新次数分别是零、一、一。触发条件与调用的方法有关,不能只盯着输出文字里有没有换行。
特别看第三组:printf 的格式里没有换行,仍发生自动刷新。第一组虽然明确写入换行字符,却没有触发同样的动作。PrintWriter 文档把 println、printf 与 format 列为自动刷新的触发方法,本实验实际对照前两种和普通 write。
println 使用当前平台的行分隔符。制作环境是 Windows,因此它接收了七个字符;write 中显式的单个换行只增加一个字符。跨平台验收应核对触发次数和错误状态,不应把这两个字符数硬写成所有系统的共同结果。
运行七组对照,先核对小表再阅读实现
把下方完整代码保存为 PrintWriterBoundary.java,或从练习包解压同名文件,在所在目录执行给出的命令。源码启动方式不需要先手工运行 javac,但需要包含编译能力的完整 JDK。先用 java --version 确认你实际调用的版本。
程序在当前工作目录的 printwriter-output 中写出 report.json 与 matrix.csv,重复运行会更新同名教学结果。报告文件由 Files.writeString 写出,让报告自身的写入错误继续向外传播,避免用未经检查的 PrintWriter 证明 PrintWriter 没有出错。
制作时显式调用 Microsoft OpenJDK 17.0.20.1,七组断言通过并打印 PASS。JSON 记录本次 java.version;你在其他环境运行时应保留自己的版本和结果。输出目录也可作为唯一命令行参数指定。
直接调用 Writer 的那一行没有 checkError 通道,所以报告填不适用,而不是 false。零个已接收字符、一次刷新和一个错误标记也不能互相替代;读表时应沿同一行检查全部字段,避免从某一个看似正常的值推出整次操作成功。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.IOException;
import java.io.PrintWriter;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
public class PrintWriterBoundary {
// Deliberate in-memory fault injection, not a real disk failure.
static final class ProbeWriter extends Writer {
final boolean failWrite;
final boolean failFlush;
final StringBuilder accepted = new StringBuilder();
int flushCalls;
boolean closed;
ProbeWriter(boolean failWrite, boolean failFlush) {
this.failWrite = failWrite;
this.failFlush = failFlush;
}
@Override public void write(char[] chars, int offset, int length) throws IOException {
if (closed) throw new IOException("closed");
if (failWrite) throw new IOException("injected write failure");
accepted.append(chars, offset, length);
}
@Override public void flush() throws IOException {
if (closed) throw new IOException("closed");
flushCalls++;
if (failFlush) throw new IOException("injected flush failure");
}
@Override public void close() throws IOException {
if (!closed) {
try { flush(); } finally { closed = true; }
}
}
}
record Row(String name, String writeOutcome, Boolean checkError,
int acceptedChars, int flushesBeforeCheck, int flushesAfterCheck) {
String json() {
return String.format(Locale.ROOT,
"{\"case\":\"%s\",\"write_outcome\":\"%s\",\"check_error\":%s,"
+ "\"accepted_chars\":%d,\"flushes_before_check\":%d,\"flushes_after_check\":%d}",
name, writeOutcome, checkError, acceptedChars,
flushesBeforeCheck, flushesAfterCheck);
}
String csv() {
return String.join(",", name, writeOutcome,
checkError == null ? "not_applicable" : checkError.toString(),
Integer.toString(acceptedChars), Integer.toString(flushesBeforeCheck),
Integer.toString(flushesAfterCheck));
}
}
static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static Row directFailure() throws IOException {
ProbeWriter sink = new ProbeWriter(true, false);
String outcome = "returned";
try {
try { sink.write("hello"); }
catch (IOException expected) { outcome = expected.getClass().getSimpleName(); }
require(outcome.equals("IOException"), "direct Writer must report the injected failure");
return new Row("direct_write_failure", outcome, null, sink.accepted.length(),
sink.flushCalls, sink.flushCalls);
} finally { sink.close(); }
}
static Row wrapped(String name, boolean failWrite, boolean failFlush,
boolean autoFlush, String operation, int expectedBefore,
boolean expectedError) {
ProbeWriter sink = new ProbeWriter(failWrite, failFlush);
PrintWriter writer = new PrintWriter(sink, autoFlush);
try {
// None of these PrintWriter calls declares IOException.
switch (operation) {
case "write" -> writer.write("hello");
case "newline" -> writer.write("hello\n");
case "println" -> writer.println("hello");
case "printf" -> writer.printf("%s", "hello");
default -> throw new IllegalArgumentException(operation);
}
int before = sink.flushCalls;
boolean error = writer.checkError(); // This call also flushes an open writer.
int after = sink.flushCalls;
require(before == expectedBefore, name + ": unexpected automatic flush");
require(after == before + 1, name + ": checkError must perform one flush here");
require(error == expectedError, name + ": unexpected error flag");
require(failWrite ? sink.accepted.length() == 0 : sink.accepted.length() > 0,
name + ": unexpected accepted characters");
return new Row(name, "returned", error, sink.accepted.length(), before, after);
} finally { writer.close(); }
}
public static void main(String[] args) throws Exception {
Path output = args.length == 0 ? Path.of("printwriter-output") : Path.of(args[0]);
List<Row> rows = new ArrayList<>();
rows.add(directFailure());
rows.add(wrapped("wrapped_write_failure", true, false, false, "write", 0, true));
rows.add(wrapped("flush_failure_on_check", false, true, false, "write", 0, true));
rows.add(wrapped("healthy_control", false, false, false, "write", 0, false));
rows.add(wrapped("auto_write_newline", false, false, true, "newline", 0, false));
rows.add(wrapped("auto_println", false, false, true, "println", 1, false));
rows.add(wrapped("auto_printf_no_newline", false, false, true, "printf", 1, false));
Files.createDirectories(output);
String version = System.getProperty("java.version");
String json = "{\n \"java_version\":\"" + version
+ "\",\n \"fault_model\":\"in_memory_ordinary_IOException\",\n \"rows\":[\n "
+ String.join(",\n ", rows.stream().map(Row::json).toList()) + "\n ]\n}\n";
String csv = "case,write_outcome,check_error,accepted_chars,flushes_before_check,flushes_after_check\n"
+ String.join("\n", rows.stream().map(Row::csv).toList()) + "\n";
// Report creation deliberately uses an API that propagates IOException.
Files.writeString(output.resolve("report.json"), json, StandardCharsets.UTF_8);
Files.writeString(output.resolve("matrix.csv"), csv, StandardCharsets.UTF_8);
System.out.println("Java " + version + "; 7 cases; " + output);
System.out.println("direct: IOException; wrapped: returned + checkError=true");
System.out.println("autoFlush before check: write(newline)=0; println=1; printf(no newline)=1");
System.out.println("PASS");
}
}
怎样运行:java --source 17 PrintWriterBoundary.java(在解压目录执行;需要完整 JDK 17 或更新版本,仅标准库,不要求 javac 在 PATH 中。) 2026-10-15 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 PrintWriter 与可控内存 Writer 故障;矩阵在关闭前采样,未单独验收关闭阶段,不代表实际磁盘、网络或断电持久性。
运行后对照结果
Java 17.0.20.1; 7 cases; printwriter-output
direct: IOException; wrapped: returned + checkError=true
autoFlush before check: write(newline)=0; println=1; printf(no newline)=1
PASS把错误检查写进成功条件,而不是随手记一条日志
如果当前业务继续使用 PrintWriter,负责完成输出的一层应在约定的完成边界检查错误状态,并在为 true 时阻止“成功”结果继续传播。只是把 true 写到调试日志,然后仍向用户显示完成,并没有改变错误处理结果。
错误标记也不是原始异常详情。需要精确追踪哪次输出失败、保留具体 IOException 或交给现有异常链处理时,可以评估直接使用会传播异常的 Writer 接口,把字符串格式化与实际写出分开。选型应服务于调用方需要的失败信息。
不要直接在一个只调用 PrintWriter.write 的 try 块外添加 catch(IOException),就认为已经补上检查。该方法本身没有声明这个受检异常;若块里没有其他可能抛出它的操作,这样的 catch 还会导致编译错误。错误通道要按实际调用核对。
资源释放与输出验收承担不同职责
try-with-resources 可以保证按规则执行关闭动作,但不会替你定义“导出成功”。PrintWriter 的 close 也沿用自己的 I/O 错误报告方式。需要把关闭阶段纳入验收时,应明确最终错误检查位置,不能只因资源块正常离开就发出成功状态。
本脚本的矩阵在 finally 关闭对象之前取值,主要对照写入与刷新故障。刷新失败组的故障会持续到关闭时触发的再次刷新,但报告没有单独记录关闭阶段,也没有隔离一个仅在关闭时失败的场景。独立练习会要求补上这一边界,并区分关闭前后检查。
刷新也不等于断电后一定可恢复。Writer 文档明确区分把数据交给操作系统与写到物理设备;本篇使用内存目标,更没有进行持久性测试。导出内容正确、输出无报告错误、文件可重新读取和持久性要求,应按业务分别验收。
记住这三点
PrintWriter的普通 I/O错误需要按其错误状态通道检查,不能只等 write 抛出异常。checkError在对象未关闭时会刷新,观察前后计数才能辨认错误发生时机。autoFlush的触发取决于方法,write 中出现换行并不等同于调用 println。
别把例子用过头
- 把
PrintWriter的约定推广成所有 Writer 都不抛 IOException。 - 先调用
checkError,再把新增的刷新误算成自动刷新。 - 正常离开资源块或得到
false,就宣称业务内容、真实设备与持久性全部通过。
自己试一小步
如果故障只发生在关闭阶段,谁应该阻止输出任务报告成功?
- 复制脚本,增加一个只在 close 中抛普通 IOException 的测试替身。
- 先预测直接调用、
PrintWriter包装、关闭前检查与关闭后检查分别能观察到什么。 - 给每组记录增加关闭次数、关闭结果和最终错误标记,实际运行后对照预测。
- 设计一个明确拥有输出对象的工具方法,说明成功判定放在哪一层,并保留一次健康对照。
做到这些,就算完成
- 新的故障点确实只在关闭阶段触发,不能沿用写入失败代替。
- 区分方法是否返回、底层是否完成关闭和调用方是否报告成功。
- 说明自定义替身的范围,不把它称为已复现真实磁盘故障。
- 保留原始报告,不只交一条 PASS。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17:PrintWriter 的错误与自动刷新约定 (新标签页)↗
核读类说明、构造器与
checkError、write、close:输出方法的 I/O错误通道,以及自动刷新触发方法。 - Java 17:Writer 的 write、flush 与 close (新标签页)↗
核读 write 的异常声明,以及 flush 对操作系统和物理设备的保证边界。
- OpenJDK 17.0.20:PrintWriter 的实现 (新标签页)↗
实际读取固定标签源码的 flush、close、
checkError与 write 分支,确认 trouble 标记及检查时刷新。