开始前,会这些就够了
- 知道普通类、继承、构造器、static 字段与 transient 的基本语法。
- 能读懂
ObjectOutputStream/ObjectInputStream的写入和读回,不需要了解二进制协议布局。 - 使用完整 JDK 17 或更新版本;实验只读取本程序刚生成的内存字节。
- 需要补基础时:先认识对象流与引用身份
遇到不熟悉的词,先看这 2 个解释
- transient 字段
- 普通默认序列化不会自动保存这个实例字段;恢复后的值取决于默认值和该类另写的恢复逻辑。
- 规范构造器
- record 中参数与全部组件逐一对应的构造器;记录对象恢复时也会经过它。
一个金额类在构造器里拒绝负数。有人据此认为:只要所有 new 都经过检查,从对象流读出来的金额也一定非负。这个推断漏掉了一个入口——对象恢复不一定按普通 new 的路径重新执行这个类的构造器。
我们不修改外部文件,也不构造未知输入。程序先创建合法对象,再故意改变自己持有的可变字段,把这个已知状态写入内存后读回。通过计数和字段值,你能看见到底哪一层执行了构造、哪一层直接恢复了数据。
把例子带到自己的电脑上
下载完整练习包
需要 JDK 17 或更新版本,使用 Java 标准库;制作时实测 Windows / JDK 17.0.20.1 / 21.0.12。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- SerializationConstructorsDemo.java:完整可运行示例
- README.md:运行说明与独立练习
- observed.stdout.txt:本次实际输出
- verification.json:源码和产物验证记录
- SHA256SUMS.txt:内容摘要
在这个文件夹打开 PowerShell,逐行运行
java -version
javac -version
javac -encoding UTF-8 --release 17 SerializationConstructorsDemo.java
java -cp . SerializationConstructorsDemo解压到独立练习文件夹,在含 SerializationConstructorsDemo.java 的目录打开终端运行。仅本程序生成的内存字节;普通Serializable子类/非Serializable父类构造计数、transient默认值、readObject拒绝自造非法状态、合法record恢复;JDK17/21各8项检查,未处理外部输入或协议篡改。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先把三种观察量分开
Ordinary 是实现 Serializable 的普通子类,Base 没有实现这个接口。Base 的字段初始值是七,构造器每执行一次就增加自己的静态计数;Ordinary 也有独立计数,并把 transient initialized 写为九十九。
主程序先 new Ordinary(5),随后故意把 amount 改成负五、baseValue 改成八十八。这种直接改字段只是为了制造本地反例,并不是建议的业务封装。构造器原先确实接受过合法值,但之后的对象状态已经发生变化。
观察表同时记录构造次数、可序列化子类字段、未序列化的父类字段和 transient 字段。它们分别回答不同问题,不能只看到 amount 有值,就推断所有初始化步骤都重做了一遍。
普通 Serializable 子类不会重新执行自己的构造器
读回后 Ordinary 的构造计数仍为一,amount 却是负五。该类构造器里的负数检查没有重新执行,字段值按照对象流中的记录恢复。这解释了为什么“只有构造器里校验”不能覆盖这个恢复入口。
initialized 读回为零,而不是九十九。它是 transient,因此没有通过默认序列化保存;普通 Serializable 类在恢复时又不会执行自己的字段初始化表达式,于是这里保留的是 int 的默认值。
这不表示 transient 总能保证某个具体值。类可以定义自己的恢复逻辑,在合法时重新建立派生状态;这里故意不做这一步,才让默认值与字段初始化表达式之间的差异直接可见。
第一个不可序列化父类走的是另一条路
Base 的构造计数变成二,恢复对象的 baseValue 是七。原对象中后改的八十八没有作为这个不可序列化父类的默认字段数据恢复;Base 的无参构造器及其初始化逻辑重新建立了这部分状态。
所以“反序列化从不调用构造器”同样不准确。要先分清普通 Serializable 类自身、继承链中第一个不可序列化的父类,以及记录类等特殊情形。这个实验只建立两层继承,不偷偷把复杂层次的结果也算成已经实跑。
若父类状态本来需要表达某个外部资源或业务关联,就必须明确恢复后怎样重建与验证,不能期待原实例里的一切都自动回来。这里父类只有一个整数,便于把构造路径看清。
在恢复边界检查状态,和在创建边界检查一样具体
Guarded 类提供 private readObject。它先调用 defaultReadObject 恢复字段,再检查 amount;如果负数就抛 InvalidObjectException。程序对这个类重复相同的本地负数样本,结果明确被拒绝。
检查顺序不能倒过来:尚未恢复字段就检验当前默认值,回答的不是对象流即将带回的状态。本例只验证单个整数,若规则依赖整个对象图恢复完毕,校验安排还需要根据相应 API 的时机另行设计。
这项检查只回答示例金额是否合法,不能自动代表任意输入的类型、资源消耗或来源都已被完整审查。整个项目继续只处理本进程自己产生的字节,练习也不需要引入外部二进制样本。
record 恢复时会调用规范构造器
Amount 是实现 Serializable 的 record,紧凑构造器要求 value 非负并增加静态计数。创建 Amount(5) 时计数为一,读回后变成二:记录类通过规范构造器接收恢复出的组件值。
读回的 record 与原对象按值相等,但引用不同。这里没有篡改字节或试图绕过 record 创建非法组件;实际运行证明的是合法样本的构造调用和结果值,不能把未执行的错误路径写成实验事实。
记录类的序列化也有自己的限制,例如它不会用普通类的自定义 readObject 方法代替这条构造路径。不要因为名字都叫 Serializable,就把普通类里粘贴来的恢复钩子当作 record 的等价扩展点。
保存单文件,实跑并核对每项观察
把代码保存为 SerializationConstructorsDemo.java,在这个文件所在目录执行下方命令。需要完整 JDK 17 或更新版本;只有 java 而没有 javac 的环境还不能完成这里的编译步骤。无需 Maven、外部依赖或 IDE 插件。
2026 年 10 月 17 日已使用 Microsoft OpenJDK 17.0.20.1 和 21.0.12,各自在新建临时目录中以 --release 17 编译并执行。两版输出相同,每次完成 8 项检查。下方保留实际输出,失败时不会打印 PASS。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InvalidObjectException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
/** Deserializes only byte arrays produced inside this program. */
public class SerializationConstructorsDemo {
private static int checks;
static class Base {
static int constructors;
int baseValue = 7;
protected Base() { constructors++; }
}
static final class Ordinary extends Base implements Serializable {
@Serial private static final long serialVersionUID = 1L;
static int constructors;
int amount;
transient int initialized = 99;
Ordinary(int amount) {
if (amount < 0) throw new IllegalArgumentException("negative amount");
constructors++;
this.amount = amount;
}
}
static final class Guarded implements Serializable {
@Serial private static final long serialVersionUID = 1L;
int amount;
Guarded(int amount) {
if (amount < 0) throw new IllegalArgumentException("negative amount");
this.amount = amount;
}
@Serial private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
input.defaultReadObject();
if (amount < 0) throw new InvalidObjectException("negative amount");
}
}
record Amount(int value) implements Serializable {
static int constructors;
Amount {
if (value < 0) throw new IllegalArgumentException("negative amount");
constructors++;
}
}
private static byte[] encode(Serializable value) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(bytes)) { output.writeObject(value); }
return bytes.toByteArray();
}
private static Object decodeOwnBytes(byte[] bytes) throws IOException, ClassNotFoundException {
try (ObjectInputStream input = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
return input.readObject();
}
}
private static void check(boolean condition, String message) {
if (!condition) throw new AssertionError(message);
checks++;
}
public static void main(String[] args) throws Exception {
Ordinary original = new Ordinary(5);
// Deliberately break our own mutable test object before serializing it.
original.amount = -5;
original.baseValue = 88;
Ordinary restored = (Ordinary) decodeOwnBytes(encode(original));
check(Base.constructors == 2, "non-serializable base constructor runs again");
check(Ordinary.constructors == 1, "ordinary serializable constructor is not rerun");
check(restored.amount == -5, "constructor-only validation was not applied");
check(restored.initialized == 0, "serializable field initializer was not rerun");
check(restored.baseValue == 7, "base state rebuilt rather than restored from stream");
System.out.println("ordinary: child_constructors=1; base_constructors=2; amount=-5; transient_initializer=0; base_value=7");
Guarded guarded = new Guarded(5);
guarded.amount = -5;
boolean rejected = false;
try { decodeOwnBytes(encode(guarded)); }
catch (InvalidObjectException expected) { rejected = expected.getMessage().equals("negative amount"); }
check(rejected, "readObject must validate restored fields");
System.out.println("guarded: invalid_local_state=InvalidObjectException");
Amount amount = new Amount(5);
Amount copied = (Amount) decodeOwnBytes(encode(amount));
check(copied.equals(amount) && copied != amount, "record round trip produces equal new value");
check(Amount.constructors == 2, "record canonical constructor runs on restoration");
System.out.println("record: canonical_constructors=2; value=5; same_reference=false");
System.out.println("PASS checks=" + checks + "; input=program_generated_byte_arrays_only");
}
}
怎样运行:在源码所在目录,先单独执行 javac -encoding UTF-8 --release 17 SerializationConstructorsDemo.java;编译成功后,再执行 java -cp . SerializationConstructorsDemo。两条命令分别回车。2026-10-17 已在 Windows、JDK 17.0.20.1 / 21.0.12 编译运行此份源码并通过检查。仅本程序生成的内存字节;普通Serializable子类/非Serializable父类构造计数、transient默认值、readObject拒绝自造非法状态、合法record恢复;JDK17/21各8项检查,未处理外部输入或协议篡改。
运行后对照结果
ordinary: child_constructors=1; base_constructors=2; amount=-5; transient_initializer=0; base_value=7
guarded: invalid_local_state=InvalidObjectException
record: canonical_constructors=2; value=5; same_reference=false
PASS checks=8; input=program_generated_byte_arrays_only这次研究恢复入口,和对象流是否重复取样是两件事
站内的对象流句柄实验研究同一对象反复 writeObject、flush 和 reset 后的状态及引用关系。本例每次把一个已知对象写成独立内存字节,再读取一次,因此不需要借助 reset 来解释结果。
真正需要记住的是检查覆盖哪些入口:普通构造、字段变更、对象恢复和业务执行可以是不同位置。先为每个入口列出需要保持的状态规则,再用对应样本证明它被执行,而不是只看某个类里有没有一段 if。
记住这三点
- 普通 Serializable 类自身的构造器和字段初始化表达式不会按普通 new 路径重跑。
- 第一个不可序列化父类的无参构造器与 record 规范构造器各有明确规则。
- 对普通类需要在恢复状态之后验证不变量,构造器校验不能自动覆盖所有入口。
别把例子用过头
- 把 Serializable 普通类、不可序列化父类和 record 统称为同一构造路径。
- 认为 transient 字段一定恢复为源码初始化表达式中的值。
- 在
defaultReadObject之前检查尚未恢复的字段。 - 把本地整数校验当成外部反序列化接口已经完整验收。
自己试一小步
把继承层次扩大到三层后,哪些构造器和初始化表达式会参与恢复?
- 在新的单文件副本中设计一个不可序列化根类和两个连续可序列化子类,各放一个计数器和可观察字段。
- 先画出继承关系,预测普通创建和读回各自经过哪些步骤。
- 仍只生成并读取本程序的内存字节,对字段值、构造次数与 transient 结果分别写断言。
- 再为叶子类增加一项恢复后的状态检查,设计会被拒绝的本地样本并保存真实异常。
做到这些,就算完成
- 不只打印
toString;每层的计数与字段来源有独立记录。 - 构造时合法与写出时合法被区分,没有把故意修改的状态藏起来。
- 只读自己刚生成的字节,不从文件或网络接受未知样本。
- 预测若与结果相同也要解释规则,无须故意写错预测。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Java 对象序列化规范:对象输入第 3.1 节 (新标签页)↗
核对普通对象恢复、首个不可序列化父类构造,以及记录类构造路径。
- Java 对象序列化规范:记录类第 1.13 节 (新标签页)↗
核对记录组件与规范构造器,以及被忽略的自定义恢复方法。
- Java 17:Serializable 的继承与自定义恢复 (新标签页)↗
核对非序列化父类无参构造要求与
readObject的特殊签名。