深蓝黑背景封面:左侧纵向流水线——最上方卡片「堆中的对象 User」,字段 id=1、name="A"、password;向下青色箭头标注「序列化 writeObject()」,指向中间卡片「字节流」,内容以十六进制「AC ED 00 05 …」开头,注明魔数、版本、类描述、字段值四部分,且 serialVersionUID 随类描述写入流中;再向下紫色箭头标注「反序列化 readObject()」,指向底部卡片「还原的对象」,其中 transient 标记的 password 字段还原为 null。流水线下方两行小字:transient 字段不进字节流;类结构变化导致 UID 对不上就抛 InvalidClassException。右侧大标题「Java 序列化,为什么被弃用」,副标题「Serializable 的机制,与安全、兼容性顽疾」。左上标注「面试经典 · Java 后端」,右上圆角胶囊标注「Day 06 · 11-13」 图 1:本文封面,一次序列化往返的关键机关。

先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「Java 序列化是怎么回事,为什么不推荐用原生序列化」把三个考点捆在一起问:Serializable 机制本身、serialVersionUID 与 transient 各管什么、反序列化为什么成了安全与兼容性重灾区。逐个拆开看。

一次往返:对象怎么变成字节流,又怎么变回来。

实现 Serializable 接口(它是个空接口,没有任何方法)之后,ObjectOutputStream 的 writeObject 会把对象连同它可达的整个对象图递归写进字节流;ObjectInputStream 的 readObject 再把流读回来还原成对象。这个字节流是有格式的:开头固定是魔数 AC ED 加版本号 00 05,后面跟着类描述(类名、serialVersionUID、字段清单)和真正的字段值数据。Java 对象序列化规范写得很清楚:默认被序列化的是「非 transient 且非 static」的字段——静态字段天然不进流,transient 则是主动剔除某个实例字段的开关。

serialVersionUID 与 transient:一个管兼容,一个管取舍。

反序列化时,JVM 会拿流里记录的 serialVersionUID 和本地类的 serialVersionUID 对一遍:一致才允许还原,不一致直接抛 InvalidClassException。不显式声明的话,编译器会按类结构算一个默认值,于是你加个方法、改个字段,默认值就变,历史数据全读不回——所以实践上要求显式声明一个固定的 UID,把「版本是否兼容」的决定权握在自己手里。transient 的用途更直接:密码、密钥、会话令牌这类敏感字段不该落盘也不该上网,标成 transient 后它不进字节流,还原出来是 null。序列化规范在「保护敏感信息」一节给出的第一条建议,就是把敏感字段声明为 private transient。

代码实测:一次完整往返,加一次 UID 失配。

用 JDK 21 跑两段程序(javac SerialDemo.java && java SerialDemo)。第一段序列化一个带 transient 密码字段的 User:

import java.io.*;

public class SerialDemo {
    static class User implements Serializable {
        private static final long serialVersionUID = 1L;
        int id;
        String name;
        transient String password; // 不进入字节流

        User(int id, String name, String password) {
            this.id = id; this.name = name; this.password = password;
        }
    }

    public static void main(String[] args) throws Exception {
        User u = new User(1, "A", "s3cret");
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(u);
        }
        byte[] bytes = bos.toByteArray();
        System.out.println("字节流长度: " + bytes.length + " 字节");
        System.out.printf("前 4 字节: %02X %02X %02X %02X%n",
                bytes[0], bytes[1], bytes[2], bytes[3]);

        User back;
        try (ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytes))) {
            back = (User) ois.readObject();
        }
        System.out.println("还原: id=" + back.id + " name=" + back.name + " password=" + back.password);
        System.out.println("同内容 JSON 文本长度: "
                + "{\"id\":1,\"name\":\"A\"}".getBytes("UTF-8").length + " 字节");
    }
}

实测输出:

字节流长度: 77 字节
前 4 字节: AC ED 00 05
还原: id=1 name=A password=null
同内容 JSON 文本长度: 19 字节

四个信息点:流确实以 AC ED 00 05 开头;transient 的 password 还原为 null;一个只有 id 和 name 两个字段的小对象序列化出 77 字节,而同内容 JSON 只要 19 字节——类描述反复写进流里,体积天然臃肿。第二段程序把 serialVersionUID 从 1L 改成 2L 重新编译(模拟类升级),再读旧的字节流:

抛出 InvalidClassException: demo.User; local class incompatible:
stream classdesc serialVersionUID = 1, local class serialVersionUID = 2

UID 对不上,JVM 宁可抛异常也不猜——兼容性重灾区就是这么来的。

深蓝黑背景。顶部小字「概念图 · Java 序列化机制」,大标题「一次往返,三个关键机关」。左侧三张卡片纵向排列:第一张「对象到字节流的往返」,三个方框「对象 → 字节流 → 还原对象」横排,两段箭头分别标注 writeObject() 与 readObject();第二张「字节流里有什么」,一条分段长条依次为魔数 AC ED、版本 00 05、类描述(类名加 serialVersionUID)、字段值数据,下方注明 transient 字段不写入流;第三张「serialVersionUID 校验」,「流中的 UID」与「类中的 UID」两个方框比较,一致则正常还原,不一致则抛 InvalidClassException。右侧两张卡片:「为什么不再推荐」列出三大顽疾——安全(readObject 触发即执行代码,恶意对象链可在反序列化时执行任意逻辑)、兼容性(字段增删或类改名导致 UID 失配,历史数据读不回)、体积与性能(类描述反复写入流,臃肿且慢);「工程上的正确姿势」列出四条——敏感字段加 transient、必须用时自定义 writeObject/readObject、校验输入来源并白名单过滤类、新系统直接换 JSON/Kryo/Protobuf。底部通栏总结:Serializable 能跑,但安全、兼容、性能三座大山让新系统不该再选它 图 2:字节流结构、UID 校验与三大顽疾的全景。

为什么不推荐:三座大山。

第一座是安全。readObject 在还原对象时会执行类的自定义反序列化逻辑,攻击者只要构造一条 gadget 链(把类路径上若干类的反序列化逻辑串起来),就能在反序列化瞬间执行任意代码。OWASP 的反序列化备忘单把 ObjectInputStream 的 readObject 直接列为需要警惕的 API,防护建议是重写 resolveClass 做类白名单,只放行预期类型。第二座是兼容性:UID 失配、字段增删、类改名,都可能让历史数据报废。第三座是体积与性能:77 对 19 只是开胃菜,对象图一大,类描述的冗余和反射还原的开销更难看。

工程上的正确姿势。

维护遗留系统时:敏感字段加 transient;用自定义 writeObject/readObject 控制进出流的内容;绝不反序列化不可信来源的字节流,必要时做类白名单过滤。新系统则直接换文本或二进制协议——至于 JSON、Kryo、Protobuf 该怎么选,同批的知识延伸文专门展开,这里不重复。

回到面试现场,建议的答题结构:先讲清 writeObject/readObject 的一次往返和字节流里有什么,再分别点出 serialVersionUID(兼容校验)与 transient(字段取舍)的作用,最后把「为什么不推荐」落到安全、兼容、体积三件事上,以白名单过滤或换协议收尾。机制背出来是及格线,讲清反序列化为何危险才是加分项。你在项目里处理过 InvalidClassException 吗?当时选择改 UID 还是洗数据?欢迎聊聊。