要不要加 Serializable?搞懂 Java 序列化
2024-09-18 02:46:04 # Technical # JavaBase

每次创建实体类时都会纠结是否实现 Serializable 接口,但是实不实现好像都没什么影响,就此决定对 Java 序列化一探究竟

序列化的意义

互联网的迅猛发展受益于网络通信技术的成熟和稳定,网络通信协议是机器双方共同约定的协议。在应用层看到的是结构体、对象,但是在网络协议里,机器之间传输的都是二进制流。网络编程中,需要定义应用层协议。最原始的应用层协议是字节数组,在 Java 语言里以 byte[] 体现,在 C 语言里以 char[] 体现。不管是 Java 语言还是 C 语言,开发人员都需要知道字节数组里每个字节的含义才能保证数据写入和读取的正确性。这对开发人员来说,是非常严苛且低效的。 如何将程序中的结构体、对象等结构化内存对象转换为扁平的二进制流?如何将二进制流还原为结构化内存对象?为了解决这些问题,序列化/反序列化技术应运而生。

序列化和反序列化

核心意义:对象状态的保存(序列化)和重建(反序列化)

序列化的方式

一般来说,序列化/反序列化分为 IDL(Interface Description Language,接口描述语言)和非 IDL 两类。非 IDL 技术方案包含 JSON、XML 等,提供构造和解析的工具包即可使用,不需要做代码生成的工作。IDL 技术方案包含 Thrift、Protocol Buffer、Avro 等,有比较完整的规约和框架实现。

IDL 序列化方式

以我了解的 Protocol Buffer 为例,客户端与服务端使用 protoc 命令根据 .proto 文件生成相应语言的代码(Java、C++…)然后就会有相应的序列化与反序列化方法进行请求的发送与接收。

JDK 中的序列化与反序列化

实现 Serializable 接口

这是最常见的一种 Java 序列化方式

实现 Serializable 的待序列化的对象 Student

1
2
3
4
5
6
7
8
@Data
@NoArgsConstructor
@AllArgsConstructor
class Student implements Serializable {
private static final long serialVersionUID = 7013329321078382881L;
private String name;
private Integer age;
}

将 Student 序列化到指定文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void test() {
Student student = new Student("venom", 18);
serializeToFile(student, "D:\\temp\\student.ser");
}

public void serializeToFile(Student student, String filePath) {
try (FileOutputStream fileOut = new FileOutputStream(filePath);
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
objectOut.writeObject(student);
System.out.println("Serialization completed. Object saved to file: " + filePath);
} catch (IOException e) {
e.printStackTrace();
}
}

查看生成的二进制文件

student.ser

将刚才生成的二进制文件反序列化成 Student

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void test() {
Student deserializedObj = deserializeFromFile("D:\\temp\\student.ser");
System.out.println(deserializedObj);
}

public Student deserializeFromFile(String filePath) {
try (FileInputStream fileIn = new FileInputStream(filePath);
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
Student deserializedObj = (Student) objectIn.readObject();
System.out.println("Deserialization completed. Object loaded from file: " + filePath);
return deserializedObj;
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}

console

1
2
Deserialization completed. Object loaded from file: D:\temp\student.ser
Student(name=venom, age=18)

实现 Externalizable 接口

除了实现 Serializable 接口完成序列化/反序列化外,还可以通过实现 Externalizable 接口达到序列化与反序列化的目的。与 Serializable 不同的是,Externalizable 有抽象方法需要实现,Externalizable 继承了 Serializable

实现 Externalizable 的待序列化对象 User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Data
@NoArgsConstructor
@AllArgsConstructor
class User implements Externalizable {
private static final long serialVersionUID = 4523061523714222909L;
private String name;
private Integer age;

@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeObject(name);
out.writeObject(age);
}

@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
name = (String) in.readObject();
age = (Integer) in.readObject();
}
}

将 User 序列化到指定文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
public void test() {
User user = new User("venom", 18);
serializeToFile(user, "D:\\temp\\user.ser");
}

public void serializeToFile(User user, String filePath) {
try (FileOutputStream fileOut = new FileOutputStream(filePath);
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
objectOut.writeObject(user);
System.out.println("Serialization completed. Object saved to file: " + filePath);
} catch (IOException e) {
e.printStackTrace();
}
}

查看生成的二进制文件

user.ser

将刚才生成的二进制文件反序列化成 User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void test() {
User user = deserializeFromFile("D:\\temp\\user.ser");
System.out.println(user);
}

public User deserializeFromFile(String filePath) {
try (FileInputStream fileIn = new FileInputStream(filePath);
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
User deserializedObj = (User) objectIn.readObject();
System.out.println("Deserialization completed. Object loaded from file: " + filePath);
return deserializedObj;
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}

console

1
2
Deserialization completed. Object loaded from file: D:\temp\user.ser
User(name=venom, age=18)

自定义序列化与反序列化

除了使用默认的序列化机制外,对于一些特殊的类, 我们需要定制序列化和反序列化方法的时候,可以通过重写以下方法实现

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

将 Student 的序列化 name 加上 24,age + 1

1
2
3
4
private void writeObject(ObjectOutputStream out) throws IOException {
out.writeObject(name + "24");
out.writeObject(age + 1);
}

重新序列化

1
2
3
4
5
@Test
public void test() {
Student student = new Student("venom", 18);
serializeToFile(student, "D:\\temp\\student2.ser");
}

查看二进制文件

student2.ser

可以看出现在是 venom24

反序列化将前面的改动还原

1
2
3
4
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
name = ((String) in.readObject()).substring(0, 5);
age = ((Integer) in.readObject()) - 1;
}

反序列化

1
2
3
4
5
@Test
public void test() {
Student student = deserializeFromFile("D:\\temp\\student2.ser");
System.out.println(student);
}

console

1
2
Deserialization completed. Object loaded from file: D:\temp\student2.ser
Student(name=venom, age=18)

JDK 序列化原理

// TODO

serialVersionUID

serialVersionUID 用来实现类版本兼容,在实际开发中能满足类字段变化的需求。如果我们有一个 Student 类,实现了 Serializable 接口,但是没有定义 serialVersionUID,对 Student 类增加一个 double 类型的字段 height,再读取增加字段之前的序列化数据,反序列化会报 InvalidCastException 异常。如果 Student 类定义了 serialVersionUID,对 Student 类增加一个 double 类型的字段 height,再读取增加字段之前的序列化数据,反序列化可以成功。serialVersionUID 必须是 static+final 类型,否则 serialVersionUID 不会被序列化。

static

static 字段属于类全局共有,不会被序列化。在反序列化得到的结果里,静态变量的值依赖类对该静态字段的初始化操作以及是否在同一个 JVM 进程内。比如说静态变量初始值为 0,在序列化之前静态变量的值被设置为 10,在同一个 JVM 进程内执行反序列化操作,得到的静态变量的值为 10。如果在另外一个 JVM 进程内执行反序列化操作,得到的静态变量的值为 0。这是因为类在 JVM 进程内只会被加载一次,相同的类在不同的 JVM 内都会初始化一遍。

transient

Java 序列化可以通过 transient 关键字来控制字段不被序列化。通过跟进 ObjectStreamClass 的 getDefaultSerialFields 方法内部实现,可以看到序列化字段不能为 static 且不能为 transient

ObjectStreamClass#getDefaultSerialFields

writeReplace

writeReplace 用于序列化写入时拦截并替换成一个自定义的对象。 用于多种用途,包括安全性、版本兼容性和对象共享等。当需要精细控制对象序列化过程时,可以考虑使用它 。

  • 安全性:writeReplace 方法可以用于返回安全的代理对象,以确保敏感数据在序列化时不会泄露。
  • 压缩:writeReplace 中返回一个压缩过的对象,以减小序列化数据的大小
  • 版本兼容: 如果类的版本发生了变化,可以使用 writeReplace 来返回与旧版本兼容的替代对象
  • 对象共享: 可以返回一个代理对象,以确保在序列化和反序列化期间共享对象实例

由于 writeReplace 的调用是基于反射来执行的,所以作用域限定符不受限制,可以是 private、default、protected、public 中的任意一种。 如果定义了 wirteReplace 方法,就没必要再定义 writeObject 方法了。即使定义了 writeObject 方法,该方法也不会被调用,内部会先调用 writeReplace 方法将当前序列化对象替换成自定义目标对象。同理,也没必要定义 readObject 方法,即使定义了也不会被调用。

readResolve

readResolve 方法与 writeReplace 方法的作用相反。它用于在反序列化时提供一个机会来替代反序列化得到的对象,从而可以进行自定义的对象还原或替代操作。

  • 对象替代: 在反序列化时,可以返回一个替代的对象,而不是反序列化得到的对象。这可以用于实现单例模式,对象池,或其他替代逻辑
  • 对象还原: 如果对象的类结构发生变化,可以在 readResolve 中还原对象,以确保与旧版本的对象一致性
  • 安全性: 可以在 readResolve 中检查反序列化得到的对象,以确保它符合某些安全性规则,然后返回一个安全的对象

readResolve 方法用于反序列化拦截并替换成自定义的对象。但和 writeReplace 方法不同的是,如果定义了 readResolve 方法,readObject 方法是允许出现的。

ObjectInputValidation

ObjectInputValidation 接口是 Java 序列化机制提供的一种机制,用于验证反序列化的对象是否合法。它允许在反序列化对象之后执行自定义的验证操作,以确保反序列化的对象状态是有效的。

  1. 实现接口:验证类实现 ObjectInputValidation 接口,并实现 validateObject 方法

  2. 验证: 在 validateObject 方法中编写验证逻辑,检查对象的状态是否合法

  3. 抛出异常: 如果在验证过程中发现对象状态不合法,可以通过抛出 InvalidObjectException 异常来中止对象的反序列化

  4. 注册验证: 在反序列化之前,通过 ObjectInputStreamregisterValidation 方法将实现了 ObjectInputValidation 接口的对象注册为验证对象

    1
    2
    3
    ObjectInputValidation validationObject = new MyValidationObject();
    ObjectInputStream ois = new ObjectInputStream(inputStream);
    ois.registerValidation(validationObject, 0);
  5. 反序列化:进行反序列化操作

以 Student 为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@NoArgsConstructor
@AllArgsConstructor
class Student implements Serializable, ObjectInputValidation {
private static final long serialVersionUID = 6397825858906650077L;
private String name;
private Integer age;

@Override
public void validateObject() throws InvalidObjectException {
if (age < 0) {
throw new InvalidObjectException("age cannot be negative");
}
}
}

序列化与反序列化的加解密

Java 序列化后的数据是明文形式,而且数据的组成格式有明确的规律。当这些数据脱离 Java 安全体系存在磁盘中时,可以通过二进制数编辑工具查看,甚至修改。如果这些数据注入了病毒,应用程序的表现行为将无法预计。为了保障数据的安全性,引入 SealedObjectSignedObject 对序列化数据进行加密

SealedObject

序列化加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Test
public void test() throws NoSuchAlgorithmException {
Student student = new Student("venom", 18);
// 生成密钥
SecretKey key = KeyGenerator.getInstance("DESede").generateKey();
encryptAndSerializeToFile(student, "D:\\temp\\encrypt_student.ser", key);
}

public void encryptAndSerializeToFile(Student student, String filePath, SecretKey key) {
try (FileOutputStream fileOut = new FileOutputStream(filePath);
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
// 初始化加密器
Cipher cipher = Cipher.getInstance("DESede");
cipher.init(Cipher.ENCRYPT_MODE, key);
SealedObject sealedObject = new SealedObject(student, cipher);
objectOut.writeObject(sealedObject);
System.out.println("Encrypted Serialization completed. Object saved to file: " + filePath);
} catch (IOException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | IllegalBlockSizeException e) {
throw new RuntimeException(e);
}
}

查看生成的二进制文件

encrypt_student.ser

可以看到现在基本已面目全非

反序列化解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public Student decryptAndDeserializeFromFile(String filePath, SecretKey key) {
try (FileInputStream fileIn = new FileInputStream(filePath);
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
SealedObject sealedObject = (SealedObject) objectIn.readObject();
Student deserializedObj = (Student) sealedObject.getObject(key);
System.out.println("Decrypted Serialization completed. Object loaded from file: " + filePath);
return deserializedObj;
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException(e);
}
}

console

1
2
Decrypted Serialization completed. Object loaded from file: D:\temp\encrypt_student.ser
Student(name=venom, age=18)

SignedObject

SignedObject 也是通过加解密的方式来保护序列化安全,与 SealedObject 不同的是,SignedObject 使用数字签名来验证对象的完整性和来源,所以 SignedObject 使用的是非对称加密

序列化加签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Test
public void test() throws NoSuchAlgorithmException {
Student student = new Student("venom", 18);
// 生成密钥对
KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
encryptAndSerializeToFile(student, "D:\\temp\\encrypt_student1.ser", keyPair);
}

public void encryptAndSerializeToFile(Student student, String filePath, KeyPair keyPair) {
try (FileOutputStream fileOut = new FileOutputStream(filePath);
ObjectOutputStream objectOut = new ObjectOutputStream(fileOut)) {
// 使用待序列化对象、私钥和签名来创建 SignedObject
SignedObject signedObject = new SignedObject(student, keyPair.getPrivate(), Signature.getInstance("SHA256withRSA"));
objectOut.writeObject(signedObject);
System.out.println("Encrypted Serialization completed. Object saved to file: " + filePath);
} catch (IOException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException | InvalidKeyException |
SignatureException e) {
throw new RuntimeException(e);
}
}

查看生成的二进制文件

encrypt_student1

可以看出,还是能看到相关属性的

反序列化验签

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Student decryptAndDeserializeFromFile(String filePath, KeyPair keyPair) {
try (FileInputStream fileIn = new FileInputStream(filePath);
ObjectInputStream objectIn = new ObjectInputStream(fileIn)) {
SignedObject signedObject = (SignedObject) objectIn.readObject();
// 验证签名
signedObject.verify(keyPair.getPublic(), Signature.getInstance("SHA256withRSA"));
Student deserializedObj = (Student) signedObject.getObject();
System.out.println("Decrypted Serialization completed. Object loaded from file: " + filePath);
return deserializedObj;
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
} catch (NoSuchAlgorithmException | InvalidKeyException | SignatureException e) {
throw new RuntimeException(e);
}
}

要不要加 Serializable

到了最终的问题,一个对象或者实体类,到底需不需要实现 Serializable 接口呢?

从前面的了解可知,实现 Serializable 的目的是为了 Java 的序列化与反序列化,那么如果是 Java 与 Python,或者 Java 与 C++ 之间的网络传输呢?其根本就是要看序列化的方式,如果使用了 Java 自身的序列化方案,那么就需要实现 Serializable ,否则就不必

拿分布式系统中的 SpringCloudDuboo 来说

Spring Cloud 的 Feign 使用了一种自定义的序列化和反序列化机制来传输数据。默认情况下,Feign 使用基于 JSON 的序列化。也可以选择其他序列化格式,如 XML,或者自定义序列化/反序列化机制。因此,不需要在远程调用的请求和响应对象上实现 Serializable 接口

Dubbo 也提供了自定义的序列化和反序列化机制,通常使用 Hessian、Protobuf、或其他序列化框架。也可以选择其他适合需求的序列化方式。Dubbo 的序列化机制不要求对象实现 Serializable 接口

再比如常见的 ORM 框架

Hibernate 提供了自定义的对象到关系数据库表的映射机制。Hibernate 使用自己的序列化机制来将对象持久化到数据库,而不依赖于 Java 自带的序列化。对象可以映射到数据库表,并且可以通过自定义的 SQL 或 HQL(Hibernate Query Language)进行操作

MyBatis 将 SQL 语句与 Java 对象进行映射。MyBatis 通常不依赖于 Java 自带的序列化。它通过 SQL 映射文件或注解将查询语句映射到 Java 方法,然后使用自定义的机制将查询结果映射到 Java 对象