缓存中的serialVersionUID序列化问题
阿昌 Java小菜鸡

缓存中的serialVersionUID序列化问题

Hi,我是阿昌,今天记录一个 Java 序列化容易被忽略的问题:serialVersionUID 不一致导致InvalidClassException

这个问题通常出现在项目版本升级的过程中,尤其是缓存或者数据库里存了旧版本的序列化数据,代码一升级就开始报错,排查起来还挺绕的。

一、问题现象

线上某个接口突然开始报 java.io.InvalidClassException,异常堆栈大概长这样:

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

翻译成人话就是:序列化的时候 UID 是 1,反序列化的时候 UID 变成了 2,两边对不上,JVM 就不干了。

imageimage

二、问题分析

这个问题的根因很清楚:同一个类的 serialVersionUID 发生了变化。

Java 的序列化机制在反序列化时,会拿当前这个类的 serialVersionUID 跟序列化数据流里记录的 UID 做对比。如果两者不一致,JVM 就认为这是两个”不同版本”的类,直接抛出 InvalidClassException,拒绝反序列化。

那什么情况下 UID 会变呢?分两种情况:

1、没有显式声明 serialVersionUID

如果类实现了 Serializable 但没有显式声明 serialVersionUID,JVM 会在编译期根据类的结构自动计算一个 UID。

计算时会考虑这些因素:

  • 类名
  • 实现的接口
  • 所有 public 和 protected 成员变量
  • 所有 public 和 protected 方法
  • 构造器

所以,只要你随便改点东西——比如加个字段、删个方法、甚至换一个编译环境——这个自动生成的 UID 就可能发生变化。

2、显式声明了 serialVersionUID 但被改动了

如果之前声明了 serialVersionUID = 1L,后面有人不小心改成了 2L,那也会导致同样的问题。

这在项目迭代中很容易发生,尤其是在多人协作、代码合并的时候。

回到业务场景

缓存里存的对象是用旧版本(UID=1)序列化写入的,代码升级后,类结构调整导致 UID 变成了 2。这时候再去读缓存——反序列化——UID 对不上,直接报错。

三、代码案例

下面用两个简单的 Demo 展示问题是怎么产生的。

场景:序列化写入 → 代码改动 → 反序列化读取

Step 1:定义一个没有显式声明 UID 的类并写文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.io.*;

// V1:没有显式声明 serialVersionUID
public class User implements Serializable {

private String name;
private int age;

public User(String name, int age) {
this.name = name;
this.age = age;
}

@Override
public String toString() {
return "User{name='" + name + "', age=" + age + "}";
}

public static void main(String[] args) throws Exception {
// 序列化写入文件
try (ObjectOutputStream oos =
new ObjectOutputStream(new FileOutputStream("user.obj"))) {
oos.writeObject(new User("阿昌", 18));
System.out.println("序列化写入成功");
}
}
}

运行后,user.obj 文件中记录的是 JVM 自动生成的 UID。

Step 2:改动类结构后尝试反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.io.*;

// V2:加了一个字段,依然没有显式声明 serialVersionUID
public class User implements Serializable {

private String name;
private int age;
private String email; // ← 新增字段

public User(String name, int age) {
this.name = name;
this.age = age;
}

@Override
public String toString() {
return "User{name='" + name + "', age=" + age
+ ", email='" + email + "'}";
}

public static void main(String[] args) throws Exception {
// 反序列化读取文件
try (ObjectInputStream ois =
new ObjectInputStream(new FileInputStream("user.obj"))) {
User user = (User) ois.readObject();
System.out.println(user);
}
}
}

执行第二步时就会抛出:

1
2
3
4
java.io.InvalidClassException: com.example.User;
local class incompatible:
stream classdesc serialVersionUID = 6995618842974011208,
local class serialVersionUID = -2134351137722478609

因为加了一个 email 字段,类结构变了,JVM 重新计算的 UID 跟之前不同,反序列化失败。

正确做法:显式声明 serialVersionUID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import java.io.*;

public class User implements Serializable {

private static final long serialVersionUID = 1L;
// ↑ 显式声明,永远不改

private String name;
private int age;
private String email; // 新增字段也没关系,UID 一致就行

public User(String name, int age) {
this.name = name;
this.age = age;
}

// getter/setter、构造器省略...
}

加了 serialVersionUID = 1L 之后,只要这个值不变,即使后续加字段、删方法,JVM 也不会抛 InvalidClassException

注意:删字段或者改字段类型还是要小心,即使不报错,旧数据里对应字段的值也会是默认值(null / 0)。

四、解决办法

如果线上已经出现这个问题,处理步骤就三步:

1、统一 UID

在类的定义中显式加上 serialVersionUID,值设置为跟当前线上最新的序列化数据保持一致。

如果旧数据的 UID 是 JVM 自动生成的,可以通过 serialver 命令查出来:

1
serialver com.example.User

然后用这个值去声明:

1
private static final long serialVersionUID = -2134351137722478609L;

2、清理旧缓存

把 Redis 缓存、数据库里存有旧序列化数据的记录清掉(或者做平滑迁移),避免旧版本的脏数据继续作怪。

3、以最新版本重新写入

缓存 / 数据清掉之后,后续的读写都会以当前最新的 UID 版本来序列化和反序列化,就不会再报错了。

五、总结

这个问题归根结底就一句话:

实现 Serializable 接口的类,一定要显式声明 serialVersionUID,并且不要随意改动它。

几点注意事项:

  • 声明了就别改serialVersionUID = 1L 从上线第一天就定好,后续迭代不要动这个值。
  • 不要依赖 JVM 自动生成:自动生成的值受类结构影响,稍微改点东西就可能变,完全不可控。
  • 字段增删要谨慎:加字段一般没问题,旧数据那个字段会是默认值;但删字段或改字段类型,即使不报异常逻辑也可能出错。
  • 版本升级时注意数据兼容:如果缓存或数据库里存了序列化二进制数据,升级前要想好旧数据的兼容方案——要么保证 UID 不变,要么做数据迁移。

这个坑不大,但掉进去的时候排查起来挺费时间的,所以从一开始就养成好习惯,所有实现了 Serializable 的类都把 UID 写死就对了。

 请作者喝咖啡