qinghuan
← 回到知识探索

后端现场 / 动手实验

对象改了又写一遍,为什么读出来还是旧值?

用真实 ObjectOutputStream 对照同对象重写、flush、reset 与新实例,区分对象身份、字段相等和独立状态快照。

展开本文目录
  1. 下载完整练习包
  2. 对象流会记住“这个对象已经写过”
  3. 最容易误会的一行:意图变了,读回值没有变
  4. flush 推进已有字节,不能要求字段重新取样
  5. reset 改变后续引用的范围,不会删除前面的数据
  6. 新对象可以保存新状态,相等对象仍可以彼此独立
  7. 运行六组矩阵,同时保存可检查的字节样本
  8. 要历史快照,就把取样边界写进设计
  9. 保持输入范围,别把实验变成任意文件读取器
  10. 记住这三点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
  • 能区分对象、引用、字段,以及 == 与 equals 的基本用法。
  • 了解字节流和 try-with-resources;不要求事先熟悉 Java 序列化协议。
  • 使用完整 JDK 17 或更新版本;本例只反序列化刚在内存中自行生成的字节。

把一张便签写进对象流,随后把状态从草稿改成已发布,再调用一次 writeObject。接收方连续读两次,却拿到两份草稿状态,而且两个变量还指向同一个对象。数据并非一定丢在缓冲区里,问题也可能是你把对象流当成了逐次拍照的工具。

本篇用一个只有字符串与整数的 Note,执行六组真实 JDK 对照。每组都记录写入时打算发送的值、实际读回的值、两次引用是否相同,以及 equals 是否成立。先看清这些列回答的问题,再决定是否需要重置或创建独立快照。

把例子带到自己的电脑上

下载完整练习包

需要 JDK 17 或更新版本,无第三方依赖;制作时实测 openjdk 17.0.20.1 2026-08-18 LTS。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • ObjectStreamBoundary.java:完整示例
  • README.md:环境、范围与独立练习
  • observed.stdout.txt:制作时的实际输出
  • verification.json:源码与产物验证记录
  • SHA256SUMS.txt:文件内容摘要

在这个文件夹打开 PowerShell,逐行运行

java --version
java --source 17 ObjectStreamBoundary.java

解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实 JDK 17 对象流的六个受控案例,仅反序列化本次自行生成的内存字节;不读取外部文件或验证线上存储。

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

对象流会记住“这个对象已经写过”

Java 对象序列化需要表达对象之间的引用关系。如果两个位置原本引用同一个对象,恢复时也需要能够保留这种共享关系。为此,流会给已经写出的对象分配可再次引用的标记,通常称为句柄。

在本例使用的普通 writeObject 路径中,再遇到已经写过的同一个对象,可以写出指向先前记录的引用,而不是再次扫描它现在的字段。句柄描述的是本条对象流内的关系,不是业务编号,也不是对象在内存里的公开地址。

所以两次方法调用不自动等于两份独立状态。你需要先确定任务是保留一张对象关系图,还是保存多个时刻的值。API 的共享引用机制本身有用,只是不能未经检查地当作每次调用都重新取样。

最容易误会的一行:意图变了,读回值没有变

每组从 Note("draft", 1) 开始。第一次写入后,把同一实例改成 published、2,再写一次。报告中的 first_intent 为 draft:1,second_intent 为 published:2,它们记录各次调用前的字段值。

same_mutated 这一行读回的两项却都是 draft:1,same_read_object 为 true。接收方的两个变量指向同一恢复对象;第二次写入没有产生所期待的新状态。健康基线 same_unchanged 同样共享对象,但两次意图原本就相同。

脚本在输出对象流关闭之后才创建输入流,并按相同顺序读两次。因此这里没有并发抢读,也没有在写完之前提取字节。把写入意图与读回事实放在不同列,能避免只打印源对象当前字段而误以为接收方已经更新。

两次读回共享引用也意味着,对其中一个变量指向的恢复对象再做修改,另一个变量可能看到同一变化。不过当前脚本没有加入读后修改,矩阵记录的是刚读回时的值;这项后续影响可以另设对照,不能冒称已经运行。

flush 推进已有字节,不能要求字段重新取样

flush_only 在修改字段后先调用 flush,再执行第二次 writeObject。实际结果仍是两次 draft:1,并且引用相同。这个对照专门排查“多刷新一次就会发送新状态”的猜测。

ObjectOutputStream 的 flush 负责把缓冲字节向底层输出推进。它没有承担清除已写对象记录的职责。字段重读与字节刷新处于不同层次,后者不能把一个指向旧对象记录的引用自动改成新的字段内容。

这里底层是 ByteArrayOutputStream,整组结束后才取出完整字节。我们没有测套接字到达时间、磁盘持久性或刷新频率带来的吞吐变化。本次对照回答的是状态是否重新进入序列化记录。

reset 改变后续引用的范围,不会删除前面的数据

reset_before_second 在修改对象后调用 reset,再写入同一实例。读回第一项为 draft:1,第二项为 published:2,两个恢复对象不同。源端仍使用同一实例,变化发生在对象流如何处理后续写入。

文档说明 reset 会放弃已写对象的状态记录,并在流中标出相应位置,使读取方在相同位置重置。前面已经写出的便签仍然存在;本例还能读回第一项,正好说明它没有清空整个字节输出。

重置作用于这条流此前积累的对象记录,不能理解成只刷新某一张便签。真实对象图若依赖跨批共享引用,插入 reset 会改变之后恢复的关系。是否使用它,应根据批次之间需要保留的关联来决定。

新对象可以保存新状态,相等对象仍可以彼此独立

new_snapshot 根据修改后的字符串和整数创建新的 Note,再写入同一条对象流。结果也是 draft:1 与 published:2,读回引用不同。这里的字段只有不可变字符串与整数,构造新实例足以表达本例希望保留的两份状态。

distinct_equal 则创建字段完全相同的两个 Note。我们特意实现按字段比较的 equals,使两者 equals 为 true;读回后它们仍然 == false、equals true。这说明对象句柄复用没有把业务值相等当成对象身份相同。

核读固定标签 OpenJDK 源码时,句柄表查找使用引用相等比较,与这组结果一致。若以后增加可变列表或共享子对象,只 new 外层 Note 不一定得到完整独立快照。那需要另行决定哪些内部对象也应复制,不能从当前两字段样本直接推断。

运行六组矩阵,同时保存可检查的字节样本

把下方源码保存为 ObjectStreamBoundary.java,在所在目录运行命令。程序每组都新建输出流,再从这一组刚生成的内存字节新建输入流,两次读回后进行断言。没有从磁盘、URL 或用户提供的数据构造输入流。

object-stream-output 会得到 report.json、matrix.csv 与六个 bin 文件。二进制文件是已经自生成的实验留档;脚本并不会把目录里的旧文件读回来。重复执行更新同名结果,输出目录可以用命令行参数替换。

2026 年 10 月 16 日重新使用 Microsoft OpenJDK 17.0.20.1 运行,六组全部通过。报告记录 encoded_bytes,但长度只帮助比较本次输入;类名、字段定义或协议处理改变后,字节数也会改变,不能把它当作业务成功条件。

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

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

小实验 · java
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Objects;

public class ObjectStreamBoundary {
    static final class Note implements Serializable {
        @Serial private static final long serialVersionUID = 1L;
        String state;
        int revision;
        Note(String state, int revision) { this.state = state; this.revision = revision; }
        String value() { return state + ":" + revision; }
        @Override public boolean equals(Object object) {
            return object instanceof Note other
                && revision == other.revision && state.equals(other.state);
        }
        @Override public int hashCode() { return Objects.hash(state, revision); }
    }

    record Row(String name, String firstIntent, String secondIntent,
               boolean sameSourceObject, String firstRead, String secondRead,
               boolean sameReadObject, boolean equalReadValues, int encodedBytes) {
        String json() {
            return String.format(Locale.ROOT,
                "{\"case\":\"%s\",\"first_intent\":\"%s\",\"second_intent\":\"%s\","
                + "\"same_source_object\":%s,\"first_read\":\"%s\",\"second_read\":\"%s\","
                + "\"same_read_object\":%s,\"equal_read_values\":%s,\"encoded_bytes\":%d}",
                name, firstIntent, secondIntent, sameSourceObject, firstRead, secondRead,
                sameReadObject, equalReadValues, encodedBytes);
        }
        String csv() {
            return String.join(",", name, firstIntent, secondIntent,
                Boolean.toString(sameSourceObject), firstRead, secondRead,
                Boolean.toString(sameReadObject), Boolean.toString(equalReadValues),
                Integer.toString(encodedBytes));
        }
    }

    static void require(boolean condition, String message) {
        if (!condition) throw new AssertionError(message);
    }
    static Row run(String mode, Path output) throws Exception {
        Note original = new Note("draft", 1);
        Note second = original;
        String firstIntent = original.value();
        String secondIntent;
        ByteArrayOutputStream bytes = new ByteArrayOutputStream();
        try (ObjectOutputStream stream = new ObjectOutputStream(bytes)) {
            stream.writeObject(original);
            if (!mode.equals("same_unchanged") && !mode.equals("distinct_equal")) {
                original.state = "published";
                original.revision = 2;
            }
            if (mode.equals("flush_only")) stream.flush();
            if (mode.equals("reset_before_second")) stream.reset();
            if (mode.equals("new_snapshot") || mode.equals("distinct_equal")) {
                second = new Note(original.state, original.revision);
            }
            secondIntent = second.value();
            stream.writeObject(second);
        }
        byte[] encoded = bytes.toByteArray();
        Note firstRead;
        Note secondRead;
        // Only deserialize bytes just generated above; no file, URL, or user-supplied input.
        try (ObjectInputStream stream = new ObjectInputStream(new ByteArrayInputStream(encoded))) {
            Object first = stream.readObject();
            Object next = stream.readObject();
            require(first instanceof Note && next instanceof Note, "unexpected local fixture types");
            firstRead = (Note) first;
            secondRead = (Note) next;
        }
        boolean sameReadObject = firstRead == secondRead;
        boolean expectsShared = mode.equals("same_unchanged") || mode.equals("same_mutated")
            || mode.equals("flush_only");
        String expectedSecond = mode.equals("reset_before_second") || mode.equals("new_snapshot")
            ? "published:2" : "draft:1";
        require(firstRead.value().equals("draft:1"), mode + ": first value");
        require(secondRead.value().equals(expectedSecond), mode + ": second value");
        require(sameReadObject == expectsShared, mode + ": object identity");
        if (mode.equals("distinct_equal")) {
            require(original != second && original.equals(second), "equal inputs must remain distinct");
            require(firstRead.equals(secondRead), "equal output values");
        }
        Files.write(output.resolve(mode + ".bin"), encoded);
        return new Row(mode, firstIntent, secondIntent, original == second,
            firstRead.value(), secondRead.value(), sameReadObject, firstRead.equals(secondRead), encoded.length);
    }

    public static void main(String[] args) throws Exception {
        Path output = args.length == 0 ? Path.of("object-stream-output") : Path.of(args[0]);
        Files.createDirectories(output);
        List<Row> rows = new ArrayList<>();
        for (String mode : List.of("same_unchanged", "same_mutated", "flush_only",
                "reset_before_second", "new_snapshot", "distinct_equal")) {
            rows.add(run(mode, output));
        }
        String version = System.getProperty("java.version");
        String json = "{\n  \"java_version\":\"" + version
            + "\",\n  \"input_scope\":\"only_fresh_self_generated_memory_bytes\",\n  \"rows\":[\n    "
            + String.join(",\n    ", rows.stream().map(Row::json).toList()) + "\n  ]\n}\n";
        String csv = "case,first_intent,second_intent,same_source_object,first_read,second_read,"
            + "same_read_object,equal_read_values,encoded_bytes\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; only self-generated memory bytes read");
        System.out.println("same mutated / flush only: draft:1 twice, same object=true");
        System.out.println("reset / new snapshot: draft:1 then published:2, same object=false");
        System.out.println("equal distinct inputs remain distinct; PASS; " + output);
    }
}

怎样运行:java --source 17 ObjectStreamBoundary.java(在源码所在目录执行;需要完整 JDK 17 或更新版本,仅标准库。) 2026-10-16 已用 openjdk 17.0.20.1 2026-08-18 LTS 执行这份代码并通过自带检查;下方是当次输出。真实 JDK 17 对象流的六个受控案例,仅反序列化本次自行生成的内存字节;不读取外部文件或验证线上存储。

运行后对照结果
Java 17.0.20.1; 6 cases; only self-generated memory bytes read
same mutated / flush only: draft:1 twice, same object=true
reset / new snapshot: draft:1 then published:2, same object=false
equal distinct inputs remain distinct; PASS; object-stream-output

要历史快照,就把取样边界写进设计

如果你的需求是保存编辑历史,先规定每次取样后哪些值必须保持不变,再选择承载它们的对象。可以用独立的数据对象表达每条记录,也可以在明确的批次边界重置流;无论哪种,都要读回后核对历史值和引用关系。

相反,如果正在保存一张需要共享引用的对象图,自动把每个引用都复制成新实例,反而可能改变图的含义。没有一种“全部 new”或“每次 reset”的做法能够替代对目标数据关系的定义。

本脚本不覆盖自定义 writeObject、writeReplace、readResolve 或完整继承层次。它也没有比较 JSON 框架和其他序列化协议。结论应落在已使用的 JDK 类、普通 Note 和六种实际操作顺序上。

保持输入范围,别把实验变成任意文件读取器

ObjectInputStream 官方文档明确提醒,反序列化不可信数据具有风险。这个实验的输入范围因此写得很具体:只读取同一进程刚刚自行编码的内存字节;instanceof 检查用于确认实验结果类型,并不构成外部输入的完整安全校验。

把示例改成上传任意 bin 再 readObject,会引入本篇没有验证的输入边界。继续练习时保持自生成样本即可。生产系统还要单独审视允许的类型、资源限制和数据来源,不能把这六行 PASS 当成对外反序列化接口已经验收。

记住这三点

  • 同一条对象流可能用句柄再次引用已写对象;修改字段不保证下一次 writeObject 记录新状态。
  • flush、reset 与创建新实例改变的是不同环节;应分别验证值和引用关系。
  • equals 相等不代表对象身份相同;简单外层复制也不保证复杂对象图成为独立快照。

别把例子用过头

  • 只打印源对象修改后的值,没有检查接收方实际读回什么。
  • 把 flush 当成重置对象记录,或把 reset 当成清空全部输出字节。
  • 把本地自生成样本实验改成任意外部二进制反序列化,再沿用原来的验证结论。

自己试一小步

如果便签里再放入一个可变标签列表,只新建外层对象还能保存独立历史吗?

  1. 为 Note 增加一个可变列表字段,设计两个外层实例共用该列表的样本;所有数据仍在同一进程生成。
  2. 先预测三种操作:只新建外层对象、连同列表建立副本、在第二次写入前 reset。分别预测外层与列表的引用关系。
  3. 实际写入和读回后,同时记录字段内容、外层 ==、列表 == 与 equals,保留各组矩阵。
  4. 根据你的历史记录需求说明哪些共享应保留、哪些必须切开,并写出一条能发现错误快照的断言。

做到这些,就算完成

  • 对列表的修改发生在哪两次写入之间有明确记录,不能用两个原本独立的列表替代共享样本。
  • 值相等与引用相同分列验收,没有只凭 toString 判断。
  • 提交自拟样本、预测、源码、运行版本及实际结果,不读取外部未知文件。
  • 不把练习中的一种复制策略宣称为任意对象图的通用深拷贝。

查原始资料

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