开始前,会这些就够了
- 能读懂 Java 字节数组、输入流和 IOException。
- 知道 read 返回正数表示本次取得字节,-1 表示读到结尾。
- 完整 JDK 17 或更新版本;只使用标准库,不需要联网下载压缩样本。
导入一份压缩表格时,标题和三行内容已经全部显示出来,程序却在继续读取时报告“压缩尾部损坏”。如果内容都能显示,为什么还不能宣布导入完成?流式解压允许先交出内容,而某些检查要到后面的边界才有足够信息执行。
本篇在内存中生成一个真实 GZIP 成员,只改动尾部,再交给 JDK 的 GZIPInputStream。六组对照分别记录拿到多少字节、是否与原文完全相同、有没有读到 -1,以及读取和关闭各自的错误。
把例子带到自己的电脑上
下载完整练习包
需要 JDK 17 或更新版本,无第三方依赖;制作时实测 openjdk 17.0.20.1 2026-08-18 LTS。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- GzipTrailerBoundary.java:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
java --version
java --source 17 GzipTrailerBoundary.java解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 读取自制单成员 GZIP,核对固定损坏样本的返回字节与尾部检查;不验证任意文件、来源真实性或线上导入。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
拿到最后一个内容字节,仍可能没有完成最后一次读取
小样本原文为一行标题和三行虚构标签,共三十二个 UTF-8 字节。循环每次最多请求七个字节,把已经返回的内容加入内存缓冲。某次调用把最后几个原文字节交出来,并不意味着这次调用也一定已经确认压缩流结束。
完整读取分支继续调用 read,直到看到 -1 或遇到异常。另一个分支在累计三十二字节时主动停止。两者都可能拿到与原文完全相同的内容,但只有第一种尝试走完库提供的结束读取路径。
因此报告分开保存 equals_expected_payload 与 saw_eof。这两个布尔值回答不同问题:前者比较已返回内容,后者表示是否实际观察到结束信号。不能用“数量正好够了”替换对流结束和错误结果的检查。
尾部还带着校验值与原始长度记录
本例由 GZIPOutputStream 生成一个成员,关闭压缩输出后才取出完整字节。这个成员的最后八个字节包含 CRC32 和 ISIZE。CRC32 根据未压缩内容计算,ISIZE 记录原始长度对二的三十二次方取模后的值。
脚本复制出三种损坏样本:翻转 CRC 字段的一个位、翻转 ISIZE 字段的一个位,以及移除最后四个字节。压缩内容部分保持原样,所以可以专门观察“内容能够解出来,尾部仍不符合”的边界。
这里用“总长度减八”和“总长度减四”定位,是因为样本由我们生成且只有一个成员。它不是通用 GZIP 编辑器,也没有处理任意拼接成员、可选头字段或外部文件结构。把这种位置计算拿到别的格式前,要先重新核对布局。
三个坏样本都先返回原文,然后在继续读取时失败
有效样本累计返回三十二字节,内容比较相同,最后读到 -1,没有读取错误。CRC 错误和 ISIZE 错误也先返回相同的三十二字节,但随后抛出 ZipException。移除了长度字段的样本则在后续读取中抛出 EOFException。
看到错误并不否定前面某些 read 已经成功返回过字节。它否定的是把这份成员当作已经通过完整读取检查的输入。如果业务已经在每次收到一行时直接写入正式结果,后来的失败就会要求你处理先前产生的效果。
脚本只把返回字节暂存在 ByteArrayOutputStream,方便在异常之后继续检查本次实际返回了什么。它没有将坏样本导入数据库,也没有把这些字节发布为成功结果。故障报告中的内容相同,是观察证据,不是接收坏输入的建议。
close 释放资源,不会替你把剩余输入全部读完
对同一个 CRC 损坏样本,改为累计三十二字节就停,再调用 close。制作时读操作没有报错,关闭也没有报错,但 saw_eof 仍为 false。只读前七个字节就关闭,结果同样没有错误,内容当然也尚未收齐。
这说明在本次 JDK17 路径中,提前关闭没有触发我们希望观察的尾部检查。把读取放进 try-with-resources,可以组织资源释放;它不会自动把“只读取一部分”的业务策略改成“读取到结束并验证”。
阅读固定版本源码能解释结果:read 在解压阶段返回结束时调用 readTrailer,校对累计 CRC 和长度;close 走释放资源路径。本篇把源码解释与实跑结果对应起来,但不把所有语言的解压库都说成拥有完全相同的关闭语义。
运行完整脚本,保存六行矩阵与四份压缩样本
把下方代码保存为 GzipTrailerBoundary.java,或从练习包解压同名文件,在该目录执行命令。程序先生成有效样本,再复制和修改字节,因此不依赖互联网上可能改变的下载内容,也不读取你的私人压缩文件。
gzip-output 中会留下 report.json、matrix.csv 和四份 gz 样本。下次运行会更新同名教学文件;也可以传入另一个输出目录。程序只生成三十二字节原文的微型输入,便于逐行查看结果,不能据此估算大文件解压速度。
制作时显式使用 Microsoft OpenJDK 17.0.20.1,六组断言全部通过,JSON 中记录了版本。完整内容先返回、随后校验失败的时序取决于本例输入与读取路径;换库或换读取封装后,应重新保留自己的结果。
矩阵同时记录读取错误与关闭错误,是为了避免只保留最后一步结果。三个坏样本关闭都没有报错,但读取阶段已经失败;这个后续的正常关闭不能把前面的失败改成成功。分析一份报告时,需要保留操作顺序和各阶段结论,不能只取最后一列。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;
public class GzipTrailerBoundary {
record Row(String name, String policy, int bytesReturned, boolean expectedPayload,
boolean sawEof, String readError, String closeError) {
String json() {
return String.format(Locale.ROOT,
"{\"case\":\"%s\",\"policy\":\"%s\",\"bytes_returned\":%d,"
+ "\"equals_expected_payload\":%s,\"saw_eof\":%s,"
+ "\"read_error\":\"%s\",\"close_error\":\"%s\"}",
name, policy, bytesReturned, expectedPayload, sawEof, readError, closeError);
}
String csv() {
return String.join(",", name, policy, Integer.toString(bytesReturned),
Boolean.toString(expectedPayload), Boolean.toString(sawEof), readError, closeError);
}
}
static void require(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
}
static byte[] compress(byte[] payload) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (GZIPOutputStream gzip = new GZIPOutputStream(bytes)) { gzip.write(payload); }
return bytes.toByteArray(); // Read after close has finished the GZIP member.
}
static Row inspect(String name, byte[] encoded, byte[] payload, String policy, int limit)
throws IOException {
GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(encoded));
ByteArrayOutputStream returned = new ByteArrayOutputStream();
byte[] buffer = new byte[7];
boolean eof = false;
String readError = "none";
String closeError = "none";
try {
while (policy.equals("to_eof") || returned.size() < limit) {
int wanted = policy.equals("to_eof") ? buffer.length
: Math.min(buffer.length, limit - returned.size());
int count = gzip.read(buffer, 0, wanted);
if (count == -1) { eof = true; break; }
returned.write(buffer, 0, count);
}
} catch (IOException expected) {
readError = expected.getClass().getSimpleName();
} finally {
try { gzip.close(); }
catch (IOException failure) { closeError = failure.getClass().getSimpleName(); }
}
return new Row(name, policy, returned.size(), Arrays.equals(returned.toByteArray(), payload),
eof, readError, closeError);
}
public static void main(String[] args) throws Exception {
Path output = args.length == 0 ? Path.of("gzip-output") : Path.of(args[0]);
byte[] payload = "id,label\n1,alpha\n2,beta\n3,gamma\n".getBytes(StandardCharsets.UTF_8);
byte[] valid = compress(payload);
byte[] badCrc = valid.clone();
badCrc[badCrc.length - 8] ^= 1; // Single member: first byte of its CRC32 trailer field.
byte[] badSize = valid.clone();
badSize[badSize.length - 4] ^= 1; // First byte of its ISIZE field.
byte[] truncated = Arrays.copyOf(valid, valid.length - 4);
List<Row> rows = List.of(
inspect("valid_full", valid, payload, "to_eof", 0),
inspect("bad_crc_full", badCrc, payload, "to_eof", 0),
inspect("bad_size_full", badSize, payload, "to_eof", 0),
inspect("truncated_full", truncated, payload, "to_eof", 0),
inspect("bad_crc_expected_length", badCrc, payload, "stop_at_limit", payload.length),
inspect("bad_crc_prefix_only", badCrc, payload, "stop_at_limit", 7)
);
Row good = rows.get(0);
require(good.expectedPayload && good.sawEof && good.readError.equals("none"), "valid fixture");
for (int i = 1; i <= 3; i++) {
Row row = rows.get(i);
require(row.expectedPayload && !row.sawEof, row.name + ": payload must precede the error");
require(row.readError.equals(i == 3 ? "EOFException" : "ZipException"), row.name);
}
Row exact = rows.get(4);
require(exact.expectedPayload && !exact.sawEof && exact.readError.equals("none"), "exact stop");
Row prefix = rows.get(5);
require(prefix.bytesReturned == 7 && !prefix.expectedPayload && !prefix.sawEof
&& prefix.readError.equals("none"), "prefix stop");
require(rows.stream().allMatch(row -> row.closeError.equals("none")), "close results");
Files.createDirectories(output);
Files.write(output.resolve("valid.gz"), valid);
Files.write(output.resolve("bad-crc.gz"), badCrc);
Files.write(output.resolve("bad-size.gz"), badSize);
Files.write(output.resolve("truncated.gz"), truncated);
String version = System.getProperty("java.version");
String json = "{\n \"java_version\":\"" + version + "\",\n \"payload_bytes\":"
+ payload.length + ",\n \"fixture\":\"single_gzip_member\",\n \"rows\":[\n "
+ String.join(",\n ", rows.stream().map(Row::json).toList()) + "\n ]\n}\n";
String csv = "case,policy,bytes_returned,equals_expected_payload,saw_eof,read_error,close_error\n"
+ String.join("\n", rows.stream().map(Row::csv).toList()) + "\n";
Files.writeString(output.resolve("report.json"), json, StandardCharsets.UTF_8);
Files.writeString(output.resolve("matrix.csv"), csv, StandardCharsets.UTF_8);
System.out.println("Java " + version + "; 6 cases; payload bytes=" + payload.length);
System.out.println("full read: valid=EOF; bad CRC/size=ZipException; truncated=EOFException");
System.out.println("bad CRC: stop at expected length or prefix + close reports no error");
System.out.println("PASS; " + output);
}
}
怎样运行:java --source 17 GzipTrailerBoundary.java(在解压目录执行;需要完整 JDK 17 或更新版本,仅标准库,不要求 javac 在 PATH 中。) 2026-10-15 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 读取自制单成员 GZIP,核对固定损坏样本的返回字节与尾部检查;不验证任意文件、来源真实性或线上导入。
运行后对照结果
Java 17.0.20.1; 6 cases; payload bytes=32
full read: valid=EOF; bad CRC/size=ZipException; truncated=EOFException
bad CRC: stop at expected length or prefix + close reports no error
PASS; gzip-output先定义何时可以把暂存内容升级为正式结果
如果业务要求整份导入通过后才能生效,可以把解压读取和业务提交分开:读取阶段形成暂存结果,只有预期范围完整结束、没有相关异常、业务字段也通过校验,才进入正式提交。收到第一行或最后一行都不是自动提交信号。
这是一种流程设计建议,本脚本并未实现数据库事务、临时文件重命名或上传服务。真实系统选择哪种暂存方式,还取决于文件大小和撤销成本。不要为了验证大文件而无上限地把全部解压内容装进内存。
有些产品只需要预览前几行,提前停止可以是明确的功能。此时应把结果标为“部分预览,完整性尚未核验”,而非“导入成功”。关键是调用方知道自己选择了哪个读取边界,并让展示状态与证据一致。
CRC 能帮助发现意外损坏,不能证明来源可信
本篇损坏的是校验字段或长度字段,所以读取到相应位置后能识别不一致。CRC 适合发现许多意外差错,但它不是数字签名;可以改写内容的人也可能重新计算匹配的 CRC。通过这项检查不能证明文件来自某个可信作者。
ISIZE 也不是任意大文件的精确总长度证明,因为字段只保存取模后的值。业务若有最大展开大小,应在读取时自行计数并施加限制,不能等读完后只看尾部字段才决定是否超限。
本实验没有覆盖多成员拼接、尾随垃圾、特殊头字段或资源耗尽等情况。没有异常地读到 -1,能说明的是当前库和当前输入路径完成了所执行的检查;它不等于整个文件格式、安全策略与来源认证都已经通过。
记住这三点
- 已经返回完整原文字节,与已经读到结束并完成尾部检查,是不同的观察。
- 提前 close 负责资源释放;本次 JDK17 实验中它没有补做未到达的 GZIP 尾部检查。
- 完整导入、部分预览、意外损坏检查和来源认证,应分别定义成功条件。
别把例子用过头
- 累计到预期长度就停,并把关闭无异常视作完整校验通过。
- 边解压边产生不可撤销业务效果,却没有定义后续校验失败怎么办。
- 把 CRC 或 ISIZE 当作真实性证明、完整安全检查或大文件精确长度承诺。
自己试一小步
读取缓冲大小和停止规则改变后,你还能指出校验真正发生在哪里吗?
- 自拟至少五行新标签,其中包含中文,并在运行前计算 UTF-8 字节数。
- 把读取缓冲分别改为一字节、八字节和大于原文长度,保留三份矩阵。
- 新增一个在“还差一个原文字节”时停止的分支,先预测内容相等、EOF、读取错误和关闭错误四项结果。
- 为“只预览两行”和“正式导入整份”分别写一个成功条件,并说明暂存数据在失败后怎样处理。
做到这些,就算完成
- 字符数与 UTF-8 字节数分别记录,不能把中文字符直接按一字节计算。
- 每组都有健康样本与尾部损坏样本,错误类型来自实际运行。
- 没有通过删除断言把异常行为隐藏起来。
- 交付源码、运行版本与矩阵,并明确未实现的数据库或文件提交步骤。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 17:GZIPInputStream 的读取与关闭 (新标签页)↗
核读 read 的字节返回、-1 和异常约定,以及 close 释放资源的职责。
- OpenJDK 17.0.20:GZIPInputStream 的尾部检查 (新标签页)↗
实际读取固定标签的 read、close 与
readTrailer,核对累计 CRC、长度字段与检查时机。 - RFC 1952:GZIP 成员格式与校验字段 (新标签页)↗
核读第 2.3.1 节的 CRC32、ISIZE 及第 4 节的损坏检查建议,另核对兼容性段落。
- Java 17:GZIPOutputStream 的写入与完成 (新标签页)↗
核读构造、write 与 finish,说明压缩写入与完成的区别。