覆盖考点:014|Java 序列化:serialVersionUID、兼容性、安全风险与替代协议
适合读者:高级 Java 工程师、技术专家、架构师候选人
预计阅读时长:38–40 分钟(文末问答不计入)
分布式系统里,对象经常要"离开内存":一次 RPC 调用要把参数对象传到另一台机器;本地缓存要把对象落到 Redis 或磁盘;集群做 Session 复制,要把登录状态同步到另一个节点;消息队列要把一个 DTO 对象送进 Kafka。这些场景表面上互不相关,但底层都要回答同一个问题:内存里那个由字段、引用、类型信息拼成的对象,怎么变成一串能被存储和传输的字节,之后又怎么从这串字节里原样"长"出一个对象。这正是序列化要解决的问题。
面试官问到这个考点,追问链条通常是:序列化底层做了什么 → serialVersionUID 是干什么用的、不写会怎样 → 类改了字段之后,老数据还能不能读出来 → 反序列化为什么会被列为安全风险、历史上出过什么事故 → 除了 Java 原生方案,工程上还有哪些替代协议、怎么选。本文按这条链展开。
一、序列化解决什么问题:从"对象"到"可传输的字节"
1. 为什么需要序列化
一个 Java 对象在内存里的真实形态,是堆上一块区域,里面装着字段的值,其中引用类型的字段存的是指向别处内存的地址。这种形态离不开当前这个 JVM 进程——地址只在这个进程的地址空间里有意义,换一台机器、甚至只是重启一次这个进程,原来的地址就全部失效。可是分布式系统恰恰需要经常做的一件事,就是把对象从一个地方"挪"到另一个地方:
- 网络传输:RPC 调用把方法参数对象序列化后通过网络发给另一台机器,对方反序列化还原出同样的对象再执行方法。
- 持久化存储:把对象写入文件或数据库的二进制字段,下次启动时再读出来还原,状态不会因为进程重启而丢失。
- 跨进程/跨节点共享:本地缓存把对象存进 Redis;集群做会话复制,把一个节点上的登录态同步给另一个节点。
- 深拷贝:把对象写成字节再读回来,能得到一份和原对象内存完全独立的新对象(上一篇讲深拷贝时提到的序列化方案,正是这里的机制)。
这些场景的共同前提是:对象需要被转换成一种不依赖具体内存地址、可以脱离当前进程存在的表示形式——一串字节。序列化(Serialization)就是"对象 → 字节"的过程,反序列化(Deserialization)是反过来"字节 → 对象"的过程。
图1:序列化的本质——内存里的对象图,变成一串可以传输和落盘的字节

2. Serializable:一个只做标记的接口
Java 给这件事准备的原生方案,核心是一个几乎不含任何方法的接口:
public interface Serializable {
}
Serializable 是一个标记接口(marker interface),没有声明任何方法,实现它本身不需要写一行业务逻辑。它唯一的作用,是告诉 JVM"这个类允许被 ObjectOutputStream 序列化"——如果一个类没有实现这个接口,却被传进 writeObject,会直接抛出 NotSerializableException。真正干活的是 ObjectOutputStream(写)和 ObjectInputStream(读)这一对流:
public class UserSession implements Serializable {
private String userId;
private long loginTime;
private List<String> roles;
// 省略构造器、getter/setter
}
// 序列化:对象 -> 字节
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("session.bin"))) {
oos.writeObject(session);
}
// 反序列化:字节 -> 对象
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("session.bin"))) {
UserSession restored = (UserSession) ois.readObject();
}
writeObject 调用之后,session.bin 里存的不只是字段的值,还包括类名、类的描述信息(字段名、字段类型)以及后面要重点讨论的 serialVersionUID。这些额外的"元信息"正是 Java 原生序列化和后面要对比的 JSON、Protobuf 等方案在体积和用途上的关键差异点,第七节会展开讲。
二、机制细节:writeObject 到底写了什么
1. 默认序列化:反射遍历字段
如果类里没有额外写任何序列化相关的方法,writeObject 的默认行为是:通过反射拿到这个类(以及它所有父类)声明的字段,逐个判断——static 字段属于类而不属于对象,不参与序列化;transient 修饰的字段被显式排除;剩下的字段,按值写入字节流。如果某个字段本身是引用类型(比如上面例子里的 List<String> roles),会递归地对它引用的对象也执行同样的过程,直到递归到基本类型或者字符串这类"终点"为止——这也是为什么一个对象要能被完整序列化,它内部所有引用到的类,都必须实现 Serializable,否则递归过程走到那个类时会直接抛异常。
ObjectOutputStream 内部还维护了一张"已写入对象"的引用表:同一个对象实例在一次 writeObject 调用里只会被真正写入一次,后续再遇到对它的引用,写入的是一个指向已有条目的标记,而不是重复写一份数据。这个设计带来一个直接的好处:即使对象图里存在循环引用(A 引用 B、B 又引用回 A),原生序列化也不会陷入无限递归——查表发现 A 已经写过,直接引用那个标记即可。这一点会在第五节讨论 Kryo 等替代方案时再对比一次。
2. transient:显式排除的字段
有些字段天生不适合被序列化:一个数据库连接、一个线程锁对象、一份运行时才能重新计算出来的缓存值。这些字段要么本身不可序列化(会导致整个对象序列化失败),要么序列化后也没有意义(连接对象换个进程根本不能用)。transient 关键字就是用来标记"这个字段不参与序列化":
public class UserSession implements Serializable {
private String userId;
private transient Connection dbConnection; // 不会被写入字节流
}
反序列化出来的新对象里,dbConnection 字段会是该类型的默认值——对象引用是 null,基本类型是 0/false。如果业务需要这个字段在反序列化后被重新初始化,就要用到下面这一节的自定义 readObject。
3. 自定义 writeObject / readObject:在默认行为上打补丁
类可以声明两个签名固定的私有方法,JVM 会在序列化和反序列化时优先调用它们,而不是走纯反射的默认流程:
public class UserSession implements Serializable {
private String userId;
private transient Connection dbConnection;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // 先按默认规则写完常规字段
// 这里可以追加自定义逻辑,比如对敏感字段做加密后再写
}
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 先按默认规则读回常规字段
this.dbConnection = DataSourceHolder.getConnection(); // 手动重建 transient 字段
}
}
defaultWriteObject() / defaultReadObject() 负责处理常规字段(非 transient、非 static),调用之后,方法体里可以插入任意自定义逻辑——常见用途包括:重新初始化 transient 字段、对某些字段做额外的加解密、在版本升级时把旧格式的数据转换成新格式。这一对方法必须声明为 private,JVM 通过反射调用它们,访问修饰符不影响调用,但 private 能防止子类意外覆盖或调用它们,破坏序列化契约。
图2:writeObject / readObject 的默认流程与自定义补丁的插入点

4. Externalizable:完全接管序列化格式
如果默认序列化的性能和格式都不满意,还有一层更彻底的控制方式——实现 Externalizable 接口,完全自己决定写入和读取的每一个字节:
public class UserSession implements Externalizable {
private String userId;
private long loginTime;
public UserSession() {} // Externalizable 要求必须有public无参构造器
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeUTF(userId);
out.writeLong(loginTime);
}
@Override
public void readExternal(ObjectInput in) throws IOException {
userId = in.readUTF();
loginTime = in.readLong();
}
}
Serializable 的默认反序列化不会调用类的构造器——JVM 通过反射直接在堆上分配内存、再逐个字段赋值,跳过了构造逻辑(这也是安全问题的根源之一,第五节会讲)。Externalizable 则不同:反序列化时会先调用这个类的public 无参构造器,再调用 readExternal 把数据填进去,所以它强制要求这个构造器必须存在且可访问。作为交换,Externalizable 省掉了类描述信息(字段名、字段类型等元数据)带来的体积开销,格式完全由开发者决定,通常比默认序列化更紧凑、更快,但也意味着完全的兼容性责任落在开发者自己身上——版本升级怎么兼容旧数据,不再有 JDK 帮忙。
5. 反序列化会绕过单例:readResolve
单例模式的核心保证是"整个 JVM 里只有一个实例",但如果这个单例类实现了 Serializable,这个保证会被反序列化悄悄打破——因为默认反序列化不会调用任何构造器(连私有构造器也绕过),而是直接在堆上分配一块新内存来生成新对象,每反序列化一次,就会得到一个和原单例完全不同、但字段值相同的新实例:
public class ConfigCenter implements Serializable {
private static final long serialVersionUID = 1L;
private static final ConfigCenter INSTANCE = new ConfigCenter();
private ConfigCenter() {}
public static ConfigCenter getInstance() { return INSTANCE; }
}
对这个类的实例执行一次"写入字节流再读出来",得到的对象和 ConfigCenter.getInstance() 返回的对象,== 比较会是 false——两个内存地址完全不同的实例,只是字段值恰好一样。修复方式是在类里补一个 readResolve 方法,反序列化流程在生成新对象之后,会检查类里有没有这个方法,如果有就用它的返回值替换掉刚生成的那个实例:
private Object readResolve() {
return INSTANCE; // 用已有的单例替换掉反序列化出来的新对象
}
这个方法名和签名都是固定的(无参、返回 Object),JVM 通过反射按约定识别并调用它,不需要实现任何接口来声明——它和前面提到的 writeObject/readObject 属于同一类"约定优于配置"的钩子方法。
三、serialVersionUID:版本号是干什么的
1. 反序列化时,JVM 靠什么判断"是不是同一个类"
字节流里的数据要还原成对象,JVM 必须先确认:当前 classpath 里加载到的这个类,和当年写这份字节流时的那个类,是不是"同一个版本"。这个判断不是靠类名,而是靠一个 long 类型的字段——serialVersionUID。序列化时,这个值会被一并写进字节流;反序列化时,JVM 会读出流里的 serialVersionUID,和当前 classpath 里这个类实际持有的 serialVersionUID 做比较,两者不一致就直接抛出 InvalidClassException,拒绝还原这个对象——哪怕字段值本身完全能对应上。
图3:serialVersionUID 校验的完整时序——先比版本号,再决定放不放行

2. 不显式声明会怎样:JVM 按类结构自动计算
如果类里没有显式写这个字段,JVM 不会跳过校验,而是在类加载时按照一套固定算法(基于类名、实现的接口、所有 public/protected 成员的签名等信息算出一个 SHA-1 摘要,再截取成一个 long)自动生成一个值。这个自动计算的过程有两个实际后果:
- 开销:这个计算涉及反射遍历类的成员并生成摘要,比直接读一个静态常量慢,虽然只在类加载时算一次,但在类多、加载频繁的场景下仍是一笔额外成本。
- 脆弱:计算规则覆盖的范围很广,包括方法签名、接口列表等和字段值毫无关系的细节。这意味着哪怕只是新增了一个普通方法、调整了访问修饰符,都可能让自动计算出的
serialVersionUID发生变化——即使真正被序列化的字段一个都没改。一旦变化,老版本序列化出来的数据,在新版本的类面前就会被判定为"不是同一个类",直接抛InvalidClassException,即便这次改动本来是完全兼容的。
3. 显式声明的意义:把"版本判断"的控制权拿回自己手里
解决办法是在类里显式声明这个字段:
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private long loginTime;
}
显式声明之后,这个值就完全由开发者控制,不再随代码里那些和序列化无关的改动(加方法、改访问修饰符)而漂移。它的实际含义变成了一种人为约定:"只要这个值不变,我保证这个类当前的版本能兼容处理带这个 UID 的历史数据"。这也是为什么几乎所有代码规范都建议:只要一个类实现了 Serializable,就应该显式声明 serialVersionUID,哪怕值是随手写的 1L——重点不是这个数字本身有什么含义,而是避免让 JVM 的自动计算规则来决定兼容性判断的时机。IDE 和 serialver 命令行工具都能基于类的当前结构算出一个初始值,但之后要不要改变它,就要交给下一节的兼容性规则来判断。
四、兼容性:类改了之后,老数据还能不能读出来
1. 兼容的改动
假设 serialVersionUID 保持不变(这是讨论兼容性的前提——如果连这个值都变了,JVM 在校验的第一步就会直接拒绝,后面的字段规则根本不会被走到)。在这个前提下,Java 序列化规范定义了一批"结构改了但不会报错"的改动:
- 新增字段:反序列化老数据时,新增字段在流里根本没有对应的值,会被填充为该类型的默认值(对象引用是
null,基本类型是0/false)。 - 新增方法、新增类:反序列化过程不关心方法签名和类的新增,这些改动不影响老数据的还原。
- 把字段的访问修饰符从
**private**改成**public**(或反过来):默认序列化通过反射直接读写字段,不经过 getter/setter,访问修饰符的变化不影响这个过程。 - 把某个字段新增
**writeObject**/**readObject**自定义逻辑:只要defaultWriteObject/defaultReadObject仍然覆盖了原有字段集合,额外插入的自定义逻辑不影响老版本数据的读取。
直观感受一下"新增字段"为什么不会出问题:
// V1:序列化时的类
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
}
// V2:serialVersionUID 保持不变,只新增一个字段
public class UserSession implements Serializable {
private static final long serialVersionUID = 1L;
private String userId;
private String deviceId; // 新增字段
}
用 V1 序列化出来的字节流,交给 V2 的类去反序列化,userId 会被正确还原,deviceId 因为流里根本没有对应的数据,会被填成默认值 null——JDK 不要求流里的数据和当前类的字段完全对齐,缺失的部分直接补默认值,不会因为"类比流多了一个字段"就判定整个对象作废。这正是这条规则被划入"兼容改动"的直接原因。
2. 看起来"兼容"却暗藏风险的改动
删除字段是一个容易被误判的情况:把某个字段从类里删掉,反序列化老数据时不会抛异常——流里那部分数据会被直接跳过、丢弃,程序照常运行。这确实不会报错,但本质上是一次静默的数据丢失:如果被删除的字段其实还有业务上的读取需求(比如另一个模块通过反射读取过它),这个字段的值就这样悄无声息地消失了,且没有任何异常提示能定位到这次改动。这也是为什么"兼容性"要分两个层次看:一层是"会不会抛异常",另一层是"语义上是否还正确"——原生序列化规范只保证第一层,第二层要靠开发者自己审查每一次字段改动。
3. 不兼容的改动
以下改动即使 serialVersionUID 显式声明且保持不变,也会导致反序列化失败(InvalidClassException 或 ClassCastException):
- 修改字段的类型:流里存的是按老类型编码的数据,新类型读取时格式对不上,直接失败。
- 把一个字段从非
**static**/**transient**改成**static**或**transient**:这等价于"删除"这个字段又"新增"了一个不参与序列化的同名字段,流里的历史数据会被当作多余数据丢弃。 - 改变类的继承关系,导致父类中原本可序列化的字段在新的继承链里丢失或位置变化。
- 在
**Serializable**和**Externalizable**之间切换:两者的字节格式完全不同,不存在互相兼容的可能。
图4:字段改动落地后,哪些是"兼容"、哪些会让老数据直接读不回来

工程实践里应对这类问题的常见做法,是新增字段时给一个合理的默认值兜底、废弃字段先保留但不再读写(等确认没有历史数据依赖了再真正删除)、大的结构调整宁可新增一个版本号字段走灰度迁移,而不是直接动老字段。这些做法背后的判断依据,正是上面这几条规则。
五、安全风险:反序列化为什么可能变成远程代码执行
1. 问题的根源:readObject 默默做了太多事
前面提到过一个关键细节:ObjectInputStream.readObject() 还原一个对象时,不会调用这个类正常的构造器,而是通过反射直接在堆上分配内存、再把字段值逐个填进去。这个设计本身是为了让反序列化能还原出任意状态的对象,但它同时带来一个后果——只要字节流里声明了某个类,JVM 在还原这个对象的过程中,就会去调用这个类里和序列化相关的方法(比如自定义的 readObject,如果这个类的父类链上有的话),而这些方法执行了什么逻辑,完全由字节流的构造者说了算,不由接收方的业务代码决定。
如果这份字节流的来源是不可信的——比如直接来自外部 HTTP 请求体、某个暴露给外网的 RPC 接口、用户可控的上传文件——那么反序列化过程本质上是在"执行攻击者递过来的一段配方",而这段配方能触碰到的范围,是当前 JVM classpath 上所有已经加载的类,不只是业务代码自己写的那几个类。
2. Gadget Chain:借用已有的类拼出攻击链
攻击者当然没办法直接把一段任意代码塞进字节流里执行——Java 字节流里只有类名和字段值,没有可执行指令。真正的攻击手法是:在目标应用的 classpath 里(可能是业务代码引入的某个第三方库),找到一条已经存在的方法调用链——A 类的某个方法在反序列化早期就会被自动触发(例如 readObject、hashCode、equals、finalize 这类会被反序列化机制或垃圾回收隐式调用的方法),这个方法内部又调用了 B 类的某个方法,B 又调用 C,一路调用下去,最终链条的某一环恰好具备"把一段字符串当命令执行"或者"通过反射调用任意方法"这类危险能力。攻击者要做的,只是精心构造字节流里每个对象的字段值,让这条本来就存在于 classpath 里的调用链,被恰好触发、恰好一路传递到危险的那一环——这条人为拼接出来的调用链,就叫 Gadget Chain(工具链/齿轮链)。
这个手法最著名的一次公开案例发生在 2015 年:安全研究者发现,只要 classpath 里存在 Apache Commons Collections 这个极其常见的工具库(它被大量框架间接引入),就可以拼出一条从 readObject 触发、经过它内部的 InvokerTransformer(一个通过反射调用任意方法的工具类)、最终执行任意系统命令的完整链条——而这个过程不需要目标应用自己的代码有任何漏洞,唯一的条件是"classpath 上有这个库,且应用某处会对不可信字节流调用 readObject"。这个发现直接导致了当时多个使用该库的知名中间件(应用服务器、消息中间件、CI 工具等)被披露存在远程代码执行漏洞,因为几乎所有企业级 Java 项目都会间接依赖这个库。开源工具 ysoserial 后来把多个类似的 Gadget Chain 打包成了可复现的 payload 生成器,也让这类漏洞的存在范围和危害被更广泛地认识到。
图5:Gadget Chain 的攻击直觉——顺着 classpath 里现成的调用链,一路"传递"到危险的那一环

3. 为什么这个问题特别难防
这类漏洞的棘手之处在于:漏洞不在"自己写的代码"里,而在"classpath 上有什么"——只要引入了某个存在已知 Gadget Chain 的库版本,即使业务代码从头到尾没有一行明显的问题,只要程序里有任何一处会对不可信来源的数据调用 readObject(哪怕是很隐蔽的一处,比如某个中间件框架内部的心跳协议、某个管理后台的会话反序列化),就可能被利用。而"classpath 上有哪些库"往往是间接依赖决定的,开发者自己都未必清楚全貌。
六、防御手段
1. 第一原则:不要对不可信数据调用原生反序列化
最根本的防御不是修补,而是从设计上避免——面向外部输入(用户上传、公开 API 接收的数据、跨信任边界的消息)的场景,直接不使用 Java 原生序列化格式,改用后面第七节要讲的 JSON、Protobuf 这类格式。这些格式的反序列化只是把数据映射进已知字段,不会触发任意类的方法调用,天然不存在 Gadget Chain 这类问题。这也是为什么 2015 年那次事件之后,很多原本在内部 RPC 里使用 Java 原生序列化的框架(比如早期的 Dubbo),后来都把默认序列化协议换成了 Hessian、Kryo 或其他专门设计的协议,而不是简单修补漏洞。
2. ObjectInputFilter:JDK 层面的白名单机制
如果确实需要保留 ObjectInputStream(比如维护遗留系统、内部完全可信但仍想加一道防线),JDK 9 引入(并回移植到 8u121)的 ObjectInputFilter 提供了一种运行时过滤机制:在反序列化真正还原对象之前,先对流里出现的每一个类名做检查,只放行明确允许的类,其余一律拒绝:
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.session.UserSession;" + // 允许业务自己的会话类
"java.util.ArrayList;" + // 允许用到的 JDK 集合类
"java.lang.String;" +
"!*" // 其余全部拒绝
);
try (ObjectInputStream ois = new ObjectInputStream(inputStream)) {
ois.setObjectInputFilter(filter);
UserSession session = (UserSession) ois.readObject();
}
这个过滤器还能限制反序列化过程中对象图的深度、数组长度、引用数量等指标,防止即使类名合法,也通过构造超大或超深的对象图来做拒绝服务式的资源耗尽攻击。它的核心思路和第五节的问题根源正好对应:Gadget Chain 之所以能生效,是因为 classpath 上"能被反序列化实例化"的类范围过大(等于整个 classpath),ObjectInputFilter 就是把这个范围显式收紧到业务真正需要的那一小撮类上。
图6:ObjectInputFilter 的白名单逻辑——只放行明确允许的类,其余一律拦下

3. 补充措施
除了上面两条主线,工程上还有一些辅助手段:升级到已修复已知 Gadget Chain 的依赖版本(安全公告通常会列出受影响的库版本区间);在网络边界限制哪些接口会接收和处理序列化格式的请求体;对反序列化入口做审计,梳理清楚哪些地方最终会调用到 readObject,尤其是框架内部隐藏得比较深的调用路径。这些都是缓解手段,真正的解法仍然是第一条——从根源上不让不可信数据走原生反序列化这条路。
七、替代协议:JSON、Protobuf、Hessian、Kryo 怎么选
原生 Java 序列化的问题不只是安全——它还只能在 Java 与 Java 之间使用(字节流里带着 Java 特有的类描述信息,别的语言无法解析),格式也偏冗余(前面提过,元信息占了不小的体积),性能在几种方案里往往是最慢的一档。工程上常见的替代方案,各自在不同维度上做了取舍:
| 方案 | 格式 | 跨语言 | 反序列化安全性 | 性能/体积 | 典型场景 |
|---|---|---|---|---|---|
| Java 原生序列化 | 二进制,含类元信息 | 否 | 差,存在 Gadget Chain 风险 | 较慢,体积较大 | 内部完全可信环境的深拷贝、遗留系统兼容 |
| JSON(Jackson/Gson) | 文本 | 是 | 好,仅做字段映射 | 中等 | 对外 API、跨语言/跨团队接口、需要可读性 |
| Protobuf | 二进制,需预定义 Schema | 是 | 好,按 Schema 严格解析 | 快,体积小 | 高频 RPC、对性能和带宽敏感的微服务通信 |
| Hessian | 二进制,自描述 | 是(多语言实现) | 中等,早期版本曾有过风险,需关注版本 | 较快 | 传统 RPC 框架(如 Dubbo 早期默认协议) |
| Kryo | 二进制,无 Schema | 否(主要用于 Java 内) | 中等,仍是通用对象反序列化,需限制类白名单 | 很快,体积很小 | 对性能极度敏感的内部场景(如缓存序列化) |
几个关键判断维度值得展开说一句:
- 是否需要跨语言:Protobuf、JSON 天然支持多语言客户端/服务端互通;Java 原生序列化和 Kryo 本质上是"为 Java 对象图设计的格式",跨语言几乎不可行。
- Schema 演进方式:Protobuf 要求提前定义
.proto文件描述字段结构,字段变更有明确的兼容规则(新增字段必须带唯一编号且不能复用旧编号,删除字段要保留编号占位);JSON 天然弱类型、字段增减基本不需要额外处理,但也因此更容易在运行时才发现字段缺失或类型不对;Java 原生序列化的兼容规则就是第四节讨论的那一套,规则藏在 JVM 内部行为里,不像 Protobuf 那样以显式 Schema 的形式摆在台面上。 - 反序列化安全性:本质上区分"这个格式解析时会不会触发任意类的方法调用"。JSON、Protobuf 的反序列化过程只是把数据填进目标类型已知的字段,不会像
readObject那样隐式调用一串未知方法,天然规避了 Gadget Chain 这类问题;Kryo 虽然性能出色,但它本质上仍是"通用对象反序列化",如果对不可信输入使用,同样需要显式配置类白名单来收窄风险面。 - 性能差异的来源:Java 原生序列化慢,主要成本花在两处——写入大量类描述信息(类名、字段名字符串等),以及大量依赖反射读写字段;Protobuf、Kryo 之所以快,一是格式本身更紧凑(Protobuf 用字段编号代替字段名,Kryo 干脆不写类型描述信息,依赖注册表), 二是大量使用编译期生成代码或运行时字节码生成来读写字段,避免了反射调用的开销。
图7:从"沉重的密封包裹"到"轻快的火箭"——几种序列化方案在体积与速度上的直觉对比

八、总结:一张决策树覆盖所有场景
把前面几节串起来,选择序列化方案可以归纳成一套判断顺序:先问这份数据要不要跨语言/跨系统传输——如果是,原生 Java 序列化和 Kryo 直接排除在外,在 Protobuf(追求性能和带宽)与 JSON(追求可读性和开发效率)之间选;如果不需要跨语言,再看数据要不要长期持久化存储且要求较强的跨版本兼容性——这种情况即便是纯 Java 场景,也建议优先用 JSON 或结构化存储,而不是把原生序列化字节流当作长期存储格式(前面讨论的兼容性规则虽然存在,但一旦踩中不兼容的改动,历史数据基本无法挽回);如果只是短期、进程内或可信环境内部使用(比如缓存序列化、深拷贝),再看反序列化数据源是否完全可信——完全可信可以用 Kryo 这类高性能方案追求速度,一旦数据来源不完全可信(哪怕只是"目前可信,未来可能对接外部系统"),就应该直接放弃原生 Java 序列化,改用 JSON 并对输入做验证。
图8:序列化方案选型决策树——先问要不要跨语言,再问信不信任数据来源

问答(不计入阅读时长)
Q1:serialVersionUID 到底应该固定写 1L,还是每次改动都手动更新?
A:多数团队的实践是初始写 1L(或用 IDE/serialver 工具生成的初始值),之后只要改动仍属于第四节列出的"兼容改动",就不改这个值——改了反而会让本来兼容的老数据被无辜拒绝。只有在真正做了不兼容的改动、且明确希望老版本数据直接报错(而不是被静默错误处理)时,才主动修改这个值,把它当成一个"强制版本隔离"的开关来用。
Q2:为什么 Externalizable 反序列化需要 public 无参构造器,而 Serializable 不需要?
A:两者还原对象走的是完全不同的路径。Serializable 的默认反序列化不调用任何构造器,是通过反射在堆上直接分配内存、逐字段赋值,所以哪怕类没有无参构造器(甚至构造器是 private 的)也能被反序列化。Externalizable 则是先正常调用无参构造器创建一个"空对象",再调用 readExternal 把数据填进去——这一步依赖构造器必须存在且可访问,如果构造器是 private 或者不存在,反序列化会直接失败。
Q3:反序列化漏洞是 Java 语言本身的缺陷,还是某个具体类库的问题?
A:两者都有关系,但根源在语言/API 设计层面。Java 序列化协议允许字节流指定"要还原成哪个类",且还原过程会触发目标类里和序列化相关的方法调用,这个设计本身就把"信任边界"放在了一个很宽的范围(整个 classpath)。像 Commons Collections 那样的具体类库只是提供了"恰好可以被拼成攻击链的现成方法调用",换成任何其他有类似反射调用能力的库,原则上都可能被找到类似的链条——这也是为什么这类问题被称为"通用反序列化风险",而不是某一个库的孤立漏洞。
Q4:如果一个字段的类型从 int 改成 long,算兼容改动吗?
A:不算,这属于第四节里"修改字段类型"的不兼容改动。即使数值上 long 能完全表示原来 int 的取值范围,反序列化时读取的是按老类型编码的字节,新类型的读取逻辑对不上老格式,会直接抛出与类型不匹配相关的异常,不会静默转换。
Q5:JSON 反序列化真的完全没有安全风险吗?
A:JSON 本身的反序列化过程(把 JSON 文本映射进已知字段)不存在 Gadget Chain 这类"隐式触发任意方法"的问题,但如果解析库启用了某些支持"多态类型"的高级特性(比如允许 JSON 里携带一个字段声明"用哪个具体类来反序列化",Jackson 的 enableDefaultTyping 就属于这类配置),理论上也可能被诱导实例化一个非预期的类,间接构造出类似的风险。这不是 JSON 格式本身的问题,而是特定库某些非默认配置项的问题——保持默认的、不开启多态类型推断的配置,风险面和原生序列化不是一个量级。
Q6:Kryo 既然也是通用对象反序列化,为什么很多团队还是选它做内部缓存序列化?
A:因为它用在"内部、可信"的场景里——比如本地缓存或者同一套系统内部节点之间的数据交换,字节流的产生方和消费方都是自己的代码,不存在外部攻击者能注入任意字节流的路径。这种场景下,Kryo 的高性能收益(远超 Java 原生序列化和 JSON)远比反序列化风险更值得优先考虑;一旦这份数据的产生方不完全可信(比如要接收来自外部系统或者未经验证的用户输入),就不再适用这个判断,应该换成 JSON 等更安全的格式。
Q7:为什么 Protobuf 新增字段要求带唯一编号,而不能像 Java 序列化那样直接按字段名识别?
A:因为 Protobuf 的二进制格式里根本不写字段名——为了体积和速度,它只在字节流里写字段编号和值,靠双方共享的 .proto 定义把编号翻译回字段名。这意味着字段的"身份"完全由编号决定,一旦某个编号被复用给不同语义的字段,新旧版本的服务在解析同一份数据时会把值对到完全不同的字段上,且不会报错——这比 Java 序列化里"改类型直接抛异常"更隐蔽,所以 Protobuf 的规范特别强调编号一旦分配就不能删除或复用,只能标记为 deprecated。
Q8:一个类如果既没实现 Serializable 又没实现 Externalizable,能不能被序列化?
A:不能,直接尝试序列化会抛出 NotSerializableException。如果这个类是第三方库提供的、无法修改源码,又确实需要序列化,常见的绕行方案是自己写一个可序列化的包装类(DTO),在 writeObject/readObject 里手动把目标对象的字段值搬进搬出,或者换用不依赖 Serializable 接口的方案(比如 JSON 序列化,只要有可访问的 getter,不要求实现任何特定接口)。