qinghuan
← Java SE / 全部笔记

单元 27 / Java SE

P0 · 必会主线L1 · 基础

IO:字节、字符、缓冲与资源关闭

先分清字节与文本,再按实际读取数量处理数据,明确编码、缓冲和谁负责关闭资源。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能选择正确 IO 抽象,并说明逐行读取为什么不保证字节级复制。

学习理由、准备要求与知识点

为什么学它

字节、字符、编码和关闭时机构成文件与网络数据处理的共同基础。

先读这些内容

知识点清单

  • 字节流与字符流
  • 字符集显式转换
  • 缓冲作用与限制
  • Files.newBufferedReader
  • try-with-resources
  • readLine 换行丢失
  • 实际读取长度
  • 文本读取与二进制复制

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

读写文件前先判断内容:图片、压缩包等按原始字节处理,文本则需要按字符编码转换。InputStream 和 OutputStream 处理字节,Reader 和 Writer 处理字符;InputStreamReader 按指定字符集把字节变成文字,OutputStreamWriter 做反方向转换。即使文件名是 txt,也要约定编码,否则同一份字节可能被读成不同内容或报错。

读取时还要知道“实际读到了多少”。InputStream.read() 用 int 返回 0 到 255 的字节值,并用 -1 表示结束;read(buffer) 返回本次读到的数量,不保证填满数组。后续只处理这部分有效数据,别把剩余旧内容一起写出去。available 也不能普遍当作文件总长度,或用来判断以后再也没有数据。

缓冲层把多次小读写集中处理,减少与下层交互次数,但不改变业务数据本身。BufferedInputStream 提供字节缓冲,BufferedReader 在字符层还支持 readLine;readLine 会去掉行结束符,并可能为超长一行分配大量内存。资源通常用 try-with-resources 管理,关闭最外层包装器会按其契约处理下层资源,应明确谁拥有这条流链。

文中这几个词,先认一认

短读
一次读取比请求的数量少,但后面仍可能有数据,不能因此就当作结束。
编码桥接
按指定字符集在原始字节和文本字符之间转换。
缓冲
先在内存中积攒或保存一小段数据,减少频繁调用下层读写操作。
流所有权
约定谁负责关闭这条流及其包装层,避免资源泄漏或过早关闭。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

一个可靠读取循环以 read 返回值驱动,负一表示结束,正数表示当前有效数据长度。网络、过滤流和其它实现都可能短读,因此不能根据小于缓冲区长度就提前退出。对于要求读取固定帧长的场景,需要循环累积或使用明确的精确读取 API,并处理提前结束;文本行读取则是另一套分隔规则。

字符解码器需要保留跨块状态。UTF-8 一个字符可能被拆到两次字节读取中,对每块直接 new String(bytes,charset) 可能把不完整片段替换成错误字符。使用 Reader 或维护 CharsetDecoder 状态才能正确连接边界。下面算法明确匹配原始字节模式,不进行字符解码,因此它只需保留模式匹配状态,不把字节边界误当成字符边界。

flush 通常把包装层已缓冲数据交给下层,不保证数据已持久化到磁盘,也不保证远端已确认收到。close 可能顺带刷新并释放资源,但若关闭失败仍需保留异常。图把读取、有效长度和跨块算法状态分开,使读者能看到缓冲数组可复用,逻辑状态却必须跨多次读取持续存在。

正在加载流程图…

读取块只是传输批次;有效长度和跨块状态共同决定处理正确性。
查看流程图源码(Mermaid)
flowchart TD
 A["底层 InputStream"] --> B["读取到可复用 byte 缓冲"]
 B --> C{"返回数量"}
 C -->|"负一"| D["结束"]
 C -->|"正数"| E["只处理有效前缀"]
 E --> F["更新跨块匹配状态"]
 F --> B
 A --> G["文本需求另加指定字符集 Reader"]

按顺序看,每一步做了什么

  1. 确定数据是二进制还是文本。
  2. 文本显式指定字符集与错误策略。
  3. 以实际读取数量驱动处理。
  4. 保留跨块协议或匹配状态。
  5. 明确流关闭责任与刷新、持久化边界。

03对照实现代码

缓冲重填如何保留尚需使用的字节 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/io/BufferedInputStream.java · java.io.BufferedInputStream#fill()

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;
public final class SourceModel extends InputStream {
    private final InputStream source;
    private final byte[] buffer = new byte[8];
    private int position, limit;
    public SourceModel(InputStream source) { this.source = Objects.requireNonNull(source); }
    @Override public int read() throws IOException {
        if (position == limit) {
            position = 0;
            limit = source.read(buffer);
            if (limit < 0) { limit = 0; return -1; }
            if (limit == 0) throw new IOException("source made no progress");
        }
        return buffer[position++] & 0xff; // 保留完整无符号字节值,负一留给结束。
    }
    @Override public void close() throws IOException { source.close(); }
}

真实 BufferedInputStream.fill 在没有有效 mark 时可以把已消费部分丢弃并从头重填;存在 mark 时,可能需要移动保留区间、使标记失效或增长缓冲。模型只演示没有 mark 的单线程顺序读,不实现 mark/reset、skip、关闭后状态及并发关闭协调,不能作为生产替代实现。

position 指向下一字节,limit 表示当前有效数据终点。只有两者相等才需要下层读取,返回正数后更新有效范围。缓冲区容量通常大于本次数据量,所以对外读取不能直接遍历整个数组;数组中未被本次覆盖的位置可能保存上一次内容。

模型用与 0xff 保留 byte 的无符号数值,避免高位字节转换成负数并与结束标记混淆。真实单字节读取 API 的 int 返回契约由此得到直观解释。按 byte 数值直接比较模式则不必先转为无符号,只要两边采用同一八位表示即可。

真实实现还包含大数组边界和异步关闭时的内部协调,部分路径借助 JDK 内部能力。教学模型不接触这些内部接口,也不据此推断加一层缓冲必然更快。是否减少系统调用、是否额外复制,应结合已有包装层和实际读块大小判断。

代码先调用谁,再调用谁

  1. read 检查缓冲是否还有有效数据
  2. 数据耗尽时进入 fill
  3. 根据 mark 状态保留或释放旧区间
  4. 从下层读取并更新 count 与 pos
  5. 向调用者交付有效字节

04动手写一个例子

跨读取块统计字节模式出现次数

在 InputStream 中统计非空 byte 模式的出现次数,允许重叠。使用 KMP 保留跨块匹配状态;方法借用输入流而不关闭,调用者管理资源,输入和模式计算期间不得被并发修改。

输入 1
字节aaaa,模式aa
输出 1
3
输入 2
字节ababa,模式aba
输出 2
2
输入 3
空字节流,模式a
输出 3
0

先为模式构建前缀表,记录每个前缀的最长相同真前后缀长度。扫描输入时,matched 表示已经匹配的模式前缀长度;遇到不匹配,就沿前缀表退到仍可能成立的较短前缀,而不把输入流倒回去。这个性质特别适合不能随机访问的流。

matched 必须放在读取循环外,不能每换一个缓冲块就归零。一个完整模式可能从上一块末尾开始,在下一块开头结束;块边界不是业务分隔符。只遍历本次实际读取数量,避免把缓冲区剩余旧字节当作新输入。

匹配完整模式后计数加一,再退到模式的最长真前后缀,允许重叠出现。比如 aaaa 中 aa 有三个起点,匹配后直接清零会漏掉部分重叠。测试用只返回一个字节的短读流强制制造边界,验证算法不依赖某次 read 恰好拿到完整模式。

为什么这样写是对的处理每个输入字节后,matched 是已读后缀中仍与模式前缀相同的最长长度;计数包含所有已完成匹配。

时间 O(n+m),n 为读取字节数,m 为模式长度。额外空间 O(m+b),b 为读取缓冲大小。

模式预处理线性;扫描中 matched 的增加总次数不超过输入长度,回退消耗此前增加的长度,因此总回退也是线性。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 58 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
public class Main {
    static long count(InputStream input, byte[] pattern, int block) throws IOException {
        if (input == null || pattern == null || pattern.length == 0 || block < 1 || block > 65536)
            throw new IllegalArgumentException("input");
        int[] prefix = new int[pattern.length];
        for (int i = 1, j = 0; i < pattern.length; i++) {
            while (j > 0 && pattern[i] != pattern[j]) j = prefix[j-1];
            if (pattern[i] == pattern[j]) j++;
            prefix[i] = j;
        }
        byte[] buffer = new byte[block];
        int matched = 0; // 跨读取块保留状态,不能放进 read 循环内部。
        long hits = 0;
        for (int n; (n = input.read(buffer)) != -1;) {
            if (n == 0) throw new IOException("non-progressing source");
            for (int i = 0; i < n; i++) { // 只处理本次有效字节。
                while (matched > 0 && buffer[i] != pattern[matched])
                    matched = prefix[matched-1];
                if (buffer[i] == pattern[matched]) matched++;
                if (matched == pattern.length) {
                    hits = Math.addExact(hits,1L);
                    matched = prefix[matched-1]; // 保留重叠匹配的可能。
                }
            }
        }
        return hits;
    }
    static void check(long wanted, String text, String pattern) throws IOException {
        byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
        try (InputStream input = new ByteArrayInputStream(bytes) {
            @Override public synchronized int read(byte[] b, int off, int len) {
                return super.read(b,off,Math.min(len,1));
            }
        }) {
            if (count(input,pattern.getBytes(StandardCharsets.UTF_8),3) != wanted)
                throw new AssertionError(text);
        }
    }
    public static void main(String[] args) throws Exception {
        // 单字节短读强制覆盖块边界,另验空流和重叠。
        check(3,"aaaa","aa");
        check(2,"ababa","aba");
        check(0,"","a");
        check(1,"清欢清欢","欢清");
        try (InputStream input = new ByteArrayInputStream(new byte[]{0,-1,0})) {
            if (count(input,new byte[]{-1,0},2) != 1) throw new AssertionError("binary");
        }
        boolean rejected = false;
        try { count(new ByteArrayInputStream(new byte[0]),new byte[0],1); }
        catch (IllegalArgumentException e) { rejected = true; }
        if (!rejected) throw new AssertionError("empty pattern");
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 模式为空时拒绝,避免不明确的无限匹配语义。
  • 输入为空时返回零。
  • 短读和跨块模式保持正确。
  • 支持二进制高位字节与重叠出现。
进阶选读 · 可选深入:多模式流式匹配与解码

场景需要同时检测许多模式,或按规范化文本而非原始字节匹配。

做法多模式可以构建失败转移自动机;文本匹配则先维护正确解码与规范化状态,再明确码点和字素边界。

复杂度自动机匹配通常按输入与命中数量计费,构建和存储随模式总长度增长。

好处与代价更多状态增加内存和维护成本,字节匹配结果也不自动等同于文本语义匹配。

05项目里怎么用

读文件或网络内容时先限制总体大小、单行长度或帧长度,再决定是否全部读入内存。readAllBytes 很方便,但对未知大小输入可能造成大分配;分块处理降低整体保留量,却要求正确维护跨块状态。缓冲大小是性能参数,不应改变正确结果。

编码应贯穿写入、存储、传输和读取链路。显式 UTF-8 可以减少平台默认差异,但仍需处理非法字节、BOM 与规范化等具体需求;不能把乱码都归因于字体。排错时保留可解释的原始字节片段和声明字符集,避免反复用不同编码试到“看起来正常”便认定正确。

包装流时也要说清谁负责关。方法只是借用调用方的流,通常把关闭责任留给调用方;方法自己打开文件,就应在自己的处理范围内保证关闭。flush 成功不表示数据已经永久保存,也不表示远端已经确认收到。关键数据是否完成,要按文件系统、数据库或协议提供的相应保证判断。

容易踩的坑

  • 忽略 read 返回数量,把整块旧数据写出。
  • 短读时误判为文件结束。
  • 逐块 new String 破坏跨块多字节字符。
  • 把 flush 等同于数据已持久化或远端已收到。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
全部读入逻辑简单且可随机访问未知大小可能耗尽内存有严格大小上限的小数据
流式分块内存可控且适合持续输入需要跨块状态与边界处理大文件、协议和扫描
字符 Reader集中处理解码状态必须明确字符集及错误策略文本处理与逐行读取

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么 read() 返回 int 而不是 byte?

展开参考答案与推导

需要同时表示全部二百五十六种字节值和负一结束标记。若直接用 byte 保存结果,高位字节可能表现为负数,便无法可靠区分数据与结束。

02一次 read 少于缓冲长度是否表示结束?

展开参考答案与推导

不是。它只说明本次实际获得的数据量,后面仍可能继续读取。结束由负一表示,固定长度协议需持续累积并处理提前结束,不能用短读作为通用终止条件。

03缓冲与字符编码分别解决什么?

展开参考答案与推导

缓冲改变下层调用批次和数据暂存方式,编码决定字节与字符如何转换。添加 BufferedInputStream 不会自动解码 UTF-8,Reader 也不意味着所有数据可无上限读入。

换个情况,还会处理吗

01复制文件后尾部重复,先查哪里?

展开参考答案与推导

检查写出是否使用 out.write(buffer,0,n),而不是每次写整个缓冲数组。最后一次短读后剩余槽位可能保留上一块内容,忽略实际数量就会重复写旧字节。

02中文只在块边界乱码,可能是什么?

展开参考答案与推导

可能每块独立构造字符串,切断了多字节字符。使用 Reader 或有状态 CharsetDecoder 保留未完成序列,并用刻意短读样本验证,不能仅增大缓冲掩盖问题。

03调用 flush 后断电仍丢数据,为什么?

展开参考答案与推导

flush 通常只把应用包装层数据交给下层,不必然保证落到持久介质。持久化需要采用对应系统提供的确认与同步语义,并区分进程缓冲、系统缓存和设备状态。

写代码试一试

01验证模式长度大于缓冲

输入 / 条件
文本abcabc,模式abc,块1
输出 / 预期
2

提示 匹配状态跨读取保留。

展开参考答案与推导

以一个字节缓冲调用 count 并断言两次命中。若 matched 每轮清零就无法完成三字节模式,这个测试直接覆盖缓冲大小不应改变结果的性质。

02改为不允许重叠

输入 / 条件
aaaa 与 aa
输出 / 预期
2

提示 完成匹配后清零状态。

展开参考答案与推导

在命中分支把 matched 设为零,明确这是新的非重叠契约,并更新 aaaa 预期为二。仍需保留跨块与短读测试,不能因为改变重叠语义就省略读取边界验证。

03统计字节总数而非匹配

输入 / 条件
三次读取数量2、1、负一
输出 / 预期
3

提示 只累计正数返回值。

展开参考答案与推导

用 long 累计每次 n,结束标记不加入;对需要严格失败的计数使用精确加法。不要使用 available 预测总长度,它描述的能力不等于流的最终全部数据量。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能选择正确 IO 抽象,并说明逐行读取为什么不保证字节级复制。

本篇重点加练

重点增加编码选择与实际读取长度的检验,覆盖数据可读但已发生损坏的情况。

  • 字符集显式转换
  • 实际读取长度

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    准备一个 UTF-8 文本和一个任意二进制样本,说明哪些处理目标适合字节层、哪些需要字符层;定义编码错误如何报告,并解释实验中的“字符数”具体计量对象。

    提交内容
    两类处理流程和计量合同。
    怎样算完成
    • 显式指定字符集
    • 不把字节数量当作文本长度

    练到的知识点字节流与字符流 · 字符集显式转换

  2. 作业 02排错实验

    在临时目录生成大小有界的文本,以不同缓冲大小读取并统计行数;保留同一输入,记录读取调用次数与耗时,避免将一次计时直接解释成吞吐结论。

    提交内容
    Java 实验、自写结果一致性测试和测量记录。
    怎样算完成
    • 缓冲影响与字符解析分开
    • 测量输入及运行条件可重复

    练到的知识点缓冲作用与限制 · Files.newBufferedReader

  3. 作业 03代码推演

    读取 API 连续返回长度 2、1、4 后结束,缓冲长度为 4。用纸面缓冲内容记录每轮有效范围,审查“每次写完整缓冲”的复制提案。

    提交内容
    逐轮状态表和验收输入。
    怎样算完成
    • 新数据与残留内容可以区分
    • 结论由具体字节轨迹支持

    练到的知识点实际读取长度

综合应用

  1. 作业 04代码推演

    文件内容包含 LF、CRLF、空行和末尾无换行。分别用 readLine 和逐字符读取记录文本结构,再设计资源创建和读取失败时的关闭验证。

    提交内容
    样本字节描述、读取对照与清理事件表。
    怎样算完成
    • 换行保真需求明确
    • 失败路径也有关闭证据

    练到的知识点try-with-resources · readLine 换行丢失

  2. 作业 05编程实践

    实现二进制复制工具,输入样本长度取 0、1、7、8、9,缓冲长度为 8;禁止把整个文件一次读入内存,输出只能位于本题临时目录。

    提交内容
    Java 工具、自写逐字节一致性和异常测试、运行证据。
    怎样算完成
    • 按实际读取长度写出
    • 覆盖末块和空文件
    • 资源与失败残留政策明确

    练到的知识点实际读取长度 · 文本读取与二进制复制

  3. 作业 06面试场景

    日志归档工具读行后重新写入,发现字节校验和改变但文字看似一致。列出需要核对的编码、换行和末尾状态,并区分逐字节备份与内容导出的合同。

    提交内容
    诊断清单与两类工具验收规格。
    怎样算完成
    • 变化判断有可观察证据
    • 不把内容可读等同无损复制

    练到的知识点字节流与字符流 · 字符集显式转换 · Files.newBufferedReader · readLine 换行丢失

  4. 作业 07编程实践

    实现只读取前 N 个字节的二进制预览器,读取源每次最多返回三字节;文本展示另由显式字符集决定,遇到截断字符按你声明的政策处理。

    提交内容
    Java 实现、自写短读/空输入/截断测试和运行证据。
    怎样算完成
    • 字节截取与文本解释分开
    • 实际读取范围能逐步核对

    练到的知识点字符集显式转换 · 实际读取长度

  5. 作业 08排错实验

    同一临时文件分别用匹配和不匹配的字符集读取,样本含 ASCII、中文与无法在另一编码表示的字符;比较原始字节与读回文本。

    提交内容
    样本生成代码、自写往返检查和观测表。
    怎样算完成
    • 编码来源明确
    • 观测不只依赖终端字体

    练到的知识点字符集显式转换

进阶挑战 · 掌握前置后选做

  1. 作业 09编程实践

    完成字符串匹配后:实现跨读取块查找字节模式的位置,允许重叠,缓冲比模式短;将输出与小文件一次性参考实现比较。

    提交内容
    Java 实现、自写分块/重叠/不存在测试和运行证据。
    怎样算完成
    • 跨块状态有边界验证
    • 复杂度含输入及输出规模
    • 输入与输出规模有上限

    练到的知识点缓冲作用与限制 · 实际读取长度 · 文本读取与二进制复制

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    需求从“复制文件”改为“统一转换为 UTF-8 并保留换行风格”。重新定义成功判据、非法输入处理和可保留的信息,比较两种需求的校验方式。

    提交内容
    转换合同和前后验收对照。
    怎样算完成
    • 字节一致与文本一致各有定义
    • 无法保留的信息被明确说明

    练到的知识点字符集显式转换 · 实际读取长度

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗