Java中的序列化和反序列化
我们来详细、系统地讲解一下 Java 中的序列化(Serialization)和反序列化(Deserialization)。
1. 什么是序列化和反序列化?
想象一下,你正在玩一个游戏,需要保存进度。游戏中的角色、装备、等级等信息都存储在内存中的 Java 对象里。当你关闭游戏时,内存会被清空,这些对象就消失了。为了下次能从同一个地方继续,你需要把这些对象的状态“冻结”起来,存到一个文件里。这个“冻结”的过程就是 序列化。
当你再次打开游戏,程序需要从文件中读取这些“冻结”的状态,并在内存中重新创建出原来的对象。这个“恢复”的过程就是 反序列化。
核心定义:
- 序列化 (Serialization): 将 Java 对象的状态信息转换为可以存储或传输的格式(通常是字节序列/字节流)的过程。这个字节流可以被写入文件、存入数据库,或者通过网络发送到另一台计算机。
- 反序列化 (Deserialization): 从字节流中重新构建出原始 Java 对象的过程。它是序列化的逆过程。
2. 为什么要使用序列化?
序列化主要用于以下场景:
- 对象持久化 (Persistence): 将对象的状态保存到磁盘(如文件、数据库),以便在程序下次启动时可以恢复。这是最常见的用途,比如保存应用配置、用户会话、游戏存档等。
- 网络传输 (Communication): 在不同的 Java 应用程序之间(可能在不同的机器上)传递对象。例如,在远程方法调用(RMI, Remote Method Invocation)中,客户端将方法参数对象序列化后发送给服务器,服务器执行后将结果对象序列化后返回给客户端。
- 缓存 (Caching): 将一些计算成本高昂但不经常变化的对象序列化后存入缓存(如 Redis、Memcached),下次需要时直接反序列化获取,而无需重新计算。
3. 如何实现 Java 序列化?
在 Java 中实现序列化非常简单,主要涉及以下两个核心组件:
A. java.io.Serializable 接口
这是一个 标记接口(Marker Interface),它本身没有任何方法。一个类只需要实现这个接口,就等于向 JVM 声明:“我这个类的对象是可以被序列化的”。
import java.io.Serializable;
public class User implements Serializable {
// ...
}
如果一个类实现了 Serializable 接口,那么它的所有非静态、非瞬时(transient)的字段都会被自动序列化。
B. ObjectOutputStream 和 ObjectInputStream
这两个类是处理对象序列化和反序列化的关键 I/O 流。
ObjectOutputStream: 用于将对象写入输出流(如文件流),即执行 序列化 操作。核心方法是writeObject(Object obj)。ObjectInputStream: 用于从输入流(如文件流)中读取字节并重建对象,即执行 反序列化 操作。核心方法是readObject()。
4. 代码示例
下面是一个完整的序列化和反序列化示例。
步骤 1: 创建一个可序列化的类
import java.io.Serializable;
// 1. 必须实现 Serializable 接口
public class User implements Serializable {
// 2. 强烈建议显式声明 serialVersionUID
private static final long serialVersionUID = 1L;
private String name;
private int age;
// 3. 使用 transient 关键字标记不需要序列化的字段
private transient String password; // 密码等敏感信息不应该被序列化
public User(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", age=" + age +
", password='" + password + '\'' + // 注意看反序列化后 password 的值
'}';
}
}
步骤 2: 编写序列化和反序列化的主程序
import java.io.*;
public class SerializationDemo {
public static void main(String[] args) {
// 创建一个 User 对象
User user = new User("Alice", 30, "123456");
String filename = "user.ser"; // 序列化后的文件名,通常以 .ser 结尾
// --- 1. 序列化过程 ---
System.out.println("开始序列化...");
try (FileOutputStream fileOut = new FileOutputStream(filename);
ObjectOutputStream out = new ObjectOutputStream(fileOut)) {
out.writeObject(user); // 将 user 对象写入文件
System.out.println("对象已序列化到 " + filename);
System.out.println("原始对象: " + user);
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("\n-----------------------------------\n");
// --- 2. 反序列化过程 ---
System.out.println("开始反序列化...");
User deserializedUser = null;
try (FileInputStream fileIn = new FileInputStream(filename);
ObjectInputStream in = new ObjectInputStream(fileIn)) {
deserializedUser = (User) in.readObject(); // 从文件读取并重建对象
System.out.println("对象已从 " + filename + " 反序列化");
System.out.println("反序列化后的对象: " + deserializedUser);
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
运行结果:
开始序列化...
对象已序列化到 user.ser
原始对象: User{name='Alice', age=30, password='123456'}
-----------------------------------
开始反序列化...
对象已从 user.ser 反序列化
反序列化后的对象: User{name='Alice', age=30, password='null'}
结果分析:
name和age字段被成功保存和恢复。password字段因为被transient修饰,没有参与序列化,所以在反序列化后它的值为null(对象的默认初始值)。
5. 重要的关键字和概念
a. transient 关键字
- 作用: 阻止某个字段被序列化。
- 用途:
- 安全: 像密码、密钥这类敏感信息不应被序列化到文件中。
- 无关数据: 某些字段是从其他字段计算得来的,或者代表一个临时的、不可序列化的资源(如数据库连接、线程池),这些字段不需要序列化。
b. serialVersionUID (序列版本号)
- 作用: 它是类的版本控制标识符。在反序列化时,JVM 会检查文件中的
serialVersionUID是否与当前 classpath 中该类的serialVersionUID一致。 - 为什么重要:
- 如果不一致: JVM 会抛出
InvalidClassException异常,反序列化失败。这可以防止因类的版本不兼容导致的数据错乱问题。 - 如果不显式定义: JVM 会根据类的结构(字段、方法等)自动生成一个。这样做很危险,因为只要你对类做了任何微小的改动(比如增加一个辅助方法),自动生成的
serialVersionUID就可能改变,导致旧的序列化数据无法被新版本的类反序列化。
- 如果不一致: JVM 会抛出
- 最佳实践: 强烈建议 在所有可序列化的类中显式声明一个
private static final long serialVersionUID。通常初始值设为1L即可。如果你做了不兼容的类修改(比如删除了一个字段),则需要手动增加这个版本号。
c. 静态字段(static)
静态字段属于类,不属于任何对象实例,因此 静态字段不会被序列化。
d. 继承中的序列化
- 如果一个父类实现了
Serializable,那么它的所有子类都自动是可序列化的。 - 如果一个子类实现了
Serializable,但其父类没有,那么:- 子类的字段可以被序列化。
- 父类的字段 不会 被序列化。
- 在反序列化时,JVM 会调用父类的 无参构造函数 来初始化父类的部分。因此,这种情况下,父类 必须有一个无参构造函数,否则反序列化会失败。
6. 优缺点和替代方案
优点
- 简单易用: Java 原生支持,只需实现一个接口,无需引入任何第三方库。
- 功能强大: 能自动处理复杂的对象图(对象引用、循环引用等)。
缺点
- 语言绑定: 序列化后的数据格式是 Java 特有的,只能被 Java 程序识别。不适合用于跨语言通信。
- 版本兼容性问题:
serialVersionUID的管理需要格外小心,否则容易出现兼容性问题。 - 安全性风险: 反序列化来自 不可信来源 的数据是一个严重的安全漏洞。攻击者可以构造恶意的字节流,在反序列化时执行任意代码(称为“反序列化攻击”)。
- 性能和空间: Java 序列化产生的字节流通常比较臃肿,性能也不如一些现代的序列化框架。
现代替代方案
由于上述缺点,在很多现代应用(特别是分布式系统和微服务)中,开发者更倾向于使用以下替代方案:
- JSON (Jackson, Gson): 人类可读,语言无关,是 Web API 和微服务通信的事实标准。
- Protocol Buffers (Protobuf): Google 开发的高性能、紧凑的二进制序列化协议,跨语言支持极佳,广泛用于 RPC 框架(如 gRPC)。
- Avro: Apache Hadoop 生态系统中的序列化系统,支持丰富的模式演进。
- XML (JAXB): 曾经很流行,但因其冗长而逐渐被 JSON 取代。
总结
Java 原生序列化是一个强大且方便的功能,非常适合用于纯 Java 环境下的对象持久化和简单通信。但在构建需要跨语言、高性能或高安全性的现代分布式系统时,更推荐使用 JSON 或 Protocol Buffers 等更为通用的解决方案。理解其工作原理、特别是 transient 和 serialVersionUID 的作用,对于编写健壮的 Java 应用至关重要。