Java IO:字节、字符与资源管理
文件里存的是字节,程序处理的却常常是文本。读写错误往往发生在两者交界处:把每次读到的字节当成完整字符串,或者以为调用 write 就已经写进文件。理解 IO,需要把数据单位、解码状态和资源生命周期分开观察。
字节流和字符流分别承诺什么
| 层次 | 常见接口 | 处理单位 | 适用场景 |
|---|---|---|---|
| 字节流 | InputStream / OutputStream | 原始 byte | 图片、压缩包、保持原样的文件复制 |
| 转换桥梁 | InputStreamReader / OutputStreamWriter | 按字符集解码 / 编码 | 字节与文本边界 |
| 字符流 | Reader / Writer | UTF-16 char 单元 | 文本处理 |
| 字符缓冲 | BufferedReader / BufferedWriter | 缓存字符 | 小量多次读写、按行读取 |
复制二进制文件不要经过 Reader:无法解码的字节可能被替换,重新编码后便不再等于原文件。文本则应在边界明确使用 StandardCharsets.UTF_8,让文件格式和调用方达成同一约定。
InputStream.read() 返回 0~255 的整数或表示结束的 -1;批量读取返回本次实际数量,不能假设填满数组。Reader.read() 读到的是 char 单元,也不等于一个完整 Unicode 码点:示例中的表情占两个 char。即使解码正确,逐个 char 做截断仍可能切断代理对。Reader 官方契约描述了其读取单位。
UTF-8 的边界不服从 read 的边界
A中B 的 UTF-8 字节为 41 E4 B8 AD 42。若第一次读到 41 E4,第二次读到 B8 AD 42,分块调用 new String(bytes, off, len, UTF_8) 会启动两次独立解码。第一次缺少“中”的后两个字节,第二次又从续接字节开始,最终出现替换字符。把数组扩到更大只能降低碰巧切中的概率,不能建立正确性。
对阻塞流,最直接的做法是在整次读取期间复用同一个 InputStreamReader,让它维护转换状态。它可能预读字节,因此包装以后不要再绕过它读取底层流,否则会与预读数据争抢位置。InputStreamReader 官方说明明确描述了桥接和预读行为。
下面摘自完整示例的读取方法,传入的底层流刻意每次最多交付一个字节:
var decoder = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
try (var reader = new BufferedReader(
new InputStreamReader(new OneByteInput(bytes), decoder))) {
var text = new StringBuilder();
char[] buffer = new char[2];
for (int n; (n = reader.read(buffer)) != -1;) {
text.append(buffer, 0, n);
}
return text.toString();
}循环只消费 0..n,不会把数组中上次留下的尾部追加进去。此处为展示而收集完整字符串;大文件应边读边处理,避免让内存随文件增长。按行读取时还要注意 readLine() 会去掉行终止符,读后再补换行并非逐字节复制。
错误策略也应明确:new String 的容错替换可能掩盖损坏;需要拒绝损坏输入时显式传入 REPORT 解码器。示例的 truncated 模式只交付 E4 B8 后结束,预期抛出 MalformedInputException,并非程序应吞掉的正常文本。
若自己用 NIO 解码,未结束时 decode(..., false),遇到 UNDERFLOW 要保留未消费尾字节,通常通过 compact() 后继续填充;OVERFLOW 则先消费输出再继续。真正 EOF 后调用 decode(..., true),再 flush(),两阶段都处理输出溢出。具体调用顺序见 CharsetDecoder 官方契约。
缓冲改变调用频率,不改变文本规则
缓冲把许多小操作合并成较少的底层操作,但不会把 read 边界变成字符边界。字节缓冲与字符缓冲可以处于不同层次,不必为每层机械叠加包装,更不能据此断言性能倍数。
示例向内存字节流外的 BufferedWriter 写入 A中B,刷新前底层大小为 0,flush() 后为 5。这展示的是这次小数据写入的可见性,大块写入可能直接穿过缓冲。close() 会先刷新再关闭;刷新仍可能失败,不能忽略关闭异常。BufferedWriter 文档给出了这些操作的语义。
flush() 只是把缓冲内容向下一层推进,不保证磁盘掉电持久化,也不保证网络对端已经读取。需要持久化语义时,应进一步研究文件系统和 FileChannel.force,不能把刷新当作事务提交。
try-with-resources 如何保留异常
资源按声明顺序初始化、按相反顺序关闭。正文抛出异常时,它仍是主异常;关闭时的异常通过 getSuppressed() 挂在主异常上。示例故意让正文和两个 close 都抛异常,实际顺序为:
close second
close first
primary=body
suppressed=[close second, close first]这样能同时保留业务失败与清理失败。若正文正常而关闭失败,关闭异常也会向外传播;若后一个资源初始化失败,先前成功创建的资源仍会关闭。规则见 JLS 21 §14.20.3。
普通包装链通常关闭最外层即可,关闭会沿链传递;若包装器构造可能失败,可先把底层资源单独放进资源声明,避免构造中途泄漏。资源所有权也必须清楚:借用调用方的流时,不应擅自关闭它。
在 JDK 21 中运行与继续阅读
完整源码:IoStreamsDemo.java。在项目根目录运行:
New-Item -ItemType Directory -Force docs/upgrade-100/checks/io-streams/classes
javac -encoding UTF-8 --release 21 -d docs/upgrade-100/checks/io-streams/classes examples/java-advanced/io-streams/IoStreamsDemo.java
java -cp docs/upgrade-100/checks/io-streams/classes IoStreamsDemo
# 预期失败:截断的 UTF-8,退出码非零
java -cp docs/upgrade-100/checks/io-streams/classes IoStreamsDemo truncated本地 JDK 21.0.12 实际编译成功,正常模式退出 0,截断模式退出 1。正常输出中,逐块解码产生三个 U+FFFD,连续解码得到 [U+0041, U+4E2D, U+0042];表情输出为两个 char、一个码点。完整命令、版本与异常栈见运行记录。
继续阅读技术进阶入口,将这里的解码状态与其中 Buffer、Channel 的状态和部分读写联系起来。
