前言
最近闲来无事,翻着以前写的代码,突然发现自己对
Java序列化这件事,竟然一直是“用得多、懂得少”。于是干脆静下心来,想和大家聊聊这个看似不起眼、实则暗藏玄机的小家伙——Java序列化。别担心,这不是那种背八股文的文章,我们来点轻松的、能真正讲明白的。
1. 序列化基础概念
1.1 什么是序列化
在 Java 世界中,所有的数据操作最终都会归结为字节流的处理。Java 对象存在于内存中,是一个复杂的数据结构,包含类型信息、实例变量、方法引用等内容。序列化就是将这种复杂的内存对象转换为可传输、可存储的字节序列的过程。
[!info] 序列化的本质理解
序列化可以理解为将一个"活着的"对象(存在于内存中)转换为"冬眠状态"(字节序列),之后可以随时"唤醒"(反序列化)恢复到原来的状态。
这个过程类似于将一个三维立体物体压缩成二维的图纸,然后可以根据图纸重新构建出原来的立体物体。
序列化(Serialization)
将 Java 对象转换为字节序列的过程。这个过程会记录对象的类型信息、实例变量的值、以及对象之间的引用关系,最终生成一个字节流。
反序列化(Deserialization)
将字节序列恢复为 Java 对象的过程。这个过程会根据字节流中记录的信息,重新构建出与原对象状态完全一致的新对象。
对象首先被序列化为字节序列,之后可以通过反序列化恢复为对象。整个过程保证了对象状态的完整性。
1.2 序列化的应用场景
序列化技术在 Java 开发中有着广泛的应用场景,理解这些场景有助于我们更好地掌握序列化的使用。
当系统需要将对象的状态保存下来,或者在不同的 JVM 之间传递对象时,就需要使用序列化技术。序列化将对象转换为字节流,这个字节流可以保存到文件、数据库,或者通过网络发送到远程主机。
[!tip] 序列化在实际项目中的典型应用
场景一:远程方法调用(RMI)
当客户端调用远程服务器上的方法时,需要将方法参数对象通过网络传输到服务器。这时就需要将参数对象序列化为字节流,通过网络发送,服务器接收后再反序列化为对象。
场景二:分布式系统通信
在微服务架构中,不同服务之间需要传递复杂的业务对象。使用序列化技术可以将对象转换为字节流,通过 HTTP、RPC 等协议传输。
场景三:Session 持久化
在分布式 Web 应用中,用户的 Session 数据需要在多台服务器之间共享。将 Session 对象序列化后存储到 Redis 等缓存系统中,可以实现 Session 的跨服务器访问。
场景四:对象缓存
将常用的对象序列化后缓存到文件或内存中,下次使用时直接反序列化,避免重复创建对象,提高系统性能。
场景五:对象深拷贝
通过序列化和反序列化可以实现对象的深拷贝,创建一个与原对象完全独立的副本。
2. 序列化的核心作用
2.1 数据持久化
数据持久化是指将内存中的对象保存到磁盘等持久化存储介质上,使得对象的生命周期超越程序的运行周期。程序运行时,对象存在于内存中。一旦程序关闭,内存中的对象就会消失。通过序列化,我们可以将对象的状态保存到文件中,下次程序启动时再恢复这些对象,实现数据的永久保存。
游戏存档系统就是数据持久化的经典应用。玩家的角色信息、装备、任务进度等数据都会被序列化保存到本地文件,下次进入游戏时自动加载。
import java.io.*;
import java.time.LocalDateTime;
import java.util.List;
/**
* 游戏角色类
* 实现 Serializable 接口以支持存档功能
*/
public class GameCharacter implements Serializable {
// 序列化版本号,用于版本控制
private static final long serialVersionUID = 1L;
private String name; // 角色名称
private int level; // 角色等级
private int health; // 当前生命值
private List
private LocalDateTime lastSaveTime; // 最后保存时间
/**
* 保存游戏进度到文件
* 将当前角色对象序列化到指定文件
*/
public void saveGame(String filePath) {
// try-with-resources 自动关闭流资源
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(filePath))) {
// 记录保存时间
this.lastSaveTime = LocalDateTime.now();
// 将整个对象序列化到文件
oos.writeObject(this);
System.out.println("游戏进度已保存:" + filePath);
} catch (IOException e) {
System.err.println("保存失败:" + e.getMessage());
}
}
/**
* 从文件加载游戏进度
* 将字节序列反序列化为角色对象
*/
public static GameCharacter loadGame(String filePath) {
// try-with-resources 自动关闭流资源
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream(filePath))) {
// 从文件读取字节序列并反序列化为对象
GameCharacter character = (GameCharacter) ois.readObject();
System.out.println("游戏进度已加载,上次保存时间:" +
character.lastSaveTime);
return character;
} catch (IOException | ClassNotFoundException e) {
System.err.println("加载失败:" + e.getMessage());
return null;
}
}
}
saveGame 方法将角色对象序列化到文件,loadGame 方法从文件反序列化恢复角色对象。通过序列化机制,游戏数据得以永久保存。
2.2 网络传输
在分布式系统中,不同进程、不同主机之间需要传递对象数据。由于网络只能传输字节流,所以必须将对象序列化后才能在网络中传输。网络协议(TCP/IP)只能传输字节数据,无法直接传输 Java 对象。序列化提供了一种标准化的方式,将对象转换为字节流进行传输,接收方再反序列化恢复对象。
客户端与服务器之间通过序列化进行通信的完整流程。请求和响应都经过了序列化-传输-反序列化的过程。
import java.io.*;
import java.net.Socket;
import java.time.LocalDateTime;
/**
* 用户登录请求对象
* 客户端将此对象序列化后发送给服务器
*/
public class LoginRequest implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String username; // 用户名
private String password; // 密码(实际应加密)
private String deviceId; // 设备标识
private LocalDateTime timestamp; // 请求时间戳
}
/**
* 用户登录响应对象
* 服务器将此对象序列化后返回给客户端
*/
public class LoginResponse implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private boolean success; // 登录是否成功
private String token; // 认证令牌
private String message; // 提示消息
private UserInfo userInfo; // 用户信息(需要实现 Serializable)
}
/**
* 模拟客户端发送登录请求
*/
public class ClientDemo {
public static void sendLoginRequest(String serverHost, int port) {
// 建立 Socket 连接并创建输入输出流
try (Socket socket = new Socket(serverHost, port);
ObjectOutputStream oos = new ObjectOutputStream(
socket.getOutputStream());
ObjectInputStream ois = new ObjectInputStream(
socket.getInputStream())) {
// 创建登录请求对象
LoginRequest request = new LoginRequest();
request.setUsername("scholar");
request.setPassword("123456");
request.setDeviceId("DEVICE-001");
request.setTimestamp(LocalDateTime.now());
// 序列化请求对象并发送
oos.writeObject(request);
oos.flush();
System.out.println("登录请求已发送");
// 接收并反序列化响应对象
LoginResponse response = (LoginResponse) ois.readObject();
System.out.println("登录结果:" +
(response.isSuccess() ? "成功" : "失败"));
if (response.isSuccess()) {
System.out.println("认证令牌:" + response.getToken());
}
} catch (IOException | ClassNotFoundException e) {
System.err.println("通信异常:" + e.getMessage());
}
}
}
客户端创建请求对象并序列化发送,服务器处理后将响应对象序列化返回。这展示了在网络通信中使用序列化的完整流程。
[!warning] 网络传输序列化的注意事项
在实际项目中使用 Java 原生序列化进行网络传输时需要注意:
安全性问题:不要序列化敏感信息(如明文密码),传输前应加密兼容性问题:客户端和服务器必须拥有相同版本的类定义性能问题:Java 原生序列化效率较低,生产环境建议使用 Protobuf、Hessian 等高效框架防火墙问题:序列化数据可能被某些安全设备拦截,需要配置白名单
2.3 深拷贝实现
Java 中的对象拷贝分为浅拷贝和深拷贝。浅拷贝只复制对象本身,对象内部的引用类型字段仍然指向原来的对象。深拷贝则会递归复制所有引用对象,创建一个完全独立的副本。
在某些场景下,我们需要创建一个与原对象完全独立的副本,修改副本不会影响原对象。例如在多线程环境中,为了避免并发修改问题,可以为每个线程创建对象的深拷贝。通过将对象序列化为字节流,再反序列化为新对象,可以轻松实现深拷贝。这种方式简单可靠,无需手动递归复制每个字段。
import java.io.*;
import java.util.*;
/**
* 地址信息类
*/
public class Address implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String province; // 省份
private String city; // 城市
private String street; // 街道
}
/**
* 用户信息类
* 演示深拷贝的使用
*/
public class User implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String name; // 姓名
private int age; // 年龄
private Address address; // 地址(引用类型)
private List
/**
* 使用序列化实现深拷贝
* 返回一个与当前对象完全独立的副本
*/
public User deepClone() {
// 使用字节数组输出流暂存序列化数据
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
// 将当前对象序列化到字节数组
oos.writeObject(this);
// 从字节数组反序列化出新对象
try (ByteArrayInputStream bais =
new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais)) {
// 返回的是全新的对象,与原对象完全独立
return (User) ois.readObject();
}
} catch (IOException | ClassNotFoundException e) {
throw new RuntimeException("深拷贝失败", e);
}
}
}
deepClone 方法通过序列化和反序列化创建了一个完全独立的对象副本。修改副本不会影响原对象,因为副本内部的所有引用类型字段也是全新创建的。
[!tip] 深拷贝的其他实现方式
除了使用序列化,还有以下几种深拷贝实现方式:
手动复制:在类中实现 Cloneable 接口,重写 clone() 方法,递归复制所有引用字段拷贝构造器:提供一个接收相同类型对象的构造器,手动复制所有字段JSON 序列化:使用 Jackson、Gson 等将对象序列化为 JSON,再反序列化为新对象第三方库:使用 Apache Commons Lang 的 SerializationUtils.clone()
各种方式的性能和适用场景不同,需要根据实际情况选择。
3. 实现 Serializable 接口
3.1 基础序列化实现
Serializable 是 Java 提供的标记接口,用于标识一个类的对象可以被序列化。这是最简单、最常用的序列化实现方式。实现 Serializable 接口非常简单,只需要在类声明时添加 implements Serializable 即可,无需实现任何方法。
import java.io.Serializable;
/**
* 学生类
* 实现 Serializable 接口使其对象可以被序列化
*/
public class Student implements Serializable {
// 序列化版本号(建议显式声明)
private static final long serialVersionUID = 1L;
private String name; // 学生姓名
private int age; // 学生年龄
private String studentId; // 学号
/**
* 有参构造器
* 注意:序列化时会调用此构造器
* 反序列化时不会调用构造器
*/
public Student(String name, int age, String studentId) {
System.out.println("Student 构造器被调用");
this.name = name;
this.age = age;
this.studentId = studentId;
}
@Override
public String toString() {
return "Student{" +
"name='" + name + '\'' +
", age=" + age +
", studentId='" + studentId + '\'' +
'}';
}
}
这个类定义了一个可序列化的 Student 类。注意几个关键点:实现了 Serializable 接口;声明了 serialVersionUID 版本号;构造器中打印了日志,用于观察反序列化时是否调用构造器。
[!info] Serializable 接口的特点
Serializable 是一个标记接口(Marker Interface),内部没有定义任何方法:
public interface Serializable {
// 空接口,仅用于标记
}
标记接口的作用是向 JVM 声明:“这个类的对象可以被序列化”。JVM 在运行时会检查对象是否实现了此接口,如果没有则抛出 NotSerializableException 异常。
3.2 序列化流程详解
序列化过程是将 Java 对象转换为字节序列并写入输出流。Java 提供了 ObjectOutputStream 类来完成这个任务。序列化的核心步骤包括:创建文件输出流 FileOutputStream;基于文件流创建对象输出流 ObjectOutputStream;调用 writeObject() 方法写入对象;关闭流释放资源。
import java.io.*;
/**
* 序列化工具类
*/
public class SerializationUtil {
/**
* 将对象序列化到文件
* obj 要序列化的对象
* filePath 目标文件路径
*/
public static void serialize(Object obj, String filePath) {
// 使用 try-with-resources 自动关闭流
try (FileOutputStream fos = new FileOutputStream(filePath);
ObjectOutputStream oos = new ObjectOutputStream(fos)) {
// 将对象写入输出流,自动转换为字节序列
oos.writeObject(obj);
// 刷新缓冲区,确保数据写入磁盘
oos.flush();
System.out.println("对象序列化成功:" + filePath);
} catch (FileNotFoundException e) {
System.err.println("文件路径不存在:" + filePath);
e.printStackTrace();
} catch (IOException e) {
System.err.println("序列化过程发生 IO 异常");
e.printStackTrace();
}
}
/**
* 演示序列化过程
*/
public static void main(String[] args) {
// 创建学生对象
Student student = new Student("张三", 20, "2021001");
System.out.println("原始对象:" + student);
// 序列化到文件
serialize(student, "student.ser");
}
}
执行这段代码,输出结果为:
Student 构造器被调用
原始对象:Student{name='张三', age=20, studentId='2021001'}
对象序列化成功:student.ser
从输出可以看到,序列化时调用了构造器创建对象,然后成功将对象写入文件。序列化后的文件是二进制格式,不是人类可读的文本。文件内容包括:魔数(Magic Number)AC ED 标识这是一个 Java 序列化文件;版本号:序列化协议版本;类描述信息:类名、serialVersionUID、字段信息等;对象数据:各个字段的值。
序列化的完整流程。JVM 会检查对象是否可序列化,然后递归处理所有字段,最终生成字节序列写入文件。
[!warning] 序列化可能抛出的异常
在序列化过程中可能遇到以下异常:
NotSerializableException:对象或其字段未实现 Serializable 接口InvalidClassException:类的版本不兼容(serialVersionUID 不匹配)IOException:文件写入失败、磁盘空间不足等 IO 问题
建议在开发时做好异常处理,记录详细日志便于排查问题。
3.3 反序列化流程详解
反序列化是序列化的逆过程,从字节序列恢复 Java 对象。Java 提供了 ObjectInputStream 类来完成这个任务。反序列化的核心步骤包括:创建文件输入流 FileInputStream;基于文件流创建对象输入流 ObjectInputStream;调用 readObject() 方法读取对象;将返回值强制转换为目标类型;关闭流释放资源。
/**
* 反序列化工具类
*/
public class DeserializationUtil {
/**
* 从文件反序列化对象
* filePath 源文件路径
* clazz 目标类的 Class 对象
* return 反序列化得到的对象
*/
@SuppressWarnings("unchecked")
public static
// 使用 try-with-resources 自动关闭流
try (FileInputStream fis = new FileInputStream(filePath);
ObjectInputStream ois = new ObjectInputStream(fis)) {
// 从输入流读取对象,自动从字节序列恢复
Object obj = ois.readObject();
// 强制类型转换为目标类型
if (clazz.isInstance(obj)) {
System.out.println("对象反序列化成功:" + filePath);
return clazz.cast(obj);
} else {
throw new ClassCastException(
"反序列化的对象类型不匹配,期望:" + clazz.getName() +
",实际:" + obj.getClass().getName());
}
} catch (FileNotFoundException e) {
System.err.println("文件不存在:" + filePath);
e.printStackTrace();
} catch (IOException e) {
System.err.println("反序列化过程发生 IO 异常");
e.printStackTrace();
} catch (ClassNotFoundException e) {
System.err.println("找不到类定义,请检查 classpath");
e.printStackTrace();
}
return null;
}
/**
* 演示反序列化过程
*/
public static void main(String[] args) {
// 从文件反序列化对象
Student student = deserialize("student.ser", Student.class);
if (student != null) {
System.out.println("反序列化得到的对象:" + student);
}
}
}
执行这段代码,输出结果为:
对象反序列化成功:student.ser
反序列化得到的对象:Student{name='张三', age=20, studentId='2021001'}
仔细观察输出,你会发现反序列化时没有打印"Student 构造器被调用"。这说明反序列化是通过特殊机制直接恢复对象状态的,并不会调用类的构造器。这个特性有重要意义:构造器中的参数校验逻辑在反序列化时不会执行;构造器中的初始化逻辑(如设置默认值)在反序列化时不会执行;反序列化可以绕过构造器的私有访问控制,这对单例模式是个隐患。
反序列化的完整流程。JVM 会检查版本号,然后通过反射机制直接创建对象,恢复字段值,整个过程不经过构造器。
[!warning] 反序列化的安全风险
反序列化不调用构造器这个特性带来了安全隐患:
绕过单例模式:即使构造器是私有的,反序列化仍然可以创建新实例绕过参数校验:构造器中的参数验证逻辑不会执行反序列化攻击:恶意构造的字节流可能创建危险对象
解决方案将在后续章节详细介绍。
4. 序列化版本控制
4.1 serialVersionUID 的作用
在实际项目中,类的定义会随着需求变化而升级。序列化版本控制机制确保不同版本的类之间可以正确地序列化和反序列化。
serialVersionUID 是序列化版本号,用于验证序列化对象和类定义的兼容性。它的工作原理是这样的:序列化时,JVM 会将类的 serialVersionUID 写入字节流,与对象数据一起保存。反序列化时,JVM 会比较字节流中的 serialVersionUID 与当前类定义的 serialVersionUID。如果两者不一致,抛出 InvalidClassException 异常。
/**
* 版本1:初始版本
*/
public class Product implements Serializable {
// 显式声明版本号
private static final long serialVersionUID = 1L;
private String name; // 产品名称
private double price; // 产品价格
}
/**
* 版本2:添加了新字段
*/
public class Product implements Serializable {
// 版本号保持不变,表示兼容旧版本
private static final long serialVersionUID = 1L;
private String name; // 产品名称
private double price; // 产品价格
private String category; // 产品分类(新增字段)
private LocalDateTime createTime; // 创建时间(新增字段)
}
两个版本的 Product 类保持相同的 serialVersionUID,表示它们是兼容的。用版本1序列化的对象,可以用版本2反序列化;反之亦然。
[!info] 显式声明 serialVersionUID 的重要性
如果不显式声明 serialVersionUID,JVM 会根据类的结构自动生成一个版本号。这个自动生成的版本号非常敏感,类的任何微小变化(如添加方法、修改注释)都会导致版本号改变,从而导致反序列化失败。
自动生成的问题:
不同 JVM 实现可能生成不同的版本号类的微小变化会导致版本号变化无法控制版本兼容性
显式声明的好处:
可以主动控制版本兼容性避免因 JVM 差异导致的问题提高代码的可移植性
4.2 版本号维护规则
在项目迭代过程中,如何维护 serialVersionUID 是一门艺术。遵循正确的规则可以最大化兼容性,减少升级带来的问题。
以下情况修改类时,无需修改版本号:
规则一:仅修改方法,无需修改版本号
如果只是修改了类的方法实现、添加了新方法,或者修改了方法签名,这些变化不影响对象的状态,因此无需修改 serialVersionUID。
/**
* 修改前
*/
public class Order implements Serializable {
// 版本号
private static final long serialVersionUID = 1L;
private String orderId;
private double totalAmount;
// 原始折扣计算方法
public double calculateDiscount() {
return totalAmount * 0.1; // 10% 折扣
}
}
/**
* 修改后:调整了方法实现
* 版本号保持不变
*/
public class Order implements Serializable {
// 版本号保持不变
private static final long serialVersionUID = 1L;
private String orderId;
private double totalAmount;
// 修改了折扣计算逻辑
public double calculateDiscount() {
if (totalAmount > 1000) {
return totalAmount * 0.2; // 大额订单 20% 折扣
} else {
return totalAmount * 0.1; // 普通订单 10% 折扣
}
}
// 添加了新方法
public String getOrderStatus() {
return "PENDING";
}
}
方法的变化不影响序列化和反序列化过程,因为序列化只保存对象的状态(字段值),不保存方法。
规则二:修改 static 或 transient 字段,无需修改版本号
static 字段属于类而非对象,不会被序列化。transient 字段被显式标记为不序列化。因此修改这些字段不影响序列化兼容性。
/**
* 修改前
*/
public class Config implements Serializable {
// 版本号
private static final long serialVersionUID = 1L;
private static final int MAX_RETRY = 3; // 静态常量
private transient Connection connection; // 临时连接对象
private String configName;
private String configValue;
}
/**
* 修改后:修改了 static 和 transient 字段
* 版本号保持不变
*/
public class Config implements Serializable {
// 版本号保持不变
private static final long serialVersionUID = 1L;
private static final int MAX_RETRY = 5; // 修改了静态常量
private transient Connection connection; // 保持不变
private transient Logger logger = Logger.getLogger(); // 新增 transient 字段
private String configName;
private String configValue;
}
静态字段和临时字段的变化不会影响对象的序列化状态,因此版本号无需修改。
以下情况修改类时,必须修改版本号:
规则三:修改字段类型,必须修改版本号
如果修改了实例字段的类型,会导致反序列化失败,因为字节流中保存的数据类型与类定义不匹配。此时必须修改 serialVersionUID,明确标识这是一个不兼容的版本。
/**
* 修改前
*/
public class Employee implements Serializable {
// 版本号
private static final long serialVersionUID = 1L;
private String employeeId;
private int salary; // 整数类型
}
/**
* 修改后:修改了字段类型
* 必须修改版本号
*/
public class Employee implements Serializable {
// 版本号递增,标识不兼容变更
private static final long serialVersionUID = 2L;
private String employeeId;
private double salary; // 改为浮点类型
}
如果不修改版本号,反序列化时会抛出 InvalidClassException:
java.io.InvalidClassException: Employee; incompatible types for field salary
以下情况修改类时,可以保持兼容:
规则四:删除字段,可以兼容无需修改版本号
删除字段不会导致反序列化失败。反序列化时,字节流中该字段的数据会被忽略,其他字段正常恢复。
/**
* 修改前
*/
public class Account implements Serializable {
// 版本号
private static final long serialVersionUID = 1L;
private String accountId;
private String accountName;
private String accountType; // 账户类型
private double balance;
}
/**
* 修改后:删除了 accountType 字段
* 版本号保持不变,仍然兼容
*/
public class Account implements Serializable {
// 版本号保持不变
private static final long serialVersionUID = 1L;
private String accountId;
private String accountName;
private double balance;
// 删除了 accountType 字段
}
反序列化旧版本的对象时,accountType 字段的数据会被忽略,不会影响其他字段。
规则五:添加字段,可以兼容无需修改版本号
添加新字段也不会导致反序列化失败。反序列化旧版本的对象时,新字段会被赋予默认值(对象类型为 null,数值类型为 0,布尔类型为 false)。
/**
* 修改前
*/
public class Customer implements Serializable {
// 版本号
private static final long serialVersionUID = 1L;
private String customerId;
private String customerName;
private String phone;
}
/**
* 修改后:添加了新字段
* 版本号保持不变,仍然兼容
*/
public class Customer implements Serializable {
// 版本号保持不变
private static final long serialVersionUID = 1L;
private String customerId;
private String customerName;
private String phone;
private String email; // 新增字段
private LocalDateTime registerTime; // 新增字段
}
反序列化旧版本的对象时,email 和 registerTime 字段的值为 null。
[!tip] 版本维护的最佳实践
总是显式声明 serialVersionUID,不要依赖自动生成初始版本从 1L 开始,每次不兼容变更递增(2L、3L…)兼容性变更无需修改版本号,包括:添加字段、删除字段、修改方法不兼容变更必须修改版本号,包括:修改字段类型、修改类层次结构在代码注释中记录版本变更历史,便于后续维护
示例:
/**
* 用户信息类
*
* 版本历史:
* - 1L: 初始版本 (2024-01-01)
* - 2L: 修改 age 字段从 int 改为 Integer (2024-03-15)
* - 3L: 修改 birthday 字段从 String 改为 LocalDate (2024-06-20)
*/
public class User implements Serializable {
private static final long serialVersionUID = 3L;
// ...
}
5. 序列化的继承与引用
5.1 父类序列化要求
当一个类实现了 Serializable 接口时,它的父类可能实现或未实现该接口。不同的情况下,序列化的行为不同。
情况一:父类也实现了 Serializable
这是最简单的情况。父类和子类的所有非 transient、非 static 字段都会被序列化。
/**
* 父类:实现了 Serializable
*/
public class Animal implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
protected String species; // 物种
protected int age; // 年龄
}
/**
* 子类:继承父类的 Serializable
*/
public class Dog extends Animal {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String name; // 姓名
private String breed; // 品种
}
序列化 Dog 对象时,父类 Animal 的 species 和 age 字段,以及子类 Dog 的 name 和 breed 字段都会被序列化。
情况二:父类未实现 Serializable
如果父类未实现 Serializable,那么父类的字段不会被序列化。反序列化时,父类的字段会通过父类的无参构造器初始化。
/**
* 父类:未实现 Serializable
*/
public class Person {
protected String name; // 姓名
protected int age; // 年龄
/**
* 无参构造器(必须提供)
* 反序列化时会调用此构造器初始化父类字段
*/
public Person() {
System.out.println("Person 无参构造器被调用");
this.name = "未知";
this.age = 0;
}
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
/**
* 子类:实现了 Serializable
*/
public class Teacher extends Person implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String subject; // 任教科目
private String employeeId; // 工号
public Teacher() {
super();
}
public Teacher(String name, int age, String subject, String employeeId) {
super(name, age);
this.subject = subject;
this.employeeId = employeeId;
}
}
测试序列化行为时会发现,反序列化后父类字段的值丢失了,被重置为构造器中的默认值。从输出可以看到:反序列化时调用了父类的无参构造器;父类字段 name 和 age 的值丢失了,被重置为构造器中的默认值;子类字段 subject 和 employeeId 正常恢复。
[!warning] 父类未实现 Serializable 的注意事项
当父类未实现 Serializable 时,必须注意以下几点:
父类必须有无参构造器:否则反序列化时会抛出 InvalidClassException父类字段的值会丢失:反序列化时使用无参构造器的默认值考虑数据完整性:如果父类字段很重要,应该让父类也实现 Serializable
如果父类是第三方库的类(无法修改),可以在子类中使用自定义序列化(writeObject 和 readObject 方法)手动保存和恢复父类字段的值。
5.2 引用对象序列化
当一个类的字段是另一个类的对象时,这个被引用的对象也必须是可序列化的,否则会抛出 NotSerializableException。
/**
* 教师类(必须实现 Serializable)
*/
public class Teacher implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String name;
private String subject;
}
/**
* 学生类(引用了 Teacher 对象)
*/
public class Student implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String name;
private int age;
private Teacher advisor; // 引用 Teacher 对象(导师)
}
序列化 Student 对象时,JVM 会递归序列化 advisor 字段引用的 Teacher 对象。反序列化时,也会递归恢复整个对象图。引用的 Teacher 对象被正确序列化和反序列化。
如果 Teacher 类没有实现 Serializable,序列化 Student 对象时会抛出异常:
Exception in thread "main" java.io.NotSerializableException: Teacher
at java.io.ObjectOutputStream.writeObject0(ObjectOutputStream.java:1184)
...
[!tip] 解决引用对象不可序列化的问题
如果引用的对象是第三方类,无法让它实现 Serializable,可以使用以下方法:
方法一:使用 transient 关键字
标记该字段为 transient,不序列化它。反序列化后该字段为 null。
private transient Teacher advisor; // 不序列化此字段
方法二:自定义序列化逻辑
使用 writeObject 和 readObject 方法,手动将引用对象的关键信息序列化为基本类型。
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // 序列化其他字段
// 手动序列化 Teacher 对象的关键信息
out.writeUTF(advisor.getName());
out.writeUTF(advisor.getSubject());
}
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject(); // 反序列化其他字段
// 手动恢复 Teacher 对象
String name = in.readUTF();
String subject = in.readUTF();
this.advisor = new Teacher(name, subject);
}
5.3 集合类序列化
Java 集合框架中的常用集合类(如 ArrayList、HashMap、HashSet 等)都实现了 Serializable 接口,可以直接序列化。虽然集合本身是可序列化的,但集合中存储的元素对象也必须实现 Serializable,否则序列化会失败。
/**
* 课程类
*/
public class Course implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String courseId;
private String courseName;
private int credits;
}
/**
* 班级类(包含课程列表)
*/
public class ClassRoom implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String className;
private List
private Set
private Map
}
所有集合数据都被正确序列化和反序列化。
[!info] 集合序列化的内部机制
Java 集合类在序列化时会进行优化:
ArrayList:只序列化实际存储的元素,不序列化数组的未使用部分HashMap:序列化键值对数据,不序列化底层的哈希表结构LinkedList:序列化节点数据,重新构建链表结构
这些集合类都实现了自定义的 writeObject 和 readObject 方法来优化序列化性能。
6. 序列化算法机制
6.1 序列化编号机制
Java 序列化使用一套复杂的算法来处理对象引用和重复序列化问题。核心是为每个序列化的对象分配一个唯一的序列化编号。
序列化编号的作用包括:识别已序列化的对象,避免重复序列化同一个对象;保持对象引用关系,正确恢复对象之间的引用关系;处理循环引用,防止循环引用导致的无限递归。
序列化编号机制的工作流程。每个对象第一次序列化时分配编号并输出完整数据,后续遇到相同对象时只输出编号。
/**
* 部门类
*/
public class Department implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String deptName;
public Department(String deptName) {
this.deptName = deptName;
}
@Override
public String toString() {
return "Department{deptName='" + deptName + "'}";
}
}
/**
* 员工类
*/
public class Employee implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String name;
private Department department; // 所属部门
public Employee(String name, Department department) {
this.name = name;
this.department = department;
}
}
/**
* 测试对象引用序列化
*/
public class ReferenceTest {
public static void main(String[] args) throws Exception {
// 创建一个部门对象
Department dept = new Department("技术部");
// 多个员工引用同一个部门对象
Employee emp1 = new Employee("张三", dept);
Employee emp2 = new Employee("李四", dept);
Employee emp3 = new Employee("王五", dept);
// 序列化多个员工
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("employees.ser"))) {
// 第一次序列化 dept 对象,分配编号并输出完整数据
oos.writeObject(emp1);
// dept 对象已序列化,只输出编号
oos.writeObject(emp2);
// dept 对象已序列化,只输出编号
oos.writeObject(emp3);
}
// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("employees.ser"))) {
Employee e1 = (Employee) ois.readObject();
Employee e2 = (Employee) ois.readObject();
Employee e3 = (Employee) ois.readObject();
// 验证三个员工是否引用同一个部门对象
System.out.println("e1 的部门:" + e1.getDepartment());
System.out.println("e2 的部门:" + e2.getDepartment());
System.out.println("e3 的部门:" + e3.getDepartment());
System.out.println("e1.dept == e2.dept: " +
(e1.getDepartment() == e2.getDepartment()));
System.out.println("e2.dept == e3.dept: " +
(e2.getDepartment() == e3.getDepartment()));
}
}
}
输出结果:
e1 的部门:Department{deptName='技术部'}
e2 的部门:Department{deptName='技术部'}
e3 的部门:Department{deptName='技术部'}
e1.dept == e2.dept: true
e2.dept == e3.dept: true
三个员工对象引用的是同一个 Department 对象。序列化编号机制正确保持了对象引用关系。
6.2 重复序列化问题
序列化编号机制虽然高效,但也带来了一个陷阱:多次序列化同一个对象时,只有第一次会输出完整数据,后续只输出编号。这意味着对象状态的修改不会被序列化。
/**
* 重复序列化陷阱演示
*/
public class RepeatedSerializationTrap {
public static void main(String[] args) throws Exception {
// 创建学生对象
Student student = new Student("张三", 20, "S001");
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("student_repeated.ser"))) {
// 第一次序列化
oos.writeObject(student);
System.out.println("第一次序列化:" + student);
// 修改学生年龄
student.setAge(25);
System.out.println("修改后的对象:" + student);
// 第二次序列化(修改后)
oos.writeObject(student);
System.out.println("第二次序列化:" + student);
}
// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("student_repeated.ser"))) {
Student s1 = (Student) ois.readObject();
Student s2 = (Student) ois.readObject();
System.out.println("\n反序列化结果:");
System.out.println("第一个对象:" + s1);
System.out.println("第二个对象:" + s2);
System.out.println("两个对象是否相同:" + (s1 == s2));
System.out.println("第二个对象的年龄(期望25):" + s2.getAge());
}
}
}
输出结果:
第一次序列化:Student{name='张三', age=20, studentId='S001'}
修改后的对象:Student{name='张三', age=25, studentId='S001'}
第二次序列化:Student{name='张三', age=25, studentId='S001'}
反序列化结果:
第一个对象:Student{name='张三', age=20, studentId='S001'}
第二个对象:Student{name='张三', age=20, studentId='S001'}
两个对象是否相同:true
第二个对象的年龄(期望25):20
从输出可以看到,第二次序列化前对象的年龄已经修改为 25,但反序列化后两个对象的年龄都是 20(第一次序列化时的值)。这说明第二次调用 writeObject(student) 时,JVM 发现该对象已经序列化过,只输出了它的序列化编号,并未输出修改后的数据。
[!warning] 重复序列化的陷阱
这个特性在以下场景中可能导致严重问题:
缓存场景:将对象序列化到缓存,修改对象后再次序列化,缓存的数据不会更新日志记录:定期序列化对象状态到日志文件,修改不会被记录状态同步:多次序列化同一对象的不同状态,只有第一次的状态被保存
解决方案:
每次序列化前创建新的 ObjectOutputStream调用 ObjectOutputStream.reset() 方法清除已序列化对象的记录序列化对象的副本而非原对象
解决方案示例:
public class RepeatedSerializationSolution {
public static void main(String[] args) throws Exception {
Student student = new Student("张三", 20, "S001");
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("student_reset.ser"))) {
// 第一次序列化
oos.writeObject(student);
System.out.println("第一次序列化:" + student);
// 修改学生年龄
student.setAge(25);
// 重置序列化状态
oos.reset(); // 清除已序列化对象的记录
// 第二次序列化
oos.writeObject(student);
System.out.println("第二次序列化:" + student);
}
// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("student_reset.ser"))) {
Student s1 = (Student) ois.readObject();
Student s2 = (Student) ois.readObject();
System.out.println("\n反序列化结果:");
System.out.println("第一个对象:" + s1);
System.out.println("第二个对象:" + s2);
System.out.println("第二个对象的年龄:" + s2.getAge());
}
}
}
输出结果:
第一次序列化:Student{name='张三', age=20, studentId='S001'}
第二次序列化:Student{name='张三', age=25, studentId='S001'}
反序列化结果:
第一个对象:Student{name='张三', age=20, studentId='S001'}
第二个对象:Student{name='张三', age=25, studentId='S001'}
第二个对象的年龄:25
调用 reset() 方法后,第二次序列化输出了修改后的完整数据。
7. 自定义序列化控制
7.1 transient 关键字使用
transient 关键字用于标记不需要序列化的字段。被标记为 transient 的字段在序列化时会被忽略,反序列化后的值为默认值(对象类型为 null,数值类型为 0,布尔类型为 false)。
使用场景包括:敏感信息,密码、密钥等不应持久化的数据;临时计算结果,可以根据其他字段重新计算的数据;系统资源,数据库连接、文件句柄等不可序列化的资源;缓存数据,可以重新加载的缓存信息。
import java.io.*;
import java.sql.Connection;
import java.util.UUID;
/**
* 用户账户类
* 演示 transient 的使用
*/
public class UserAccount implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String username; // 用户名(需要序列化)
private String email; // 邮箱(需要序列化)
private transient String password; // 密码(不序列化)
private transient String sessionToken; // 会话令牌(不序列化)
private transient Connection dbConnection; // 数据库连接(不序列化)
// 缓存字段,可以重新计算
private transient int loginCount; // 登录次数(不序列化)
public UserAccount(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
this.sessionToken = generateToken();
this.loginCount = 0;
}
private String generateToken() {
return UUID.randomUUID().toString();
}
@Override
public String toString() {
return "UserAccount{" +
"username='" + username + '\'' +
", email='" + email + '\'' +
", password='" + password + '\'' +
", sessionToken='" + sessionToken + '\'' +
", loginCount=" + loginCount +
'}';
}
}
测试 transient 字段的结果显示,transient 字段的值在反序列化后都变成了默认值。
[!tip] transient 的最佳实践
敏感数据必须标记为 transient:密码、密钥、个人隐私信息临时资源必须标记为 transient:数据库连接、文件句柄、Socket 连接可计算字段建议标记为 transient:可以根据其他字段计算得出的值反序列化后需要重新初始化:在 readObject 方法中重新计算或加载 transient 字段
7.2 writeObject 与 readObject 方法
通过实现 writeObject 和 readObject 方法,可以完全控制序列化和反序列化的过程。这两个方法是私有方法,JVM 通过反射调用它们。
方法签名如下:
private void writeObject(ObjectOutputStream out) throws IOException {
// 自定义序列化逻辑
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
// 自定义反序列化逻辑
}
使用场景包括:加密敏感字段,序列化前加密,反序列化后解密;自定义格式,按特定格式序列化数据;版本兼容处理,为旧版本数据提供兼容逻辑;初始化 transient 字段,反序列化后重新初始化临时字段。
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
/**
* 银行卡类
* 演示自定义序列化逻辑
*/
public class BankCard implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String cardNumber; // 卡号(需要加密)
private String cardHolder; // 持卡人
private double balance; // 余额
// 不序列化,反序列化后重新生成
private transient String encryptionKey;
public BankCard(String cardNumber, String cardHolder, double balance) {
this.cardNumber = cardNumber;
this.cardHolder = cardHolder;
this.balance = balance;
this.encryptionKey = generateKey();
}
private String generateKey() {
return UUID.randomUUID().toString();
}
/**
* 自定义序列化逻辑
* 在序列化前加密卡号
*/
private void writeObject(ObjectOutputStream out) throws IOException {
// 先执行默认序列化(序列化非 transient 字段)
out.defaultWriteObject();
// 加密卡号后写入
String encryptedCardNumber = encrypt(cardNumber);
out.writeObject(encryptedCardNumber);
System.out.println("自定义序列化:卡号已加密");
}
/**
* 自定义反序列化逻辑
* 反序列化后解密卡号,并重新生成密钥
*/
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
// 先执行默认反序列化
in.defaultReadObject();
// 读取加密的卡号
String encryptedCardNumber = (String) in.readObject();
// 解密卡号
this.cardNumber = decrypt(encryptedCardNumber);
// 重新生成 transient 字段
this.encryptionKey = generateKey();
System.out.println("自定义反序列化:卡号已解密,密钥已重新生成");
}
/**
* 简单的加密算法(示例用,实际应使用标准加密算法)
*/
private String encrypt(String data) {
// 实际项目中应使用 AES、RSA 等标准加密算法
return Base64.getEncoder().encodeToString(
data.getBytes(StandardCharsets.UTF_8));
}
/**
* 简单的解密算法
*/
private String decrypt(String encryptedData) {
return new String(
Base64.getDecoder().decode(encryptedData),
StandardCharsets.UTF_8);
}
}
测试结果显示:序列化时调用了自定义的 writeObject 方法,卡号被加密;反序列化时调用了自定义的 readObject 方法,卡号被解密;transient 字段 encryptionKey 被重新生成。
[!warning] writeObject 和 readObject 的注意事项
必须调用 defaultWriteObject/defaultReadObject:确保非 transient 字段正常序列化写入和读取的顺序必须一致:writeObject 写入的数据,readObject 必须按相同顺序读取异常处理要完善:防止部分数据写入/读取失败导致对象状态不一致线程安全问题:多线程环境下需要考虑并发序列化的问题
8. 单例模式与序列化
8.1 序列化破坏单例的原理
单例模式是最常用的设计模式之一,但序列化会破坏单例的唯一性。传统的单例模式通过私有构造器保证只能创建一个实例。但反序列化不调用构造器,而是通过反射机制直接创建对象,从而绕过了单例的保护机制。
/**
* 传统单例模式
* 存在被序列化破坏的风险
*/
public class UnsafeSingleton implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
// 单例实例
private static final UnsafeSingleton INSTANCE = new UnsafeSingleton();
/**
* 私有构造器
* 防止外部通过 new 创建实例
*/
private UnsafeSingleton() {
System.out.println("UnsafeSingleton 构造器被调用");
}
/**
* 获取单例实例
*/
public static UnsafeSingleton getInstance() {
return INSTANCE;
}
}
测试结果显示,构造器只调用了一次(创建单例时),反序列化创建了一个新对象,与原单例不是同一个实例,单例模式被破坏。
序列化破坏单例的完整流程。反序列化时通过反射创建了新对象,绕过了私有构造器的限制。
8.2 枚举单例解决方案
使用枚举实现单例是最安全、最简洁的方式。枚举天生就是线程安全的单例,并且 JVM 保证枚举实例的序列化和反序列化不会创建新对象。
/**
* 枚举单例(推荐方式)
* 天生防止反序列化破坏
*/
public enum SafeSingleton {
INSTANCE; // 单例实例
// 业务字段
private String data;
/**
* 业务方法
*/
public void doSomething() {
System.out.println("SafeSingleton 执行业务逻辑");
}
public String getData() {
return data;
}
public void setData(String data) {
this.data = data;
}
}
测试结果显示,反序列化后仍然是同一个实例,单例模式没有被破坏。
[!tip] 枚举单例的优势
代码简洁:一行代码实现单例,无需编写私有构造器、getInstance 方法线程安全:JVM 保证枚举实例的初始化是线程安全的防止反射攻击:无法通过反射创建枚举实例防止序列化破坏:JVM 特殊处理枚举的序列化,保证单例性支持多个实例:可以定义多个枚举值,适用于固定数量的实例场景
《Effective Java》作者 Joshua Bloch 的建议:
“单元素的枚举类型已经成为实现单例的最佳方法。”
8.3 readResolve 解决方案
如果由于某些原因无法使用枚举(如需要继承其他类),可以通过 readResolve 方法保护单例。
/**
* 使用 readResolve 保护的单例
*/
public class ProtectedSingleton implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
// 单例实例
private static final ProtectedSingleton INSTANCE = new ProtectedSingleton();
/**
* 私有构造器
*/
private ProtectedSingleton() {
System.out.println("ProtectedSingleton 构造器被调用");
}
/**
* 获取单例实例
*/
public static ProtectedSingleton getInstance() {
return INSTANCE;
}
/**
* 反序列化后返回单例实例
* 防止创建新对象
*/
private Object readResolve() throws ObjectStreamException {
System.out.println("readResolve 被调用:返回单例实例");
return INSTANCE; // 返回预定义的单例
}
}
测试结果显示,反序列化时调用了 readResolve 方法,返回的是预定义的单例实例,单例模式得到保护。
[!warning] readResolve 方案的局限性
虽然 readResolve 可以保护单例,但它有一些局限性:
仍然会创建临时对象:反序列化时先创建一个临时对象,然后 readResolve 返回单例,临时对象被丢弃内存开销:临时对象的创建和回收会带来额外开销无法防止反射攻击:反射仍然可以调用私有构造器创建新实例需要手动实现:容易遗漏,不如枚举自动保护
综合建议:优先使用枚举实现单例,除非有明确的理由(如需要继承其他类)才使用 readResolve 方案。
9. Externalizable 接口
9.1 完全自定义序列化
除了 Serializable 接口,Java 还提供了 Externalizable 接口,允许程序员完全控制序列化过程。Externalizable 接口继承自 Serializable,定义了两个方法:writeExternal 和 readExternal。实现这两个方法后,序列化过程完全由程序员控制。
接口定义如下:
public interface Externalizable extends Serializable {
void writeExternal(ObjectOutput out) throws IOException;
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}
实现示例:
import java.io.*;
import java.time.*;
/**
* 图书类
* 使用 Externalizable 完全控制序列化
*/
public class Book implements Externalizable {
private String isbn;
private String title;
private String author;
private double price;
private LocalDateTime publishDate;
/**
* 必须提供无参构造器
* Externalizable 反序列化时会调用无参构造器
*/
public Book() {
System.out.println("Book 无参构造器被调用");
}
public Book(String isbn, String title, String author,
double price, LocalDateTime publishDate) {
System.out.println("Book 有参构造器被调用");
this.isbn = isbn;
this.title = title;
this.author = author;
this.price = price;
this.publishDate = publishDate;
}
/**
* 自定义序列化逻辑
* 完全控制哪些字段、按什么格式序列化
*/
@Override
public void writeExternal(ObjectOutput out) throws IOException {
System.out.println("writeExternal 被调用");
// 手动写入每个字段
out.writeUTF(isbn);
out.writeUTF(title);
out.writeUTF(author);
out.writeDouble(price);
// 将 LocalDateTime 转换为时间戳序列化
long timestamp = publishDate.atZone(ZoneId.systemDefault())
.toInstant()
.toEpochMilli();
out.writeLong(timestamp);
}
/**
* 自定义反序列化逻辑
* 必须按照 writeExternal 的顺序读取
*/
@Override
public void readExternal(ObjectInput in)
throws IOException, ClassNotFoundException {
System.out.println("readExternal 被调用");
// 手动读取每个字段(顺序必须与 writeExternal 一致)
this.isbn = in.readUTF();
this.title = in.readUTF();
this.author = in.readUTF();
this.price = in.readDouble();
// 将时间戳转换为 LocalDateTime
long timestamp = in.readLong();
this.publishDate = LocalDateTime.ofInstant(
Instant.ofEpochMilli(timestamp),
ZoneId.systemDefault());
}
}
测试结果显示:序列化时调用了 writeExternal 方法;反序列化时先调用了无参构造器(与 Serializable 不同);然后调用了 readExternal 方法恢复字段值。
[!warning] Externalizable 的注意事项
必须提供无参构造器:否则反序列化时抛出 InvalidClassException写入和读取顺序必须一致:否则数据错乱或抛出异常所有字段必须手动处理:忘记序列化某个字段会导致数据丢失版本兼容性需要自己维护:没有 serialVersionUID 机制构造器会被调用:与 Serializable 的行为不同
9.2 性能对比分析
Externalizable 的性能通常优于 Serializable,因为它避免了反射和默认序列化的开销,完全由程序员控制。典型测试结果显示,Externalizable 的性能约比 Serializable 快 30%。
性能差异的原因:
Serializable:
使用反射获取字段信息自动处理字段序列化写入类元数据信息处理继承关系
Externalizable:
直接写入字段值,无反射开销只写入必要数据,不写入类元数据程序员优化的写入顺序和格式
[!info] 性能优化建议
虽然 Externalizable 性能更好,但在实际项目中选择序列化方案时应综合考虑:
因素SerializableExternalizable性能较慢(反射开销)较快(直接写入)开发效率高(自动序列化)低(手动编写代码)维护成本低(自动处理)高(容易出错)版本兼容性好(serialVersionUID 机制)需要自己处理适用场景大多数业务场景性能敏感、数据量大场景建议:优先使用 Serializable,只有在性能成为瓶颈时才考虑 Externalizable。
10. Spring Boot 中的序列化实战
这是本文的重点章节,将详细解释在 Spring Boot 项目中,哪些类需要实现序列化接口,哪些类不需要,以及背后的原理。
10.1 实体类(Entity)为什么要序列化
在 Spring Boot 项目中,实体类(Entity)通常是映射数据库表的 JavaBean,使用 JPA 或 MyBatis 进行持久化操作。这些实体类需要实现 Serializable 接口,原因如下。
[!info] 实体类序列化的核心原因
原因一:Session 持久化
在 Web 应用中,当服务器重启或 Session 超时时,容器(如 Tomcat)会将 Session 中的对象序列化到磁盘,以便下次使用时恢复。如果 Session 中存储了实体对象,而实体类未实现 Serializable,会抛出 NotSerializableException。
原因二:分布式系统中的 Session 共享
在分布式部署场景下(多台服务器),Session 数据通常存储在 Redis 等缓存服务器中,实现跨服务器共享。Redis 会将对象序列化为字节流存储,取出时再反序列化。如果实体类未实现 Serializable,无法存入 Redis。
原因三:二级缓存
Hibernate 的二级缓存(如 Ehcache、Redis)需要将实体对象序列化后存储。MyBatis 的二级缓存也有类似要求。
原因四:远程方法调用(RPC)
在微服务架构中,服务间通过 RPC(如 Dubbo、Feign)传递实体对象时,需要序列化为字节流传输。
原因五:异步消息队列
使用 RabbitMQ、Kafka 等消息队列时,实体对象作为消息体需要序列化后发送。
实体类序列化示例:
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户实体类
* 映射数据库 t_user 表
*/
@Entity
@Table(name = "t_user")
public class User implements Serializable {
// 序列化版本号(必须)
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id; // 用户ID
@Column(name = "username", nullable = false, length = 50)
private String username; // 用户名
@Column(name = "password", nullable = false, length = 100)
private String password; // 密码(实际应加密)
@Column(name = "email", length = 100)
private String email; // 邮箱
@Column(name = "phone", length = 20)
private String phone; // 手机号
@Column(name = "create_time")
private LocalDateTime createTime; // 创建时间
@Column(name = "update_time")
private LocalDateTime updateTime; // 更新时间
// 构造器、getter、setter 省略...
}
这个实体类实现了 Serializable 接口,并声明了 serialVersionUID。这样它就可以安全地存储到 Session、Redis 缓存、二级缓存中,也可以在微服务间传递。
[!warning] 实体类序列化的常见问题
问题一:懒加载关联对象未初始化
如果实体类包含懒加载的关联对象(如 @OneToMany(fetch = FetchType.LAZY)),序列化时可能出现 LazyInitializationException。
解决方案:
在序列化前初始化关联对象(调用 getter 方法)使用 DTO 对象传递数据,而非直接序列化实体将关联字段标记为 transient
问题二:包含不可序列化的字段
如果实体类包含数据库连接、文件句柄等不可序列化的字段,需要标记为 transient。
@Transient // JPA 注解,表示不映射到数据库
private transient Connection connection; // 不序列化
10.2 DTO/VO 为什么不需要序列化
在 Spring Boot 项目中,DTO(Data Transfer Object,数据传输对象)和 VO(Value Object,值对象)通常用于接口的请求和响应,它们一般不需要实现 Serializable 接口。
[!info] DTO/VO 不需要序列化的原因
原因一:使用 JSON 序列化
现代 Web 应用(RESTful API)通常使用 JSON 格式传输数据。Spring Boot 默认使用 Jackson 库将对象序列化为 JSON 字符串,不需要 Java 原生序列化。
@RestController
@RequestMapping("/api/user")
public class UserController {
@PostMapping("/register")
public Result
// userDTO 由 Jackson 从 JSON 反序列化而来
// 返回的 UserVO 会被 Jackson 序列化为 JSON
return Result.success(userService.register(userDTO));
}
}
在这个例子中,UserDTO 和 UserVO 不需要实现 Serializable,因为:
请求体的 JSON 由 Jackson 自动转换为 UserDTO 对象返回的 UserVO 对象由 Jackson 自动转换为 JSON 响应
原因二:生命周期短
DTO/VO 对象通常只在请求处理过程中存在,处理完成后就被销毁,不需要持久化到磁盘或缓存。
原因三:不存储到 Session
DTO/VO 对象通常不会存储到 Session 中,因为它们只是临时的数据传输载体。
原因四:无需远程传输
DTO/VO 对象通常只在单个服务内部使用,不需要通过 RPC 传递到其他服务。
DTO/VO 不序列化示例:
import java.time.LocalDateTime;
/**
* 用户注册请求 DTO
* 不需要实现 Serializable
*/
public class UserDTO {
private String username; // 用户名
private String password; // 密码
private String email; // 邮箱
private String phone; // 手机号
// 无需 serialVersionUID
// getter、setter 省略...
}
/**
* 用户信息响应 VO
* 不需要实现 Serializable
*/
public class UserVO {
private Long id; // 用户ID
private String username; // 用户名
private String email; // 邮箱
private String phone; // 手机号
private LocalDateTime createTime; // 创建时间
// 无需 serialVersionUID
// getter、setter 省略...
}
这些 DTO/VO 类不实现 Serializable,因为它们只用于 JSON 序列化,不需要 Java 原生序列化。
[!tip] DTO/VO 设计的最佳实践
使用 DTO 隔离实体类:不要直接将实体类暴露给前端,使用 DTO 进行数据传输DTO 与 VO 分离:请求使用 DTO,响应使用 VO,职责清晰字段脱敏:VO 中不要包含敏感信息(如密码),或进行脱敏处理字段验证:DTO 中使用 @Valid 注解进行参数校验避免循环引用:DTO/VO 中不要包含循环引用的对象
10.3 什么时候 DTO/VO 需要序列化
虽然大多数情况下 DTO/VO 不需要序列化,但在某些特殊场景下,仍然需要实现 Serializable 接口。
[!warning] DTO/VO 需要序列化的特殊场景
场景一:存储到 Session
如果 DTO/VO 对象需要存储到 Session 中(如多步骤表单),则必须实现 Serializable。
@PostMapping("/order/step1")
public Result
// 将 orderDTO 存储到 Session
session.setAttribute("orderDTO", orderDTO);
return Result.success("第一步完成");
}
此时 OrderDTO 必须实现 Serializable,否则 Session 持久化时会失败。
场景二:存储到 Redis 缓存
如果 DTO/VO 对象需要缓存到 Redis 中,则必须实现 Serializable。
@Service
public class UserService {
@Autowired
private RedisTemplate
public UserVO getUserInfo(Long userId) {
// 从 Redis 缓存获取
String key = "user:info:" + userId;
UserVO userVO = (UserVO) redisTemplate.opsForValue().get(key);
if (userVO == null) {
// 从数据库查询
User user = userRepository.findById(userId).orElse(null);
userVO = convertToVO(user);
// 存入 Redis 缓存
redisTemplate.opsForValue().set(key, userVO, 1, TimeUnit.HOURS);
}
return userVO;
}
}
此时 UserVO 必须实现 Serializable,否则无法存入 Redis。
场景三:通过 RPC 传递
如果 DTO/VO 对象需要通过 Dubbo、Feign 等 RPC 框架传递到其他服务,则必须实现 Serializable。
@Service
public interface UserRpcService {
// Dubbo 接口,参数和返回值都需要序列化
UserVO getUserInfo(Long userId);
boolean createUser(UserDTO userDTO);
}
此时 UserDTO 和 UserVO 必须实现 Serializable。
场景四:异步消息队列
如果 DTO/VO 对象作为消息体发送到 RabbitMQ、Kafka 等消息队列,则必须实现 Serializable。
@Service
public class OrderService {
@Autowired
private RabbitTemplate rabbitTemplate;
public void createOrder(OrderDTO orderDTO) {
// 发送消息到队列
rabbitTemplate.convertAndSend("order.queue", orderDTO);
}
}
此时 OrderDTO 必须实现 Serializable。
10.4 总结:Spring Boot 中的序列化规则
为了帮助你更好地理解 Spring Boot 中的序列化规则,下面用表格总结:
类型是否需要序列化原因示例场景实体类(Entity)需要Session 持久化、Redis 缓存、二级缓存、RPC 传递、消息队列User、Order、ProductDTO(请求对象)通常不需要使用 JSON 序列化,不存储到 Session/Redis,生命周期短UserDTO、OrderDTOVO(响应对象)通常不需要使用 JSON 序列化,不存储到 Session/Redis,生命周期短UserVO、OrderVODTO/VO(特殊)需要存储到 Session、存储到 Redis、RPC 传递、消息队列缓存的 UserVOService 类不需要无状态,不需要持久化UserServiceController 类不需要无状态,不需要持久化UserController工具类不需要无状态,不需要持久化DateUtil、StringUtil
这个流程图帮助你快速判断一个类是否需要实现 Serializable 接口。
[!tip] 最佳实践建议
实体类总是实现 Serializable:即使当前不需要,未来可能需要缓存或 RPCDTO/VO 按需实现:如果确定只用于 HTTP 接口,不需要实现;如果需要缓存或 RPC,再添加显式声明 serialVersionUID:所有实现 Serializable 的类都应声明版本号敏感字段使用 transient:密码、密钥等敏感字段不要序列化避免序列化大对象:序列化性能开销大,尽量只序列化必要的数据考虑使用 JSON 序列化:对于 Redis 缓存,可以使用 JSON 序列化代替 Java 序列化
10.5 实战示例:完整的用户管理模块
下面是一个完整的用户管理模块示例,展示了实体类、DTO、VO、Service、Controller 的序列化实践。
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 用户实体类
* 需要实现 Serializable(用于缓存、Session、RPC)
*/
@Entity
@Table(name = "t_user")
public class User implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "username", nullable = false, length = 50, unique = true)
private String username;
@Column(name = "password", nullable = false, length = 100)
private String password; // 实际应使用 BCrypt 加密
@Column(name = "email", length = 100)
private String email;
@Column(name = "phone", length = 20)
private String phone;
@Column(name = "status")
private Integer status; // 状态:0-禁用,1-正常
@Column(name = "create_time")
private LocalDateTime createTime;
@Column(name = "update_time")
private LocalDateTime updateTime;
// 构造器、getter、setter 省略...
}
/**
* 用户注册请求 DTO
* 不需要实现 Serializable(仅用于 HTTP 请求)
*/
public class UserRegisterDTO {
@NotBlank(message = "用户名不能为空")
@Size(min = 3, max = 20, message = "用户名长度必须在3-20之间")
private String username;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度必须在6-20之间")
private String password;
@Email(message = "邮箱格式不正确")
private String email;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
private String phone;
// getter、setter 省略...
}
/**
* 用户信息响应 VO
* 如果需要缓存到 Redis,则需要实现 Serializable
*/
public class UserVO implements Serializable {
// 序列化版本号(因为需要缓存到 Redis)
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String email;
private String phone;
private Integer status;
private LocalDateTime createTime;
// 不包含密码等敏感信息
// getter、setter 省略...
}
/**
* 用户 Service
* 不需要实现 Serializable
*/
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private RedisTemplate
@Autowired
private PasswordEncoder passwordEncoder;
/**
* 用户注册
*/
@Transactional(rollbackFor = Exception.class)
public UserVO register(UserRegisterDTO dto) {
// 检查用户名是否存在
if (userRepository.existsByUsername(dto.getUsername())) {
throw new BusinessException("用户名已存在");
}
// 创建用户实体
User user = new User();
user.setUsername(dto.getUsername());
user.setPassword(passwordEncoder.encode(dto.getPassword()));
user.setEmail(dto.getEmail());
user.setPhone(dto.getPhone());
user.setStatus(1);
user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
// 保存到数据库
user = userRepository.save(user);
// 转换为 VO 并返回
UserVO userVO = convertToVO(user);
// 缓存到 Redis(UserVO 需要实现 Serializable)
String cacheKey = "user:info:" + user.getId();
redisTemplate.opsForValue().set(cacheKey, userVO, 1, TimeUnit.HOURS);
return userVO;
}
/**
* 获取用户信息(带缓存)
*/
public UserVO getUserInfo(Long userId) {
// 先从 Redis 缓存获取
String cacheKey = "user:info:" + userId;
UserVO userVO = (UserVO) redisTemplate.opsForValue().get(cacheKey);
if (userVO != null) {
return userVO;
}
// 缓存未命中,从数据库查询
User user = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException("用户不存在"));
// 转换为 VO
userVO = convertToVO(user);
// 存入 Redis 缓存(UserVO 必须实现 Serializable)
redisTemplate.opsForValue().set(cacheKey, userVO, 1, TimeUnit.HOURS);
return userVO;
}
/**
* 实体转 VO
*/
private UserVO convertToVO(User user) {
UserVO vo = new UserVO();
vo.setId(user.getId());
vo.setUsername(user.getUsername());
vo.setEmail(user.getEmail());
vo.setPhone(user.getPhone());
vo.setStatus(user.getStatus());
vo.setCreateTime(user.getCreateTime());
return vo;
}
}
/**
* 用户 Controller
* 不需要实现 Serializable
*/
@RestController
@RequestMapping("/api/user")
public class UserController {
@Autowired
private UserService userService;
/**
* 用户注册
*/
@PostMapping("/register")
public Result
UserVO userVO = userService.register(dto);
return Result.success(userVO);
}
/**
* 获取用户信息
*/
@GetMapping("/{userId}")
public Result
UserVO userVO = userService.getUserInfo(userId);
return Result.success(userVO);
}
}
在这个完整示例中:
User 实体类实现了 Serializable:因为它会被存储到 Redis 缓存、二级缓存,也可能通过 RPC 传递UserRegisterDTO 不实现 Serializable:仅用于接收 HTTP 请求的 JSON 数据,使用 Jackson 反序列化UserVO 实现了 Serializable:因为它会被缓存到 Redis,需要 Java 序列化UserService 和 UserController 不实现 Serializable:它们是无状态的 Spring Bean,不需要序列化
这个示例完整展示了 Spring Boot 项目中序列化的最佳实践。
11. 序列化最佳实践
11.1 安全性考虑
序列化数据可能被恶意篡改,导致安全漏洞。在设计序列化方案时必须考虑安全性。
安全风险包括:反序列化攻击,恶意构造的字节流可能触发任意代码执行;数据篡改,序列化文件被修改后反序列化可能导致非法数据;敏感信息泄露,序列化文件可能包含密码、密钥等敏感信息;拒绝服务攻击,恶意构造的对象图可能导致内存溢出。
[!warning] 安全最佳实践
不要反序列化不可信的数据:
只反序列化来自可信源的数据使用白名单机制,只允许特定类的反序列化使用数字签名验证数据完整性
敏感字段使用 transient 标记:
private transient String password; // 不序列化密码
private transient String secretKey; // 不序列化密钥
实现 validateObject 方法进行数据校验:
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 校验反序列化的数据
if (age < 0 || age > 150) {
throw new InvalidObjectException("年龄数据非法:" + age);
}
if (email == null || !email.contains("@")) {
throw new InvalidObjectException("邮箱格式错误:" + email);
}
}
使用对象输入过滤器(Java 9+):
ObjectInputStream ois = new ObjectInputStream(fis);
// 设置反序列化过滤器
ois.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
"com.example.model.*;!*" // 只允许 com.example.model 包下的类
));
对序列化数据加密:
// 序列化前加密
Cipher cipher = Cipher.getInstance("AES");
cipher.init(Cipher.ENCRYPT_MODE, secretKey);
CipherOutputStream cos = new CipherOutputStream(fos, cipher);
ObjectOutputStream oos = new ObjectOutputStream(cos);
oos.writeObject(obj);
11.2 性能优化建议
序列化可能成为系统的性能瓶颈,以下是一些优化建议。
优化策略一:使用 transient 减少序列化数据量
不必要的字段标记为 transient,减少序列化的数据量,提高速度。
public class CachedUser implements Serializable {
// 序列化版本号
private static final long serialVersionUID = 1L;
private String userId;
private String username;
// 缓存数据,不需要序列化
private transient Map
// 计算结果,可以重新计算
private transient int totalScore;
}
优化策略二:使用对象池复用对象
对于频繁序列化的对象,使用对象池复用,避免重复创建。
public class SerializationPool {
private static final Queue
new ConcurrentLinkedQueue<>();
/**
* 获取 ByteArrayOutputStream
*/
public static ByteArrayOutputStream getBaos() {
ByteArrayOutputStream baos = BAOS_POOL.poll();
if (baos == null) {
baos = new ByteArrayOutputStream(1024);
}
return baos;
}
/**
* 归还 ByteArrayOutputStream
*/
public static void returnBaos(ByteArrayOutputStream baos) {
baos.reset(); // 重置缓冲区
BAOS_POOL.offer(baos);
}
/**
* 优化的序列化方法
*/
public static byte[] serialize(Object obj) throws IOException {
ByteArrayOutputStream baos = getBaos();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(obj);
return baos.toByteArray();
} finally {
returnBaos(baos);
}
}
}
优化策略三:批量序列化减少 IO 次数
将多个对象一次性序列化,减少 IO 开销。
/**
* 批量序列化
*/
public static void batchSerialize(List
throws IOException {
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(filePath)))) {
// 先写入对象数量
oos.writeInt(users.size());
// 批量写入对象
for (User user : users) {
oos.writeObject(user);
}
}
}
优化策略四:使用 BufferedOutputStream/BufferedInputStream
使用缓冲流减少系统调用次数,提高 IO 性能。
// 使用缓冲流
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(filePath), 8192))) {
oos.writeObject(obj);
}
优化策略五:考虑使用高性能序列化框架
如果 Java 原生序列化成为瓶颈,可以考虑:
Kryo:性能极高,速度是 Java 序列化的 10 倍以上FST:兼容 Java 序列化,性能提升 4-10 倍Protobuf:跨语言、高性能、但需要定义 schema
11.3 常见陷阱与规避
陷阱一:忘记声明 serialVersionUID
如果不声明 serialVersionUID,类的任何变化都会导致版本号改变,反序列化失败。
// ❌ 错误:未声明版本号
public class User implements Serializable {
private String name;
}
// ✅ 正确:显式声明版本号
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
}
陷阱二:序列化非线程安全的对象
序列化非线程安全的对象(如 SimpleDateFormat)可能导致并发问题。
// ❌ 错误:序列化非线程安全的对象
public class Config implements Serializable {
private SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
}
// ✅ 正确:使用线程安全的对象或标记为 transient
public class Config implements Serializable {
private transient SimpleDateFormat dateFormat;
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 反序列化后重新创建
dateFormat = new SimpleDateFormat("yyyy-MM-dd");
}
}
陷阱三:父类未实现 Serializable 导致数据丢失
父类未实现 Serializable 时,父类字段的值会丢失。
// ❌ 错误:父类未实现 Serializable
class Animal {
protected String species;
}
class Dog extends Animal implements Serializable {
private String name;
}
// ✅ 正确:父类也实现 Serializable
class Animal implements Serializable {
private static final long serialVersionUID = 1L;
protected String species;
}
class Dog extends Animal {
private static final long serialVersionUID = 1L;
private String name;
}
陷阱四:重复序列化导致数据不更新
同一个对象多次序列化,只有第一次输出完整数据。
// ❌ 错误:多次序列化同一对象
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(user); // 第一次:输出完整数据
user.setAge(30);
oos.writeObject(user); // 第二次:只输出编号,修改无效
// ✅ 正确:调用 reset() 清除缓存
ObjectOutputStream oos = new ObjectOutputStream(fos);
oos.writeObject(user);
user.setAge(30);
oos.reset(); // 清除已序列化对象的记录
oos.writeObject(user); // 输出修改后的完整数据
陷阱五:序列化包含数据库连接等资源
数据库连接、文件句柄等资源不能序列化。
// ❌ 错误:序列化数据库连接
public class UserDao implements Serializable {
private Connection connection; // 无法序列化
}
// ✅ 正确:标记为 transient 并在反序列化后重建
public class UserDao implements Serializable {
private transient Connection connection;
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 重新建立数据库连接
connection = DriverManager.getConnection(url, user, password);
}
}
[!tip] 序列化开发检查清单
在使用序列化时,请检查以下事项:
是否显式声明了 serialVersionUID 是否标记了不需要序列化的字段为 transient 父类是否也实现了 Serializable(如果需要序列化父类字段) 引用的对象是否都实现了 Serializable 是否对敏感字段进行了加密或脱敏 是否在 readObject 中对数据进行了校验 是否考虑了版本兼容性问题 单例类是否实现了 readResolve 方法 是否避免了重复序列化同一对象的陷阱 性能敏感场景是否使用了优化措施