【强制】当序列化类新增属性时,请不要修改 serialVersionUID 字段,以避免反序列失败;如果完全不兼容升级,避免反序列化混乱,那么请修改 serialVersionUID 值。

说明:注意 serialVersionUID 值不一致会抛出序列化运行时异常。

1. 序列化和反序列化是什么?为什么需要它?

序列化是将内存中的对象信息转化成可以存储或者传输的数据到临时或永久存储的过程。
而反序列化正好相反,是从临时或永久存储中读取序列化的数据并转化成内存对象的过程。
序列化引发的一个血案 - 图1
想要将 Java 中的对象进行网络传输或存储到文件中,就需要将对象转化为二进制字节流,这就是所谓的序列化。存储或传输之后必然就需要将二进制流读取并解析成 Java 对象,这就是所谓的反序列化。

序列化的主要目的是:方便存储到文件系统、数据库系统或网络传输等。

实际开发中常用到序列化和反序列化的场景有:

  • 远程方法调用(RPC)的框架里会用到序列化。
  • 将对象存储到文件中时,需要用到序列化。
  • 将对象存储到缓存数据库(如 Redis)时需要用到序列化。
  • 通过序列化和反序列化的方式实现对象的深拷贝。

    2. 常见的序列化方式

    常见的序列化方式包括 Java 原生序列化、Hessian 序列化、Kryo 序列化、JSON 序列化等。
    2.1 Java 原生序列化
    Serializable 的源码非常简单,只有声明,没有属性和方法:
    1. // 注释太长,省略
    2. public interface Serializable {
    3. }
    在学习源码注释之前,希望大家可以站在设计者的角度,先思考一个问题:如果一个类序列化到文件之后,类的结构发生变化还能否保证正确地反序列化呢?

答案显然是不确定的。

那么如何判断文件被修改过了呢? 通常可以通过加密算法对其进行签名,文件作出任何修改签名就会不一致。但是 Java 序列化的场景并不适合使用上述的方案,因为类文件的某些位置加个空格,换行等符号类的结构没有发生变化,这个签名就不应该发生变化。还有一个类新增一个属性,之前的属性都是有值的,之前都被序列化到对象文件中,有些场景下还希望反序列化时可以正常解析,怎么办呢?

那么是否可以通过约定一个唯一的 ID,通过 ID 对比,不一致就认为不可反序列化呢?

实现序列化接口后,如果开发者不手动指定该版本号 ID 怎么办?

既然 Java 序列化场景下的 “签名” 应该根据类的特点生成,我们是否可以不指定序列化版本号就默认根据类名、属性和函数等计算呢?

如果针对某个自己定义的类,想自定义序列化和反序列化机制该如何实现呢?支持吗?

带着这些问题我们继续看序列化接口的注释。


Serializable的源码注释特别长,其核心大致作了下面的说明:

Java 原生序列化需要实现 Serializable 接口。序列化接口不包含任何方法和属性等,它只起到序列化标识作用。

一个类实现序列化接口则其子类型也会继承序列化能力,但是实现序列化接口的类中有其他对象的引用,则其他对象也要实现序列化接口。序列化时如果抛出 NotSerializableException异常,说明该对象没有实现
Serializable接口。

每个序列化类都有一个叫 serialVersionUID 的版本号,反序列化时会校验待反射的类的序列化版本号和加载的序列化字节流中的版本号是否一致,如果序列化号不一致则会抛出 InvalidClassException异常。

强烈推荐每个序列化类都手动指定其 serialVersionUID,如果不手动指定,那么编译器会动态生成默认的序列化号,因为这个默认的序列化号和类的特征以及编译器的实现都有关系,很容易在反序列化时抛出
InvalidClassException异常。建议将这个序列化版本号声明为私有,以避免运行时被修改。

实现序列化接口的类可以提供自定义的函数修改默认的序列化和反序列化行为。

自定义序列化方法:

  1. private void writeObject(ObjectOutputStream out) throws IOException;

自定义反序列化方法:

  1. private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException;

通过自定义这两个函数,可以实现序列化和反序列化不可序列化的属性,也可以对序列化的数据进行数据的加密和解密处理。

2.2 JSON 序列化

JSON (JavaScript Object Notation) 是一种轻量级的数据交换方式。JSON 序列化是基于 JSON 这种结构来实现的。JSON 序列化将对象转化成 JSON 字符串,JSON 反序列化则是将 JSON 字符串转回对象的过程。常用的 JSON 序列化和反序列化的库有 Jackson、GSON、Fastjson 等。

JSON 序列化的优势在于可读性更强。主要缺点是:没有携带类型信息,只有提供了准确的类型信息才能准确地进行反序列化,这点也特别容易引发线上问题

下面给出使用 Gson 框架模拟 JSON 序列化时遇到的反序列化问题的示例代码:

  1. /**
  2. * 验证GSON序列化类型错误
  3. */
  4. @Test
  5. public void testGSON() {
  6. Map<String, Object> map = new HashMap<>();
  7. final String name = "name";
  8. final String id = "id";
  9. map.put(name, "张三");
  10. map.put(id, 20L);
  11. String jsonString = GSONSerialUtil.getJsonString(map);
  12. Map<String, Object> mapGSON = GSONSerialUtil.parseJson(jsonString, Map.class);
  13. // 正确
  14. Assert.assertEquals(map.get(name), mapGSON.get(name));
  15. // 不等 map.get(id)为Long类型 mapGSON.get(id)为Double类型
  16. Assert.assertNotEquals(map.get(id).getClass(), mapGSON.get(id).getClass());
  17. Assert.assertNotEquals(map.get(id), mapGSON.get(id));
  18. }

下面给出使用 fastjson 模拟 JSON 反序列化问题的示例代码:

  1. /**
  2. * 验证FatJson序列化类型错误
  3. */
  4. @Test
  5. public void testFastJson() {
  6. Map<String, Object> map = new HashMap<>();
  7. final String name = "name";
  8. final String id = "id";
  9. map.put(name, "张三");
  10. map.put(id, 20L);
  11. String fastJsonString = FastJsonUtil.getJsonString(map);
  12. Map<String, Object> mapFastJson = FastJsonUtil.parseJson(fastJsonString, Map.class);
  13. // 正确
  14. Assert.assertEquals(map.get(name), mapFastJson.get(name));
  15. // 错误 map.get(id)为Long类型 mapFastJson.get(id)为Integer类型
  16. Assert.assertNotEquals(map.get(id).getClass(), mapFastJson.get(id).getClass());
  17. Assert.assertNotEquals(map.get(id), mapFastJson.get(id));
  18. }

3. 序列化引发的一个血案

接下来我们看下面的一个案例:

前端调用服务 A,服务 A 调用服务 B,服务 B 首次接到请求会查 DB,然后缓存到 Redis(缓存 1 个小时)。服务 A 根据服务 B 返回的数据后执行一些处理逻辑,处理后形成新的对象存到 Redis(缓存 2 个小时)。

服务 A 通过 Dubbo 来调用服务 B,A 和 B 之间数据通过 Map 类型传输,服务 B 使用 Fastjson 来实现 JSON 的序列化和反序列化。

服务 B 的接口返回的 Map 值中存在一个 Long 类型的 id 字段,服务 A 获取到 Map ,取出 id 字段并强转为 Long 类型使用。

执行的流程如下:
序列化引发的一个血案 - 图2

通过分析我们发现,服务 A 和服务 B 的 RPC 调用使用 Java 序列化,因此类型信息不会丢失。

但是由于服务 B 采用 JSON 序列化进行缓存,第一次访问没啥问题,其执行流程如下:
序列化引发的一个血案 - 图3
如果服务 A 开启了缓存,服务 A 在第一次请求服务 B 后,缓存了运算结果,且服务 A 缓存时间比服务 B 长,因此不会出现错误。
序列化引发的一个血案 - 图4
如果服务 A 不开启缓存,服务 A 会请求服务 B ,由于首次请求时,服务 B 已经缓存了数据,服务 B 从 Redis(B)中反序列化得到 Map。流程如下图所示:

序列化引发的一个血案 - 图5

然而问题来了: 服务 A 从 Map 取出此 Id 字段,强转为 Long 时会出现类型转换异常。

最后定位到原因是 Json 反序列化 Map 时如果原始值小于 Int 最大值,反序列化后原本为 Long 类型的字段,变为了 Integer 类型,服务 B 的同学紧急修复。

服务 A 开启缓存时, 虽然采用了 JSON 序列化存入缓存,但是采用 DTO 对象而不是 Map 来存放属性,所以 JSON 反序列化没有问题。

因此大家使用二方或者三方服务时,当对方返回的是 **Map<String,Object>** 类型的数据时要特别注意这个问题

作为服务提供方,可以采用 JDK 或者 Hessian 等序列化方式;

作为服务的使用方,我们不要从 Map 中一个字段一个字段获取和转换,可以使用 JSON 库直接将 Map 映射成所需的对象,这样做不仅代码更简洁还可以避免强转失败。

代码示例:

  1. @Test
  2. public void testFastJsonObject() {
  3. Map<String, Object> map = new HashMap<>();
  4. final String name = "name";
  5. final String id = "id";
  6. map.put(name, "张三");
  7. map.put(id, 20L);
  8. //序列化
  9. String fastJsonString = FastJsonUtil.getJsonString(map);
  10. // 模拟拿到服务B的数据, 反序列化
  11. Map<String, Object> mapFastJson = FastJsonUtil.parseJson(fastJsonString,map.getClass());
  12. // 转成强类型属性的对象而不是使用map 单个取值
  13. User user = new JSONObject(mapFastJson).toJavaObject(User.class);
  14. // 正确
  15. Assert.assertEquals(map.get(name), user.getName());
  16. // 正确
  17. Assert.assertEquals(map.get(id), user.getId());
  18. }

4. 课后题

给出一个 PersonTransit 类,一个 Address 类,假设 Address 是其它 jar 包中的类,没实现序列化接口。请使用今天讲述的自定义的函数 writeObjectreadObject 函数实现 PersonTransit 对象的序列化,要求反序列化后 address 的值正常。

  1. @Data
  2. public class PersonTransit implements Serializable {
  3. private Long id;
  4. private String name;
  5. private Boolean male;
  6. private List<PersonTransit> friends;
  7. private Address address;
  8. }
  9. @Data
  10. @AllArgsConstructor
  11. public class Address {
  12. private String detail;
  13. }

这道题序列化主要有两个困难:

  1. transient关键字,序列化时默认不序列化该字段
  2. 假设Address是第三方jar包中的类,不允许修改实现序列化接口

我们通过专栏的介绍还有序列化接口java.io.Serializable的注释可知,可以自定义序列化方法和反序列化方法:

  1. private void writeObject(java.io.ObjectOutputStream out) throws IOException
  2. private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException;

实现序列化和反序列化不可序列化的属性,也可以对序列化的数据进行数据的加密和解密处理。

  1. @Data
  2. public class PersonTransit implements Serializable {
  3. private Long id;
  4. private String name;
  5. private Boolean male;
  6. private List<PersonTransit> friends;
  7. private transient Address address;
  8. /**
  9. * 自定义序列化写方法
  10. */
  11. private void writeObject(ObjectOutputStream oos) throws IOException {
  12. oos.defaultWriteObject();
  13. oos.writeObject(address.getDetail());
  14. }
  15. /**
  16. * 自定义反序列化读方法
  17. */
  18. private void readObject(ObjectInputStream ois) throws ClassNotFoundException, IOException {
  19. ois.defaultReadObject();
  20. this.setAddress(new Address( (String) ois.readObject()));
  21. }
  22. }

java.io.ObjectOutputStream.defaultWriteObject() 此方法将当前类的非静态和非瞬态字段写入此流。只能从要序列化的类的writeObject方法中调用此方法,否则调用它将抛出NotActiveException
java.io.ObjectInputStream.defaultReadObject()方法从该流中读取当前类的非静态和非瞬态字段。这也许只能称为从类反序列化readObject方法, 否则调用它会抛出NotActiveException

单元测试

  1. public class Test03 {
  2. @Test
  3. public void testJDKSerialOverwrite() throws IOException, ClassNotFoundException {
  4. PersonTransit person = new PersonTransit();
  5. person.setId(1L);
  6. person.setName("张三");
  7. person.setMale(true);
  8. person.setFriends(new ArrayList<PersonTransit>());
  9. Address address = new Address();
  10. address.setDetail("某某小区xxx栋yy号");
  11. person.setAddress(address);
  12. // 序列化
  13. JdkSerialUtil.writeObject(file, person);
  14. // 反序列化
  15. PersonTransit personTransit = JdkSerialUtil.readObject(file);
  16. // 判断是否相等
  17. Assert.assertEquals(personTransit.getName(), person.getName());
  18. Assert.assertEquals(personTransit.getAddress().getDetail(), person.getAddress().getDetail());
  19. }

用到的工具类

  1. public class JdkSerialUtil {
  2. public static <T> void writeObject(File file, T data) throws IOException {
  3. try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));) {
  4. objectOutputStream.writeObject(data);
  5. objectOutputStream.flush();
  6. }
  7. }
  8. public static <T> void writeObject(ByteArrayOutputStream outputStream, T data) throws IOException {
  9. try (ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);) {
  10. objectOutputStream.writeObject(data);
  11. objectOutputStream.flush();
  12. }
  13. }
  14. public static <T> T readObject(File file) throws IOException, ClassNotFoundException {
  15. FileInputStream fin = new FileInputStream(file);
  16. ObjectInputStream objectInputStream = new ObjectInputStream(fin);
  17. return (T) objectInputStream.readObject();
  18. }
  19. public static <T> T readObject(ByteArrayInputStream inputStream) throws IOException, ClassNotFoundException {
  20. ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
  21. return (T) objectInputStream.readObject();
  22. }
  23. }

通过单元测试验证了我们编写代码的正确性。