开始前,会这些就够了
- 知道
InputStream.read返回int,普通字节与结束标记需要区分 - 理解包装器可以在原始输入流外保存自己的状态
- 先读
available()等于零,为什么后面还能读到数据?:先区分此刻可读量与真正结束,再理解回推缓冲增加了哪些可读字节。
为了多看一个字节,再留给下一步处理,你可能想到 unread。但它不是把原文件或原始流的游标倒着拨回去,而是在外层保存待重读的字节。
本实验从 ABC 开始,观察底层剩余量和外层重读结果;再核对顺序、容量与结束标记。所有输入都在内存中构造,不访问用户文件或网络。
把例子带到自己的电脑上
下载完整练习包
需要 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- Main.java:完整可运行源码
- README.md:运行步骤与练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和运行验证记录
- SHA256SUMS.txt:文件摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 Main.java
java -cp . Main解压后,在含 Main.java 的目录打开终端。命令分别回车执行。真实PushbackInputStream包裹ByteArrayInputStream;每JDK294检查,包含256字节值与9种前缀回推;每版重复三次一致。运行无文件/网络I/O;未实现Unicode解码、完整解析器、文件回退或并发。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
unread 增加的是回推缓冲,原始流的位置没有退回
正在准备实验控件…
固定 Java 实跑结果回放:JDK 17 与 21 各运行三次,结果一致。网页只选择已有记录,不启动 JVM,也不接受任意输入执行。下方完整 Java 项目可下载复验。
外层多了待重读字节,底层仍停在原来的位置
先创建内容为 ABC 的 ByteArrayInputStream,再包一层容量为 2 的 PushbackInputStream。读出 A、B 后,底层只剩 C;把 AB 数组 unread 后,外层接下来能读到 A、B、C。
这时底层 available() 仍为 1,外层为 3。重读 A 和 B 时,底层剩余量都不变;直到外层再次读取原始 C,底层才变为零。它说明重读来自外层保存的内容,原始游标没有后退。
这里特意选 ByteArrayInputStream,因为它的 available 返回准确的剩余字节数。不能把这项观察推广成任意网络流的 available 都代表总长度,更不能据此改写通用的结束条件。
连续放回单个字节,会先读到后放的那个
在空源外面依次 unread(A)、unread(B),下一次读取先得到 B,再得到 A。每次调用都把新字节放到回推缓冲的前面,因此连续单字节操作表现为后放先读。
一次 unread([A,B]) 的结果却是先 A 后 B。数组重载会按数组里的顺序保留这一段,让下一次读取先返回第一个元素;不能把它想成按从左到右顺序反复调用单字节 unread。
它复制的是字节内容。程序 unread 数组后,把原数组第一个元素改成 Z,外层仍读回 AB。这是回推缓冲的复制行为,不代表所有输入流构造器也都复制传入数组。
默认只容纳一个字节,它也不是 mark/reset
不传容量的构造器只创建一字节回推缓冲。已经放回 A、还没读走时,再放 B 会抛 IOException;一次放回两字节数组同样超限。读取回推字节后,对应空间可以再次使用。
容量是同时等待重读的字节数量,不是程序生命周期里最多能 unread 几次。若解析步骤最多需要回看 k 个字节,应显式选择合适容量,并设计超限时的处理;不能等缓冲满后静默丢掉内容。
PushbackInputStream 的 markSupported() 为 false,mark() 不做标记,reset() 会抛 IOException。底层 ByteArrayInputStream 支持 mark/reset,也不会让外层自动获得这项能力;包装层有自己的接口约定。
-1 是结束状态;unread(-1) 却会放入真正的 FF
InputStream.read 用 int 表达结果:0 到 255 表示一个真实字节,-1 表示流结束。即使原始字节是 FF,也应读成 255,不能和结束标记混淆。
unread(int) 只保存参数的低八位。程序故意先从空源读到 -1,再把这个值 unread,下一次居然得到 255;这是新放进去的 FF 字节,不是恢复了一个结束状态。
因此 peek 辅助函数先 read,只有结果不等于 -1 才 unread。程序既检查空源仍保持结束,也检查真实 FF 能被查看并原样留给下一次读取,防止修复一边却误伤另一边。
读出一段字母,把分隔符留给下一步
readLetters 只识别连续的 ASCII 大小写字母。对 name=42,它逐个收集 name,遇到 = 时把这个字节放回并返回;调用方接着读取时仍能拿到 =,剩余内容是 42。
遇到结束标记时不回推。空输入直接得到空前缀;输入全是 name 时得到整个词并保留结束状态;一开始就是 =42 时,函数返回空前缀,把 = 留给调用方。
这只是一个明确的小步骤:它不跳过前导分隔符,也不负责继续解析数字。调用方若反复调用却始终不消费同一个分隔符,当然不会自动前进;完整解析流程必须另外定义各步骤的消费责任。
运行 294 项检查,保留完整结果
保存为 Main.java,使用标准 JDK 编译运行。除了上述可读的例子,程序逐个回推并读取 0 到 255 全部字节值,还将 ABCDEFGH 的九种前缀长度读出再放回,检查最终完整序列与原数组一致。
2026-10-23,在 Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12 上分别用 --release 17 编译;每版运行三次,每次通过 294 项检查,六份 stdout 统一换行后完全相同。
其他检查覆盖零容量拒绝、关闭后的 read/unread 拒绝、默认缓冲超限及不支持 reset。两次超限后待重读内容未被部分改写也是本次两套 JDK 的实跑观察;不把它扩成所有 I/O 异常都具备业务事务回滚。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.PushbackInputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class Main {
private static int checks;
@FunctionalInterface private interface CheckedAction { void run() throws Exception; }
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
checks++;
}
private static void rejects(Class<? extends Throwable> expected, CheckedAction action) throws Exception {
try {
action.run();
} catch (Throwable error) {
check(expected.isInstance(error), "unexpected exception: " + error);
return;
}
throw new AssertionError("expected " + expected.getSimpleName());
}
private static byte[] ascii(String value) { return value.getBytes(StandardCharsets.US_ASCII); }
private static String text(byte[] value) { return new String(value, StandardCharsets.US_ASCII); }
private static int peek(PushbackInputStream input) throws IOException {
int value = input.read();
if (value != -1) input.unread(value); // EOF is a status, never a byte to replay.
return value;
}
// This demo recognizes only ASCII letters, not a complete identifier grammar.
private static String readLetters(PushbackInputStream input) throws IOException {
StringBuilder token = new StringBuilder();
int value;
while ((value = input.read()) != -1) {
if ((value >= 'A' && value <= 'Z') || (value >= 'a' && value <= 'z')) {
token.append((char) value);
} else {
input.unread(value);
break;
}
}
return token.toString();
}
public static void main(String[] args) throws Exception {
ByteArrayInputStream raw = new ByteArrayInputStream(ascii("ABC"));
try (PushbackInputStream input = new PushbackInputStream(raw, 2)) {
check(input.read() == 'A' && input.read() == 'B', "read original prefix");
check(raw.available() == 1, "ByteArrayInputStream has one original byte left");
input.unread(ascii("AB"));
check(raw.available() == 1 && input.available() == 3, "only wrapper gains replay bytes");
check(input.read() == 'A' && raw.available() == 1, "first replay does not move raw cursor");
check(input.read() == 'B' && raw.available() == 1, "second replay does not move raw cursor");
check(input.read() == 'C' && raw.available() == 0 && input.read() == -1, "then resume raw source");
System.out.println("replay: original-remaining=1; wrapper-remaining=3; reads=ABC; raw-never-rewound");
}
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(new byte[0]), 2)) {
input.unread('A');
input.unread('B');
String singles = text(input.readAllBytes());
check(singles.equals("BA"), "individual pushes are last-in first-out");
byte[] pair = ascii("AB");
input.unread(pair);
pair[0] = 'Z';
String array = text(input.readAllBytes());
check(array.equals("AB"), "array order is preserved and bytes were copied");
System.out.println("order: unread(A),unread(B) -> " + singles + "; unread([A,B]) -> " + array);
}
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(new byte[0]))) {
input.unread('A');
rejects(IOException.class, () -> input.unread('B'));
check(input.read() == 'A' && input.read() == -1, "overflow did not replace pending byte here");
rejects(IOException.class, () -> input.unread(ascii("AB")));
check(input.read() == -1, "oversized array not partly pushed here");
input.unread('C');
check(input.read() == 'C', "reading frees pushback capacity");
System.out.println("default-capacity=1; extra-byte=rejected; two-byte-array=rejected; capacity-reusable=true");
}
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(new byte[0]))) {
int eof = input.read();
check(eof == -1, "empty source EOF");
input.unread(eof); // Deliberately wrong: only the low eight bits are stored.
int injected = input.read();
check(injected == 255 && input.read() == -1, "unread(-1) creates FF, not EOF");
check(peek(input) == -1 && input.read() == -1, "safe peek leaves EOF alone");
System.out.println("EOF misuse: read=-1; unread(-1); next-read=" + injected + "; guarded-peek=-1");
}
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(new byte[]{(byte) 0xFF}))) {
check(peek(input) == 255 && input.read() == 255 && input.read() == -1, "FF data differs from EOF");
}
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(ascii("name=42")))) {
String token = readLetters(input);
int delimiter = input.read();
String tail = text(input.readAllBytes());
check(token.equals("name") && delimiter == '=' && tail.equals("42"), "delimiter belongs to next step");
System.out.println("letters: token=" + token + "; delimiter='" + (char) delimiter + "'; tail=" + tail);
}
for (String sample : new String[]{"", "name", "=42"}) {
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(ascii(sample)))) {
String token = readLetters(input);
check(token.equals(sample.equals("name") ? "name" : ""), "empty or all-letter prefix");
check(input.read() == (sample.equals("=42") ? '=' : -1), "delimiter or EOF preserved");
}
}
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(new byte[0]))) {
for (int value = 0; value < 256; value++) {
input.unread(value);
check(input.read() == value, "all unsigned byte values round trip");
}
check(!input.markSupported(), "pushback does not support mark/reset");
input.mark(100);
rejects(IOException.class, () -> input.reset());
}
byte[] original = ascii("ABCDEFGH");
for (int count = 0; count <= original.length; count++) {
try (PushbackInputStream input = new PushbackInputStream(new ByteArrayInputStream(original), 8)) {
byte[] prefix = input.readNBytes(count);
input.unread(prefix);
check(Arrays.equals(input.readAllBytes(), original), "prefix replay for count=" + count);
}
}
rejects(IllegalArgumentException.class, () -> new PushbackInputStream(new ByteArrayInputStream(new byte[0]), 0));
PushbackInputStream closed = new PushbackInputStream(new ByteArrayInputStream(ascii("A")));
closed.close();
rejects(IOException.class, () -> closed.read());
rejects(IOException.class, () -> closed.unread('A'));
System.out.println("byte-values=256; prefix-lengths=9; mark/reset=unsupported; closed-read/unread=rejected");
System.out.println("PASS " + checks + " checks; byte-array sources only, no file or network I/O");
}
}
怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-23 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。真实PushbackInputStream包裹ByteArrayInputStream;每JDK294检查,包含256字节值与9种前缀回推;每版重复三次一致。运行无文件/网络I/O;未实现Unicode解码、完整解析器、文件回退或并发。
运行后对照结果
replay: original-remaining=1; wrapper-remaining=3; reads=ABC; raw-never-rewound
order: unread(A),unread(B) -> BA; unread([A,B]) -> AB
default-capacity=1; extra-byte=rejected; two-byte-array=rejected; capacity-reusable=true
EOF misuse: read=-1; unread(-1); next-read=255; guarded-peek=-1
letters: token=name; delimiter='='; tail=42
byte-values=256; prefix-lengths=9; mark/reset=unsupported; closed-read/unread=rejected
PASS 294 checks; byte-array sources only, no file or network I/O
回推的是你提供的字节,不是通用撤销操作
unread 并不会检查这些字节是否刚刚从同一条流读出;本实验在空源上也能放入 A、B。它可以改变外层后续可见的字节序列,不能凭方法名假定一定是在恢复历史原文。
本文不混用底层与外层的 read;底层 available 只用作受控内存实验的观察。实际处理通常应让消费逻辑都经同一个包装器,否则直接读取底层可能绕过待重读字节,破坏调用方所依赖的顺序。
验证范围是内存字节流、有限 ASCII 前缀及公开生成的数据。没有实现 UTF-8 字符解码、完整词法分析、文件定位、网络协议或并发读取;字节回推也不会自动解决这些更大的边界。
记住这三点
- unread 将字节保存到外层缓冲,不要求底层游标后退。
- 连续单字节回推与一次数组回推的读取顺序不同,默认缓冲只有一字节。
- 先判断 read 的 -1 结束状态,再决定是否回推;真实 FF 读取为 255。
别把例子用过头
- 以为 unread 会撤销原始文件或网络读取。
- 按原顺序逐个 unread,却期待下一次仍按原顺序读回。
- 把 EOF 的 -1 无条件传给 unread,注入一个 FF 字节。
- 把底层支持 mark
/reset当成外层也支持。
自己试一小步
你要预看至多三个字节来决定下一步处理,怎样保证调用方之后读到的内容与原顺序一致?
- 先规定输入不足三字节、恰好三字节和立即结束时应返回什么状态。
- 比较一次回推数组与逐字节回推两种写法,预测下一轮完整读取顺序。
- 为容量不足、原始字节 FF、空输入和前导分隔符设计检查。
- 记录哪些字节由预看步骤读出、哪些被放回、哪些最终由调用方消费。
做到这些,就算完成
- 结束状态没有被转成数据字节。
- 容量按同时待重读数量计算。
- 结果逐字节比较,而不只比较字符串长度。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java SE 17 PushbackInputStream:回推缓冲与各重载 (新标签页)↗
回推缓冲、默认一字节容量、单字节与数组顺序、低八位转换、异常和 mark
/reset/close的约定。 - Java SE 17 InputStream.read:数据与结束状态 (新标签页)↗
read 用 0..255 表示字节、用 -1 表示结束,支持本篇 EOF 防护。
- Java SE 17 ByteArrayInputStream:内存源与剩余量 (新标签页)↗
本实验底层位置由内存计数器维护,available 返回剩余字节,构造器与关闭行为另有约定。