图 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 宁可抛异常也不猜——兼容性重灾区就是这么来的。
图 2:字节流结构、UID 校验与三大顽疾的全景。
为什么不推荐:三座大山。
第一座是安全。readObject 在还原对象时会执行类的自定义反序列化逻辑,攻击者只要构造一条 gadget 链(把类路径上若干类的反序列化逻辑串起来),就能在反序列化瞬间执行任意代码。OWASP 的反序列化备忘单把 ObjectInputStream 的 readObject 直接列为需要警惕的 API,防护建议是重写 resolveClass 做类白名单,只放行预期类型。第二座是兼容性:UID 失配、字段增删、类改名,都可能让历史数据报废。第三座是体积与性能:77 对 19 只是开胃菜,对象图一大,类描述的冗余和反射还原的开销更难看。
工程上的正确姿势。
维护遗留系统时:敏感字段加 transient;用自定义 writeObject/readObject 控制进出流的内容;绝不反序列化不可信来源的字节流,必要时做类白名单过滤。新系统则直接换文本或二进制协议——至于 JSON、Kryo、Protobuf 该怎么选,同批的知识延伸文专门展开,这里不重复。
回到面试现场,建议的答题结构:先讲清 writeObject/readObject 的一次往返和字节流里有什么,再分别点出 serialVersionUID(兼容校验)与 transient(字段取舍)的作用,最后把「为什么不推荐」落到安全、兼容、体积三件事上,以白名单过滤或换协议收尾。机制背出来是及格线,讲清反序列化为何危险才是加分项。你在项目里处理过 InvalidClassException 吗?当时选择改 UID 还是洗数据?欢迎聊聊。