一句话总结

serialVersionUID 是你亲手写死的「类版本号」;不写,JVM 就自己猜,类一改它就翻脸。


一、先看事故现场

有个 User,登录后丢进 Redis 缓存 7 天:

1
2
3
4
public class User implements Serializable {
private String name;
private Integer age;
}

下一个迭代,产品说要加手机号。你顺手加了个字段,发版:

1
2
3
4
5
public class User implements Serializable {
private String name;
private Integer age;
private String phone; // ← 就加了这一行
}

发版后告警炸了:

1
2
3
4
java.io.InvalidClassException: com.xxx.User;
local class incompatible:
stream classdesc serialVersionUID = 111111,
local class serialVersionUID = 222222

线上所有还没过期的缓存,全部读不出来。 你只加了一行字段,什么逻辑都没动。


二、为什么会这样

序列化时,JVM 会把一个版本号(serialVersionUID)跟数据一起写进去。反序列化时拿出来比对,不一致就直接拒绝。

你没声明这个字段,JVM 就根据类的结构自己算一个:

1
2
旧类 User{name, age}        → 算出 111111 → 写进了 Redis
新类 User{name, age, phone} → 算出 222222 → 当前代码是这个

读的时候 111111 != 222222 → 炸。

⚠️ 更坑的是:这个自动计算不只看字段。方法签名、实现的接口、类修饰符都参与计算。所以哪怕你只是加了个 getter,ID 也可能变。

没写 serialVersionUID 的类,你几乎不能碰。


三、正确做法

手动写死:

1
2
3
4
5
6
7
public class User implements Serializable {
private static final long serialVersionUID = 1L; // ← 加这一行

private String name;
private Integer age;
private String phone;
}

现在加字段后 ID 还是 1L,跟 Redis 里一致 → 正常读出来,phone 自动为 null。平滑过渡,无感发版。


四、别以为它万能

serialVersionUID 只是让你跳过版本号检查这一关,后面还有字段匹配这一关。

✅ 能扛住的改动

改动 结果
加字段 老数据读出来,新字段为 null / 0
删字段 老数据里多的那个被丢掉
加/删/改方法 完全不影响

❌ 扛不住的改动

1
2
3
4
5
// 老版本
private Integer age;

// 新版本
private String age; // ← 一样炸

Redis 里存的是数字,代码要的是字符串,对不上,UID 救不了。

同类型的雷:

  • 类改名 / 换包路径
  • 字段加上或去掉 transient、static
  • 父类变了

一张表记住

改动 加了 UID
加 / 删字段、改方法 ✅ 兼容
改字段类型、改类名包名 ❌ 还是炸

规则:加字段随便加;要改字段类型或挪包,就别指望兼容老数据 —— 要么换个新字段名,要么先把缓存清掉。


五、@Serial 又是什么

JDK 14 引入的 java.io.Serial,说白了就是给序列化相关成员做拼写检查。

1
2
3

@Serial
private static final long serialVersionUID = 1L;

它运行时完全没用(@Retention(SOURCE)),加不加都不影响序列化行为。它只在编译期帮你确认「你这行写对了没」。

为什么需要它

序列化的那些「魔法方法」,Java 是按名字 + 签名反射调用的,不是接口实现,没有 @Override 那样的保护。写错一个字符就静默失效:

1
2
3
4
5
// 想自定义反序列化,参数类型写错了 —— 编译通过,运行时这方法永远不会被调用
private void readObject(ObjectInput in) { ...} // 应为 ObjectInputStream

@Serial
private void readObject(ObjectInput in) { ...} // ← 加上后编译期直接报错

同理 serialVersionUID 写成 int、漏掉 static,加了 @Serial 都会被当场拦住。

能标注的 7 种成员

只能用在实现了 Serializable 的类里,签名必须完全匹配,否则编译报错:

成员 要求的签名
serialVersionUID private static final long
serialPersistentFields private static final ObjectStreamField[]
writeObject private void writeObject(ObjectOutputStream) throws IOException
readObject private void readObject(ObjectInputStream) throws IOException, ClassNotFoundException
readObjectNoData private void readObjectNoData() throws ObjectStreamException
writeReplace ANY-ACCESS-MODIFIER Object writeReplace() throws ObjectStreamException
readResolve ANY-ACCESS-MODIFIER Object readResolve() throws ObjectStreamException

注意:不能用在 record 和枚举上(这些类型的 serialVersionUID 本身就被忽略),也不能标在非 Serializable 类里。


六、实战建议

1️⃣ 必须加 UID 的:会真正跨 JVM / 跨版本传输的类

  • Redis 缓存对象
  • Dubbo / RPC 传的 DTO、VO
  • Session 里的对象

2️⃣ 只是顺手 implements Serializable、从没真序列化过的 —— 加不加无所谓,但建议统一加上,成本为零,能避开发版事故。

3️⃣ 千万别随便改已有的 serialVersionUID —— 改了等于宣布「跟老数据不兼容」,线上 Redis 里的旧数据会全部读不出来。这是自己给自己造事故。

4️⃣ @Serial 按需加 —— 只有 serialVersionUID 的 DTO 加它收益不大(规范统一 + 防手误);真正值得加的是自定义了 writeObject / readObject / readResolve 的类。


口诀

UID 手写死,加删字段不用怕;
类型包名一动手,缓存照样全报废。
@Serial 不干活,只在编译帮你查。