qinghuan
← 回到知识探索

后端现场 / 可下载小项目

Inflater 已经解压完成,输入末尾为什么还留着 NEXT?

把一个 zlib 数据段与下一段文字放在同一缓冲区,核对 finished、needsInput 与剩余字节,避免把“这段完成”误当成“整块消耗完”。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 一块输入,可以装着不止一个逻辑段
  4. finished 和 needsInput 不是互为相反数
  5. 让 ByteBuffer 的位置告诉下一步从哪里接手
  6. 看到了完整 HELLO,也不能跳过结束状态
  7. 每次拿到几个字节,都要继续查看状态
  8. 运行四个状态对照和十五种尾部组合
  9. 这一段结束后,外层协议继续决定接下来读什么
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
压缩段完成
解压器已经走到当前这一段压缩格式的结尾。
剩余输入
已经交给解压器、但还没有被它消费的那部分字节。
打开完整术语小抄 ↗

一块输入里先放压缩后的 HELLO,再接上普通文字 NEXT。Inflater 已经返回 finished=true,可 needsInput=false,getRemaining() 还显示四个字节。究竟哪个状态才代表读完?

这三个状态分别回答不同问题:当前压缩段是否结束、当前交给它的输入是否耗尽、还有多少输入没消费。本文固定一个 zlib 段,用真实 JDK 输出把这几条边界拆开。

把例子带到自己的电脑上

下载完整练习包

需要 完整 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 的目录打开终端。命令分别回车执行。真实Deflater/Inflater,默认zlib包装;每JDK53检查,每版复跑3次一致。仅内存输入,未测raw DEFLATE/GZIP/ZIP、预置字典、恶意输入或网络。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

正文出现、压缩段结束、输入用完,是三个观察项

正在准备实验控件…

解压正文
HELLO
finished
true
needsInput
false
getRemaining
4

留给下一步的文字:NEXT

压缩段已经结束,NEXT 留给外层读取。当前缓冲区仍有输入,不代表当前压缩段还没结束。

四组结果均来自本页 Java 程序在 JDK 17/21 的实际输出;浏览器不执行解压。固定 HELLO 与 NEXT、默认 zlib 包装,没有覆盖任意压缩内容、预置字典或网络读取。

一块输入,可以装着不止一个逻辑段

本例先用默认 Deflater 把 ASCII 文本 HELLO 编成 zlib 格式,再把四个普通字节 NEXT 接到后面。NEXT 是我们在实验里约定的后续字段,不属于前一个 zlib 数据段,也没有再次压缩。

RFC 1950 的格式说明把 ADLER32 校验值之后的数据排除在当前 zlib 段之外。因此,一次把整块数组交给 Inflater,不意味着它应当吞掉这块数组里的所有内容。外层协议仍需要安排下一段由谁读取。

finished 和 needsInput 不是互为相反数

finished=true 表示已到达当前压缩数据段的结尾;needsInput=true 只表示当前输入区没有剩余字节。exact 控制组恰好只给一段完整压缩数据,两者可以同时为 true。

with-tail 控制组还附带 NEXT。解压段完成后 finished=true,但输入尚余四字节,所以 needsInput=false。不能写成“只要还不需要输入,就继续把数据当作当前压缩段处理”。

header-only 只送入两个头字节,得到 finished=false、needsInput=true。此时需要的是更多输入;如果外层已经确认真正结束,就应报告这段数据不完整,而不是把“输入区暂时空了”写成成功。

让 ByteBuffer 的位置告诉下一步从哪里接手

给 setInput(ByteBuffer) 传入缓冲区后,inflate 会随着消费推进它的 position。实验结束时,位置恰好停在压缩段之后,remaining 为 4;从当前位置取出的四字节正是 NEXT。

程序在调用 end 之前保存并检查这些状态,然后把后续字节交给外层逻辑。若使用 byte[] 重载,要结合本次传入区间的终点与 getRemaining 计算尾部起点;不能无视原始 offset,始终从数组的总长度倒着推。

这个演示只说明后续字节被保留,没有定义 NEXT 的真实协议含义。完整通信协议还需要明确长度、字段类型和错误处理规则。

看到了完整 HELLO,也不能跳过结束状态

另一个控制组拿掉压缩数据最后两个字节。解压器仍产出完整 HELLO,但 finished=false,needsInput=true。这个特定截断发生在末尾部分,展示的是“内容已出现,数据段尚未完整结束”。

Inflater 本身没有这里使用的“再也不会有输入”参数。是否还会到来更多字节,要由调用方根据文件结束、消息长度或上层协议判断。若上层已结束,而 finished 仍为 false,业务就不能把暂时拿到的正文作为完整成功结果。

每次拿到几个字节,都要继续查看状态

inflate 返回本次写入输出区的字节数。实验分别用容量 1、2、8 的输出数组反复取结果,再组合成 HELLO,证明不能把一次调用当成必定产生全部内容。

drain 在 finished、needsInput 或 needsDictionary 出现时停下,供调用方区分下一步。若没产出字节且没有任何上述状态,教学代码直接报错,避免静默空转;固定样本还设有 1000 次迭代上限。

这里没有预置字典样本,所以没有宣称字典恢复已验证;DataFormatException 也直接交由 main 报错。生产输入还需要明确输出大小预算,本实验的循环保护不能替代完整的资源限制。

运行四个状态对照和十五种尾部组合

Main.java 只使用标准库,数据全部来自内存;Deflater 与 Inflater 都在 finally 中调用 end 释放资源。完整压缩输出由循环收集,代码不假设它的长度或压缩字节排列在所有运行时都相同。

2026-10-24,在 Windows、Microsoft OpenJDK 17.0.20.1 与 21.0.12 分别以 --release 17 编译,每版运行三次;六份 stdout 统一换行后相同,每次通过 53 项检查。矩阵包含三种输出容量与零到四个尾字节。

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

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

小实验 · java
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.zip.Deflater;
import java.util.zip.Inflater;

public class Main {
    private static int checks;
    private static void check(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
        checks++;
    }
    private static byte[] compress(byte[] input) {
        Deflater encoder = new Deflater();
        try {
            encoder.setInput(input);
            encoder.finish();
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            byte[] block = new byte[16];
            while (!encoder.finished()) {
                int count = encoder.deflate(block);
                if (count == 0 && !encoder.finished()) throw new AssertionError("encoder stalled");
                out.write(block, 0, count);
            }
            return out.toByteArray();
        } finally { encoder.end(); }
    }
    private static byte[] drain(Inflater decoder, int blockSize) throws Exception {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        byte[] block = new byte[blockSize];
        for (int guard = 0; guard < 1000; guard++) {
            int count = decoder.inflate(block);
            out.write(block, 0, count);
            if (decoder.finished() || decoder.needsInput() || decoder.needsDictionary()) return out.toByteArray();
            if (count == 0) throw new AssertionError("decoder made no progress");
        }
        throw new AssertionError("fixture iteration limit");
    }
    private static byte[] append(byte[] first, byte[] second) {
        byte[] joined = Arrays.copyOf(first, first.length + second.length);
        System.arraycopy(second, 0, joined, first.length, second.length);
        return joined;
    }
    public static void main(String[] args) throws Exception {
        byte[] payload = "HELLO".getBytes(StandardCharsets.US_ASCII);
        byte[] encoded = compress(payload);
        byte[] tail = "NEXT".getBytes(StandardCharsets.US_ASCII);
        Inflater decoder = new Inflater();
        try {
            decoder.setInput(encoded, 0, 2);
            byte[] first = drain(decoder, 8);
            check(first.length == 0 && !decoder.finished() && decoder.needsInput(), "header alone is incomplete");
            System.out.println("header-only: output=" + first.length + " finished=" + decoder.finished()
                    + " needsInput=" + decoder.needsInput() + " remaining=" + decoder.getRemaining());
            decoder.setInput(encoded, 2, encoded.length - 2);
            check(Arrays.equals(drain(decoder, 1), payload) && decoder.finished(), "feed remaining input");
            check(decoder.getRemaining() == 0 && decoder.needsInput(), "complete input exactly consumed");
            System.out.println("exact: output=HELLO finished=" + decoder.finished()
                    + " needsInput=" + decoder.needsInput() + " remaining=" + decoder.getRemaining());

            decoder.reset();
            decoder.setInput(Arrays.copyOf(encoded, encoded.length - 2));
            byte[] truncated = drain(decoder, 8);
            check(Arrays.equals(truncated, payload) && !decoder.finished() && decoder.needsInput(), "payload is not stream completion");
            System.out.println("truncated: output=HELLO finished=" + decoder.finished()
                    + " needsInput=" + decoder.needsInput() + " remaining=" + decoder.getRemaining());

            decoder.reset();
            ByteBuffer framed = ByteBuffer.wrap(append(encoded, tail));
            decoder.setInput(framed);
            check(Arrays.equals(drain(decoder, 2), payload) && decoder.finished(), "first stream finished");
            check(decoder.getRemaining() == 4 && !decoder.needsInput(), "tail remains");
            check(framed.position() == encoded.length && framed.remaining() == 4, "byte buffer cursor identifies tail");
            byte[] recoveredTail = new byte[framed.remaining()];
            framed.get(recoveredTail);
            check(Arrays.equals(recoveredTail, tail), "next protocol field preserved");
            System.out.println("with-tail: output=HELLO finished=true needsInput=false remaining=4 tail="
                    + new String(recoveredTail, StandardCharsets.US_ASCII));
        } finally { decoder.end(); }

        for (int blockSize : new int[]{1, 2, 8}) for (int tailLength = 0; tailLength <= 4; tailLength++) {
            Inflater current = new Inflater();
            try {
                byte[] suffix = Arrays.copyOf(tail, tailLength);
                current.setInput(append(encoded, suffix));
                check(Arrays.equals(drain(current, blockSize), payload), "output block size does not lose bytes");
                check(current.finished() && current.getRemaining() == tailLength, "tail length preserved");
                check(current.needsInput() == (tailLength == 0), "needsInput is about available bytes");
            } finally { current.end(); }
        }
        System.out.println("matrix: 3 output sizes x 5 tail lengths checked");
        System.out.println("PASS " + checks + " checks; one zlib stream, no files or network I/O");
    }
}

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-24 使用 完整 JDK 17 或更新版本;实测 Windows / JDK 17.0.20.1 / 21.0.12 复跑这份源码并核对完整输出。真实Deflater/Inflater,默认zlib包装;每JDK53检查,每版复跑3次一致。仅内存输入,未测raw DEFLATE/GZIP/ZIP、预置字典、恶意输入或网络。

运行后对照结果
header-only: output=0 finished=false needsInput=true remaining=0
exact: output=HELLO finished=true needsInput=true remaining=0
truncated: output=HELLO finished=false needsInput=true remaining=0
with-tail: output=HELLO finished=true needsInput=false remaining=4 tail=NEXT
matrix: 3 output sizes x 5 tail lengths checked
PASS 53 checks; one zlib stream, no files or network I/O

这一段结束后,外层协议继续决定接下来读什么

保存输出、判断压缩段是否完整、保留后续输入,是三件独立的工作。如果协议只允许一段压缩内容,尾部数据也许应被拒绝;如果后面还有合法字段,就应按外层格式继续解析。这是调用方的约定,不由 Inflater 自动决定。

本例仅用默认 zlib 包装和固定 ASCII 内容,没有测试 raw DEFLATE、GZIP 多成员、ZIP 容器、预置字典、网络分包或恶意压缩输入。不要把这份小实验当成通用解压服务。

记住这三点

  • finished 关心当前压缩段结尾,needsInput 关心当前输入区是否为空。
  • getRemaining 与 ByteBuffer.position 可帮助保留压缩段后的数据。
  • 完整正文已出现,仍需检查压缩段真正结束及外层输入边界。

别把例子用过头

  • 把 needsInput=true 直接当作解压完成。
  • 完成一个压缩段后丢掉缓冲区里的后续字段。
  • 把 inflate 返回零或本次有输出当成唯一结束条件。
  • 只调用一次 deflate 或 inflate 就假设收齐所有结果。

自己试一小步

如果压缩段后面还有一个六字节字段,你怎样既确认前一段完整,又保证下一段不被吞掉?

  1. 把 NEXT 换成另一个固定字段,先写下预期剩余字节数。
  2. 分别用 byte[] 和 ByteBuffer 传入,记录初始范围、已消费量和后续字段起点。
  3. 改变输出区容量,比较最终正文、finished 与剩余尾部是否一致。
  4. 另做“删去尾部压缩字节”与“合法压缩段后增加字段”两组,区分截断与额外输入。

做到这些,就算完成

  • 仅在外层确认输入结束且压缩段完整时报告成功。
  • 后续字段逐字节一致,不凭字符串看起来相似判断。
  • 练习不附答案,报告中写清所用格式与尚未验证的输入。

查原始资料

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