读完这篇,你能做什么
能选择正确 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"]按顺序看,每一步做了什么
- 确定数据是二进制还是文本。
- 文本显式指定字符集与错误策略。
- 以实际读取数量驱动处理。
- 保留跨块协议或匹配状态。
- 明确流关闭责任与刷新、持久化边界。
03对照实现代码
Microsoft OpenJDK 17.0.20.1;java.base/java/io/BufferedInputStream.java · java.io.BufferedInputStream#fill()
以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。
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 内部能力。教学模型不接触这些内部接口,也不据此推断加一层缓冲必然更快。是否减少系统调用、是否额外复制,应结合已有包装层和实际读块大小判断。
代码先调用谁,再调用谁
- read 检查缓冲是否还有有效数据
- 数据耗尽时进入 fill
- 根据 mark 状态保留或释放旧区间
- 从下层读取并更新 count 与 pos
- 向调用者交付有效字节
04动手写一个例子
跨读取块统计字节模式出现次数
在 InputStream 中统计非空 byte 模式的出现次数,允许重叠。使用 KMP 保留跨块匹配状态;方法借用输入流而不关闭,调用者管理资源,输入和模式计算期间不得被并发修改。
- 输入 1
字节aaaa,模式aa- 输出 1
3
- 输入 2
字节ababa,模式aba- 输出 2
2
- 输入 3
空字节流,模式a- 输出 3
0
先为模式构建前缀表,记录每个前缀的最长相同真前后缀长度。扫描输入时,matched 表示已经匹配的模式前缀长度;遇到不匹配,就沿前缀表退到仍可能成立的较短前缀,而不把输入流倒回去。这个性质特别适合不能随机访问的流。
matched 必须放在读取循环外,不能每换一个缓冲块就归零。一个完整模式可能从上一块末尾开始,在下一块开头结束;块边界不是业务分隔符。只遍历本次实际读取数量,避免把缓冲区剩余旧字节当作新输入。
匹配完整模式后计数加一,再退到模式的最长真前后缀,允许重叠出现。比如 aaaa 中 aa 有三个起点,匹配后直接清零会漏掉部分重叠。测试用只返回一个字节的短读流强制制造边界,验证算法不依赖某次 read 恰好拿到完整模式。
为什么这样写是对的处理每个输入字节后,matched 是已读后缀中仍与模式前缀相同的最长长度;计数包含所有已完成匹配。
模式预处理线性;扫描中 matched 的增加总次数不超过输入长度,回退消耗此前增加的长度,因此总回退也是线性。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
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 抽象,并说明逐行读取为什么不保证字节级复制。
本篇重点加练
重点增加编码选择与实际读取长度的检验,覆盖数据可读但已发生损坏的情况。
- 字符集显式转换
- 实际读取长度
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01原理问答
准备一个 UTF-8 文本和一个任意二进制样本,说明哪些处理目标适合字节层、哪些需要字符层;定义编码错误如何报告,并解释实验中的“字符数”具体计量对象。
- 提交内容
- 两类处理流程和计量合同。
- 怎样算完成
- 显式指定字符集
- 不把字节数量当作文本长度
练到的知识点字节流与字符流 · 字符集显式转换
- 作业 02排错实验
在临时目录生成大小有界的文本,以不同缓冲大小读取并统计行数;保留同一输入,记录读取调用次数与耗时,避免将一次计时直接解释成吞吐结论。
- 提交内容
- Java 实验、自写结果一致性测试和测量记录。
- 怎样算完成
- 缓冲影响与字符解析分开
- 测量输入及运行条件可重复
练到的知识点缓冲作用与限制 · Files.newBufferedReader
- 作业 03代码推演
读取 API 连续返回长度 2、1、4 后结束,缓冲长度为 4。用纸面缓冲内容记录每轮有效范围,审查“每次写完整缓冲”的复制提案。
- 提交内容
- 逐轮状态表和验收输入。
- 怎样算完成
- 新数据与残留内容可以区分
- 结论由具体字节轨迹支持
练到的知识点实际读取长度
综合应用
- 作业 04代码推演
文件内容包含 LF、CRLF、空行和末尾无换行。分别用
readLine和逐字符读取记录文本结构,再设计资源创建和读取失败时的关闭验证。- 提交内容
- 样本字节描述、读取对照与清理事件表。
- 怎样算完成
- 换行保真需求明确
- 失败路径也有关闭证据
练到的知识点try-with-resources · readLine 换行丢失
- 作业 05编程实践
实现二进制复制工具,输入样本长度取 0、1、7、8、9,缓冲长度为 8;禁止把整个文件一次读入内存,输出只能位于本题临时目录。
- 提交内容
- Java 工具、自写逐字节一致性和异常测试、运行证据。
- 怎样算完成
- 按实际读取长度写出
- 覆盖末块和空文件
- 资源与失败残留政策明确
练到的知识点实际读取长度 · 文本读取与二进制复制
- 作业 06面试场景
日志归档工具读行后重新写入,发现字节校验和改变但文字看似一致。列出需要核对的编码、换行和末尾状态,并区分逐字节备份与内容导出的合同。
- 提交内容
- 诊断清单与两类工具验收规格。
- 怎样算完成
- 变化判断有可观察证据
- 不把内容可读等同无损复制
练到的知识点字节流与字符流 · 字符集显式转换 · Files.newBufferedReader · readLine 换行丢失
- 作业 07编程实践
实现只读取前 N 个字节的二进制预览器,读取源每次最多返回三字节;文本展示另由显式字符集决定,遇到截断字符按你声明的政策处理。
- 提交内容
- Java 实现、自写短读/空输入/截断测试和运行证据。
- 怎样算完成
- 字节截取与文本解释分开
- 实际读取范围能逐步核对
练到的知识点字符集显式转换 · 实际读取长度
- 作业 08排错实验
同一临时文件分别用匹配和不匹配的字符集读取,样本含 ASCII、中文与无法在另一编码表示的字符;比较原始字节与读回文本。
- 提交内容
- 样本生成代码、自写往返检查和观测表。
- 怎样算完成
- 编码来源明确
- 观测不只依赖终端字体
练到的知识点字符集显式转换
进阶挑战 · 掌握前置后选做
- 作业 09编程实践
完成字符串匹配后:实现跨读取块查找字节模式的位置,允许重叠,缓冲比模式短;将输出与小文件一次性参考实现比较。
- 提交内容
- Java 实现、自写分块/重叠/不存在测试和运行证据。
- 怎样算完成
- 跨块状态有边界验证
- 复杂度含输入及输出规模
- 输入与输出规模有上限
练到的知识点缓冲作用与限制 · 实际读取长度 · 文本读取与二进制复制
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
需求从“复制文件”改为“统一转换为 UTF-8 并保留换行风格”。重新定义成功判据、非法输入处理和可保留的信息,比较两种需求的校验方式。
- 提交内容
- 转换合同和前后验收对照。
- 怎样算完成
- 字节一致与文本一致各有定义
- 无法保留的信息被明确说明
练到的知识点字符集显式转换 · 实际读取长度
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17 API:Files (新标签页)↗
阅读文件读写、walk、符号链接与 move 的选项和资源关闭要求。
- 实践教程英文
Oracle:try-with-resources (新标签页)↗
阅读关闭顺序与 suppressed 异常;教程以较早 JDK 为背景,相关语义适用于 Java 17。
- 深入参考文档英文
Java 17 API:String (新标签页)↗
阅读 length、
codePoints、substring、split 的边界及不可变语义。 - 深入参考文档英文
OpenJDK 17u java.io.BufferedInputStream (新标签页)↗
定位
BufferedInputStream.fill的 mark 保留、缓冲增长与实际读取数量更新。 - 深入参考文档英文
Java17 InputStream API (新标签页)↗
阅读 read 返回值、短读、available 与关闭契约。
- 深入参考文档英文
Java17 InputStreamReader API (新标签页)↗
理解字节到字符的桥接与显式字符集。
- 深入参考文档英文
Java17 BufferedReader API (新标签页)↗
查阅
readLine的行结束符处理与缓冲行为。