qinghuan
← 回到知识探索

后端现场 / 可下载小项目

把字节放回去以后,为什么原始流的位置没有倒退?

用内存字节流观察回推缓冲,分清单字节与数组的顺序、容量上限,以及把 -1 当字节塞回去的后果。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 外层多了待重读字节,底层仍停在原来的位置
  4. 连续放回单个字节,会先读到后放的那个
  5. 默认只容纳一个字节,它也不是 mark/reset
  6. -1 是结束状态;unread(-1) 却会放入真正的 FF
  7. 读出一段字母,把分隔符留给下一步
  8. 运行 294 项检查,保留完整结果
  9. 回推的是你提供的字节,不是通用撤销操作
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 知道 InputStream.read 返回 int,普通字节与结束标记需要区分
  • 理解包装器可以在原始输入流外保存自己的状态
  • 先读 available() 等于零,为什么后面还能读到数据?:先区分此刻可读量与真正结束,再理解回推缓冲增加了哪些可读字节。
遇到不熟悉的词,先看这 2 个解释
回推缓冲
包装器自己保存一段字节,让下一次读取先拿到这段内容。
结束标记
read用-1表示没有后续字节,用0到255表示实际字节值。
打开完整术语小抄 ↗

为了多看一个字节,再留给下一步处理,你可能想到 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 增加的是回推缓冲,原始流的位置没有退回

正在准备实验控件…

原始字节: "ABC"
步骤 1/8
调用: start
当前 read 返回值
这一步没有 read 返回值
本次操作异常
无
底层已消耗字节 / 原始长度
0 / 3
底层还剩字节数
3
待回推数量 / 缓冲容量
0 / 2
本案例外层 available()
3

优先读取的回推内容:空。数字依次表示十进制 / 十六进制 / 可打印 ASCII 字符,箭头为读取顺序。

底层尚未读取的原始内容:"ABC"。底层位置不会因 unread 倒退。

回推内容由外层缓冲优先交付。重读 A、B 时,底层已经消耗的字节数仍为 2;随后才继续读取底层的 C。

这些内部状态怎样核对?

生成器真正执行 Java API;只读诊断子类复制受保护的 buf[pos..end) 来记录待回推内容,不修改缓冲。底层使用 ByteArrayInputStream,因此这里的 available() 恰好等于剩余字节数;不要把它当作任意文件或网络流的总剩余长度。

固定轨迹的全部记录,当前步骤标记在第一列
步骤调用read 返回底层剩余待回推
1(当前)start—3[]
2read()652[]
3read()661[]
4unread([65,66])—1[65,66]
5read()651[66]
6read()661[]
7read()670[]
8read()-1 EOF0[]

固定 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 异常都具备业务事务回滚。

展开收起代码 · 小实验java · 140 行

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

小实验 · java
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 当成外层也支持。

自己试一小步

你要预看至多三个字节来决定下一步处理,怎样保证调用方之后读到的内容与原顺序一致?

  1. 先规定输入不足三字节、恰好三字节和立即结束时应返回什么状态。
  2. 比较一次回推数组与逐字节回推两种写法,预测下一轮完整读取顺序。
  3. 为容量不足、原始字节 FF、空输入和前导分隔符设计检查。
  4. 记录哪些字节由预看步骤读出、哪些被放回、哪些最终由调用方消费。

做到这些,就算完成

  • 结束状态没有被转成数据字节。
  • 容量按同时待重读数量计算。
  • 结果逐字节比较,而不只比较字符串长度。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。