序列化 详解
场景:飞翔科技的HR系统要上线了,朱璐找到大翔:"员工信息都保存在内存里,服务器一重启就全没了,你得帮我存起来。"大翔不假思索:"序列化啊,ObjectOutputStream写到文件,下次启动读回来就行。"白歌在旁边皱眉:"小心点,序列化可不是简单的存盘——serialVersionUID、transient、对象引用环、还有那个Externalizable,坑多着呢。"Frank刚接完海外视频会议凑过来:"Make sure you handle backward compatibility. I've seen production disasters from missing serialVersionUID."小崔拿着本子猛记——今天又是一场硬核教学。
一、定义表
| 概念 | 定义 |
|---|---|
| 序列化 (Serialization) | 将Java对象的状态信息转换为可存储或传输的字节序列的过程 |
| 反序列化 (Deserialization) | 将字节序列恢复为Java对象的过程 |
| Serializable 接口 | 标记接口(无方法),标识一个类的对象可以被序列化 |
| serialVersionUID | 序列化版本号,用于在反序列化时验证发送方和接收方的类是否兼容 |
| transient 关键字 | 修饰的字段在序列化时会被忽略,反序列化后为默认值 |
| ObjectOutputStream | 将Java对象的基本数据类型和图形写入OutputStream,实现序列化 |
| ObjectInputStream | 从InputStream读取原始数据并反序列化为Java对象 |
| Externalizable 接口 | Serializable的子接口,要求实现类自行控制序列化逻辑(writeExternal/readExternal) |
| 对象图 (Object Graph) | 对象及其引用的所有可达对象的集合,序列化时会递归遍历整个图 |
| writeObject / readObject | 可在Serializable类中定义的私有方法,用于自定义序列化/反序列化行为 |
二、Mermaid 图表
2.1 序列化写入流程 (Sequence Diagram)
2.2 反序列化读取流程 (Flowchart)
三、核心API详解
3.1 ObjectOutputStream 关键方法
// 构造:包装底层输出流
ObjectOutputStream(OutputStream out)
// 写入对象
void writeObject(Object obj)
// 写入基本类型
void writeInt(int val)
void writeLong(long val)
void writeDouble(double val)
void writeBoolean(boolean val)
void writeUTF(String str) // 以UTF-8修改版写入字符串
void writeBytes(String str) // 写入字符串的低8位
// 刷新和关闭
void flush()
void close()
// 写入对象时的回调(由被序列化对象定义)
// ObjectOutputStream会调用对象的 writeObject(ObjectOutputStream oos) 方法
3.2 ObjectInputStream 关键方法
// 构造:包装底层输入流
ObjectInputStream(InputStream in)
// 读取对象
Object readObject() // 返回Object,需要强转
// 读取基本类型
int readInt()
long readLong()
double readDouble()
boolean readBoolean()
String readUTF()
// 关闭
void close()
3.3 序列化文件的二进制魔数
Java序列化文件的前4个字节为固定魔数:AC ED 00 05(十六进制),后跟版本号 00 05。可以据此识别文件是否为Java序列化格式。
四、完整代码示例
示例1:飞翔科技员工对象持久化存储
场景:飞翔科技HR系统需要持久化员工数据。每个员工包含基本信息、所属部门引用、以及敏感信息(薪资——需要标记为transient)。大翔写了一套完整的序列化/反序列化工具。
import java.io.*;
import java.util.*;
/**
* 飞翔科技 - 员工对象持久化系统
* 场景:HR系统需要将员工数据序列化到磁盘,系统重启后恢复
* 讲解重点:Serializable, serialVersionUID, transient, 对象引用
* 作者:大翔 & 白歌
*/
public class FeixiangEmployeePersistence {
public static void main(String[] args) {
// 1. 构建测试数据
Department devDept = new Department("DEV-001", "研发部", "8楼A区");
Department hrDept = new Department("HR-001", "人力资源部", "3楼B区");
List<Employee> employees = new ArrayList<>();
employees.add(new Employee("E1001", "大翔", devDept, 25000.0, "13800000001"));
employees.add(new Employee("E1002", "白歌", devDept, 22000.0, "13800000002"));
employees.add(new Employee("E1003", "小崔", devDept, 8000.0, "13800000003"));
employees.add(new Employee("E1004", "朱璐", hrDept, 15000.0, "13800000004"));
String dataFile = "d:/feixiang/employees.ser";
// 2. 序列化——保存到磁盘
serializeEmployees(employees, dataFile);
// 3. 反序列化——从磁盘恢复
List<Employee> restored = deserializeEmployees(dataFile);
// 4. 对比验证
compareResults(employees, restored);
}
/**
* 序列化员工列表到文件
*/
public static void serializeEmployees(List<Employee> employees, String filePath) {
new File(filePath).getParentFile().mkdirs();
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream(filePath)))) {
oos.writeObject(employees); // ArrayList 实现了 Serializable
System.out.println("[大翔] 序列化成功! " + employees.size()
+ " 个员工已存储到: " + filePath);
} catch (NotSerializableException e) {
System.err.println("[小崔] 序列化失败: 存在不可序列化的对象 - " + e.getMessage());
} catch (IOException e) {
System.err.println("[白歌] IO异常: " + e.getMessage());
}
}
/**
* 从文件反序列化员工列表
*/
@SuppressWarnings("unchecked")
public static List<Employee> deserializeEmployees(String filePath) {
try (ObjectInputStream ois = new ObjectInputStream(
new BufferedInputStream(new FileInputStream(filePath)))) {
List<Employee> employees = (List<Employee>) ois.readObject();
System.out.println("[大翔] 反序列化成功! " + employees.size()
+ " 个员工已从磁盘恢复");
return employees;
} catch (InvalidClassException e) {
System.err.println("[白歌] 反序列化失败: 类版本不兼容 - " + e.getMessage());
System.err.println("[白歌] 请检查 serialVersionUID 是否一致");
} catch (ClassNotFoundException e) {
System.err.println("[小崔] 反序列化失败: 找不到类定义 - " + e.getMessage());
} catch (IOException e) {
System.err.println("[小崔] IO异常: " + e.getMessage());
}
return Collections.emptyList();
}
/**
* 对比序列化前后的数据
*/
public static void compareResults(List<Employee> original, List<Employee> restored) {
System.out.println("\n========== 序列化前后数据对比 ==========");
for (int i = 0; i < original.size(); i++) {
Employee orig = original.get(i);
Employee rest = restored.get(i);
System.out.printf("[白歌] %s: 姓名=%s, 薪资(原)=%.0f, "
+ "薪资(恢复)=%.0f, 手机号=%s%n",
orig.getId(), orig.getName(),
orig.getSalary(), rest.getSalary(),
rest.getPhone());
// 注意:salary被transient修饰,反序列化后为null(包装类型)或0.0(基本类型)
}
}
}
// ==================== 辅助类定义 ====================
/**
* 部门类 —— 必须实现 Serializable
* 因为 Employee 引用了 Department,序列化时会遍历整个对象图
*/
class Department implements Serializable {
// 显式声明 serialVersionUID,确保版本兼容性
private static final long serialVersionUID = 20240315L;
private String deptId;
private String deptName;
private String location;
public Department(String deptId, String deptName, String location) {
this.deptId = deptId;
this.deptName = deptName;
this.location = location;
}
@Override
public String toString() {
return deptName + "(" + deptId + ") - " + location;
}
// getters
public String getDeptId() { return deptId; }
public String getDeptName() { return deptName; }
public String getLocation() { return location; }
}
/**
* 员工类 —— 展示完整的序列化控制
*/
class Employee implements Serializable {
private static final long serialVersionUID = 20240315L;
private String id; // 工号——序列化
private String name; // 姓名——序列化
private Department department; // 部门引用——序列化(遍历对象图)
private transient Double salary; // 薪资——不序列化(敏感信息)
private String phone; // 手机号——序列化
// 构造方法:反序列化时不调用
public Employee(String id, String name, Department department,
Double salary, String phone) {
this.id = id;
this.name = name;
this.department = department;
this.salary = salary;
this.phone = phone;
System.out.println("[构造] Employee 构造方法被调用: " + name);
// 注意:反序列化时这行不会打印——证明构造方法未被调用
}
/**
* 自定义序列化逻辑(私有方法,由JVM通过反射调用)
* 可用于加密敏感字段、添加额外数据
*/
private void writeObject(ObjectOutputStream oos) throws IOException {
System.out.println("[序列化回调] writeObject 被调用: " + name);
// 先序列化非transient字段(默认行为)
oos.defaultWriteObject();
// 可以在此处加密salary并用writeDouble手动序列化
// 这里为了演示,跳过salary的序列化(它本身是transient)
}
/**
* 自定义反序列化逻辑
*/
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
System.out.println("[反序列化回调] readObject 被调用: (name待恢复)");
// 先恢复非transient字段(默认行为)
ois.defaultReadObject();
// salary是transient的,这里保持为null
// 实际项目中可以从其它安全渠道恢复敏感数据
}
// getters
public String getId() { return id; }
public String getName() { return name; }
public Double getSalary() { return salary; }
public String getPhone() { return phone; }
public Department getDepartment() { return department; }
}
控制台输出:
[构造] Employee 构造方法被调用: 大翔
[构造] Employee 构造方法被调用: 白歌
[构造] Employee 构造方法被调用: 小崔
[构造] Employee 构造方法被调用: 朱璐
[序列化回调] writeObject 被调用: 大翔
[序列化回调] writeObject 被调用: 白歌
[序列化回调] writeObject 被调用: 小崔
[序列化回调] writeObject 被调用: 朱璐
[大翔] 序列化成功! 4 个员工已存储到: d:/feixiang/employees.ser
[反序列化回调] readObject 被调用: (name待恢复)
[反序列化回调] readObject 被调用: (name待恢复)
[反序列化回调] readObject 被调用: (name待恢复)
[反序列化回调] readObject 被调用: (name待恢复)
[大翔] 反序列化成功! 4 个员工已从磁盘恢复
========== 序列化前后数据对比 ==========
[白歌] E1001: 姓名=大翔, 薪资(原)=25000, 薪资(恢复)=null, 手机号=13800000001
[白歌] E1002: 姓名=白歌, 薪资(原)=22000, 薪资(恢复)=null, 手机号=13800000002
[白歌] E1003: 姓名=小崔, 薪资(原)=8000, 薪资(恢复)=null, 手机号=13800000003
[白歌] E1004: 姓名=朱璐, 薪资(原)=15000, 薪资(恢复)=null, 手机号=13800000004
关键观察:
- 反序列化时构造方法没有被调用(没有打印构造日志)。Java反序列化通过
sun.misc.Unsafe直接分配内存并填充字段。 salary被transient修饰,序列化时被忽略,反序列化后为null(包装类型Double的默认值)。- 如果
Department没实现Serializable,会抛出NotSerializableException——因为对象图遍历到Department时发现它不可序列化。
示例2:飞翔科技项目配置序列化 — Externalizable 对比
场景:白歌发现项目配置文件需要序列化,但默认的Serializable机制把所有字段都存了,包括缓存的计算值。她改用Externalizable来精确控制序列化内容,只存必要字段。
import java.io.*;
/**
* 飞翔科技 - 项目配置序列化 (Externalizable vs Serializable)
* 场景:配置文件只需要持久化核心参数,缓存值不应序列化
* 作者:白歌
*/
public class FeixiangProjectConfigPersistence {
public static void main(String[] args) {
String configFile = "d:/feixiang/project_config.ser";
// 1. 创建并保存配置
ProjectConfig config = new ProjectConfig(
"FX-2024", "飞翔ERP系统", "UTF-8",
"/opt/feixiang/logs", 8080, 4096);
// 修改运行时状态
config.setUpTime("2024-03-15 10:30:00");
config.setConnectionCount(128);
saveConfig(config, configFile);
// 2. 从文件恢复配置
ProjectConfig restored = loadConfig(configFile);
// 3. 对比
compareConfigs(config, restored);
}
public static void saveConfig(ProjectConfig config, String filePath) {
new File(filePath).getParentFile().mkdirs();
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(filePath))) {
oos.writeObject(config);
System.out.println("[白歌] 配置已保存到: " + filePath);
} catch (IOException e) {
System.err.println("[小崔] 保存失败: " + e.getMessage());
}
}
public static ProjectConfig loadConfig(String filePath) {
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream(filePath))) {
ProjectConfig config = (ProjectConfig) ois.readObject();
System.out.println("[白歌] 配置已从文件恢复");
return config;
} catch (IOException | ClassNotFoundException e) {
System.err.println("[小崔] 加载失败: " + e.getMessage());
}
return null;
}
public static void compareConfigs(ProjectConfig original, ProjectConfig restored) {
System.out.println("\n========== 配置对比 ==========");
System.out.println("[白歌] 原始配置 => " + original);
System.out.println("[白歌] 恢复配置 => " + restored);
System.out.println("[白歌] 运行时状态(upTime)恢复后: " + restored.getUpTime()
+ " (预期为null)");
System.out.println("[白歌] 运行时状态(connectionCount)恢复后: "
+ restored.getConnectionCount() + " (预期为0)");
}
}
// ==================== Externalizable 配置类 ====================
/**
* 使用 Externalizable 实现精确控制序列化
* 与 Serializable 的关键区别:
* 1. 必须提供 public 无参构造方法(反序列化时需要)
* 2. 必须实现 writeExternal 和 readExternal 方法
* 3. 完全手动控制序列化内容
* 4. 性能更好(跳过了反射分析字段的开销)
*/
class ProjectConfig implements Externalizable {
private static final long serialVersionUID = 20240315L;
// 持久化字段
private String projectCode;
private String projectName;
private String encoding;
private String logPath;
private int port;
private int maxThreads;
// 运行时状态 —— 不参与序列化
private String upTime; // 启动时间——每次运行不同
private int connectionCount; // 当前连接数——运行时状态
// ⚠️ Externalizable 必须提供 public 无参构造
public ProjectConfig() {
System.out.println("[Externalizable] 无参构造被调用 (反序列化)");
}
public ProjectConfig(String projectCode, String projectName, String encoding,
String logPath, int port, int maxThreads) {
this.projectCode = projectCode;
this.projectName = projectName;
this.encoding = encoding;
this.logPath = logPath;
this.port = port;
this.maxThreads = maxThreads;
System.out.println("[Externalizable] 全参构造被调用 (正常创建)");
}
/**
* 自定义序列化:只写需要的字段
*/
@Override
public void writeExternal(ObjectOutput out) throws IOException {
System.out.println("[Externalizable] writeExternal 执行");
out.writeUTF(projectCode);
out.writeUTF(projectName);
out.writeUTF(encoding);
out.writeUTF(logPath);
out.writeInt(port);
out.writeInt(maxThreads);
// 运行时状态 upTime 和 connectionCount 不写入
}
/**
* 自定义反序列化:按写入的顺序读取
*/
@Override
public void readExternal(ObjectInput in)
throws IOException, ClassNotFoundException {
System.out.println("[Externalizable] readExternal 执行");
// 顺序必须与 writeExternal 完全一致!
this.projectCode = in.readUTF();
this.projectName = in.readUTF();
this.encoding = in.readUTF();
this.logPath = in.readUTF();
this.port = in.readInt();
this.maxThreads = in.readInt();
// 运行时状态保持默认值
this.upTime = null;
this.connectionCount = 0;
}
// setters for runtime state
public void setUpTime(String upTime) { this.upTime = upTime; }
public void setConnectionCount(int connectionCount) {
this.connectionCount = connectionCount;
}
// getters
public String getUpTime() { return upTime; }
public int getConnectionCount() { return connectionCount; }
@Override
public String toString() {
return String.format(
"ProjectConfig{code=%s, name=%s, encoding=%s, logPath=%s, "
+ "port=%d, maxThreads=%d, upTime=%s, connections=%d}",
projectCode, projectName, encoding, logPath,
port, maxThreads, upTime, connectionCount);
}
}
控制台输出:
[Externalizable] 全参构造被调用 (正常创建)
[Externalizable] writeExternal 执行
[白歌] 配置已保存到: d:/feixiang/project_config.ser
[Externalizable] 无参构造被调用 (反序列化)
[Externalizable] readExternal 执行
[白歌] 配置已从文件恢复
========== 配置对比 ==========
[白歌] 原始配置 => ProjectConfig{code=FX-2024, name=飞翔ERP系统, encoding=UTF-8, logPath=/opt/feixiang/logs, port=8080, maxThreads=4096, upTime=2024-03-15 10:30:00, connections=128}
[白歌] 恢复配置 => ProjectConfig{code=FX-2024, name=飞翔ERP系统, encoding=UTF-8, logPath=/opt/feixiang/logs, port=8080, maxThreads=4096, upTime=null, connections=0}
[白歌] 运行时状态(upTime)恢复后: null (预期为null)
[白歌] 运行时状态(connectionCount)恢复后: 0 (预期为0)
示例3:序列化中的对象引用与Object Graph
场景:大翔画了个对象引用图——多个员工可以共享同一个部门对象。在序列化时,Java会维护引用完整性,反序列化后仍然保持共享引用。
import java.io.*;
/**
* 飞翔科技 - 对象图序列化:引用共享验证
* 场景:多个员工属于同一部门,序列化后引用关系应该保持不变
* 作者:大翔
*/
public class ObjectGraphSerialization {
public static void main(String[] args) {
String filePath = "d:/feixiang/graph_test.ser";
new File(filePath).getParentFile().mkdirs();
// 创建共享部门对象
Department devDept = new Department("D001", "研发部", "8楼");
// 多个员工引用同一个部门
Employee e1 = new Employee("E01", "大翔", devDept, 25000.0, "138001");
Employee e2 = new Employee("E02", "白歌", devDept, 22000.0, "138002");
// e1 和 e2 的 department 字段指向同一个 Department 实例
System.out.println("[大翔] 序列化前:");
System.out.println(" e1.department == e2.department ? "
+ (e1.getDepartment() == e2.getDepartment())); // true
System.out.println(" department hashCode: " + devDept.hashCode());
// 序列化
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(filePath))) {
oos.writeObject(e1);
oos.writeObject(e2);
oos.writeObject(devDept); // 再写一次部门
} catch (IOException ex) {
ex.printStackTrace();
}
// 反序列化
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream(filePath))) {
Employee r1 = (Employee) ois.readObject();
Employee r2 = (Employee) ois.readObject();
Department rd = (Department) ois.readObject();
System.out.println("\n[大翔] 反序列化后:");
System.out.println(" r1.department == r2.department ? "
+ (r1.getDepartment() == r2.getDepartment()));
// JDK 8 中:同一个ObjectOutputStream内写入的共享引用
// 会在反序列化时保持引用一致性 → true
System.out.println(" r1.department == rd ? "
+ (r1.getDepartment() == rd));
// 同一个流内,三次写入同一个对象,反序列化后引用一致 → true
System.out.println(" r1.name=" + r1.getName()
+ " dept=" + r1.getDepartment().getDeptName());
System.out.println(" r2.name=" + r2.getName()
+ " dept=" + r2.getDepartment().getDeptName());
} catch (IOException | ClassNotFoundException ex) {
ex.printStackTrace();
}
}
}
控制台输出:
[大翔] 序列化前:
e1.department == e2.department ? true
department hashCode: 12345678
[大翔] 反序列化后:
r1.department == r2.department ? true
r1.department == rd ? true
r1.name=大翔 dept=研发部
r2.name=白歌 dept=研发部
核心原理:ObjectOutputStream 内部维护了一个对象引用表(HandleTable),对同一个对象只序列化一次,后续遇到相同引用时只写入引用号。反序列化时,ObjectInputStream 的HandleTable会还原相同的引用关系。
五、易错场景
易错1:忘记给可序列化类添加 serialVersionUID
// ❌ 错误:未声明 serialVersionUID
// JVM会根据类结构(方法名、字段、接口等)自动生成一个hash值
// 一旦类结构有任何改动(哪怕加一个空格再加一个方法),UID就会变
class Employee implements Serializable {
private String name;
// 没有 serialVersionUID!
}
// 场景:今天序列化了1000个对象到文件
// 明天:给 Employee 加了一个 getDisplayName() 方法
// 后天:反序列化 → InvalidClassException! 所有数据读取失败!
// ✅ 正确:显式声明 serialVersionUID
class Employee implements Serializable {
private static final long serialVersionUID = 1L; // 固定版本号
private String name;
// 以后添加方法、调整字段顺序都不会改变UID
// (删除已有字段或改变字段类型仍然可能出问题,需谨慎)
}
建议值:
- 使用
1L(简单明了,手动管理兼容性) - 使用
serialver命令行工具自动生成(serialver Employee) - IDE自动生成(随机长整数,如
-7382359872398547234L)
易错2:内部类序列化的陷阱
// ❌ 错误:非静态内部类持有外部类引用,序列化时会连带外部类
class Outer {
private String secret = "password123";
class InnerData implements Serializable { // 非静态内部类
private String data;
// 隐含持有 Outer.this 引用!
}
}
// 序列化 InnerData 时,会尝试序列化 Outer 实例
// 如果 Outer 没有实现 Serializable → NotSerializableException!
// ✅ 方案1:使用静态内部类
class Outer {
private String secret = "password123";
static class InnerData implements Serializable { // 静态内部类
private String data;
// 不持有外部类引用,可以独立序列化
}
}
// ✅ 方案2:让外部类也实现 Serializable(不推荐——暴露了不该序列化的字段)
// ✅ 方案3:将内部类单独提取为顶层类
易错3:transient 字段在反序列化后为默认值
// ❌ 常见误区:以为 transient 字段反序列化后会保留原值
class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username; // 正常序列化
private transient String password; // 不序列化
}
// 序列化时: username="大翔", password="123456"
// 反序列化后: username="大翔", password=null (String默认值)
// ✅ 如果需要保护password不被泄露,但恢复时需要它:
// 方案1:在 readObject 中从一个安全服务重新加载
private void readObject(ObjectInputStream ois)
throws IOException, ClassNotFoundException {
ois.defaultReadObject();
this.password = SecurityService.reloadPassword(this.username);
}
// 方案2:在 writeObject 中加密后写入
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject();
String encrypted = AESUtil.encrypt(this.password);
oos.writeObject(encrypted); // 额外写入加密后的值
}
易错4:忘记 flush 或 close ObjectOutputStream
// ❌ 错误:未关闭流导致数据不完整
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("data.ser"));
oos.writeObject(largeObjectList); // 数据可能还在缓冲区!
// 没有 oos.close() → 文件可能不完整 → 反序列化时 StreamCorruptedException
// ✅ 正确:使用 try-with-resources
try (ObjectOutputStream oos = new ObjectOutputStream(
new BufferedOutputStream(new FileOutputStream("data.ser")))) {
oos.writeObject(largeObjectList);
oos.flush(); // 可选但建议,确保缓冲数据写入
}
易错5:静态字段不会被序列化
// 常见误区:以为 static 字段也会被序列化
class Config implements Serializable {
private static final long serialVersionUID = 1L;
private String appName; // 实例字段 → 序列化
private static int instanceCount; // 静态字段 → 不序列化! 属于类,不属于实例
}
// 序列化时: appName="飞翔ERP", instanceCount=10
// 新JVM中反序列化: appName="飞翔ERP", instanceCount=0 (类加载时的初始值)
// static 字段的状态由类加载器管理,与对象序列化无关
六、面试考点
Q1: Serializable 和 Externalizable 有什么区别?
答:
| 维度 | Serializable | Externalizable |
|---|---|---|
| 接口类型 | 标记接口(无方法) | Serializable 的子接口(有2个方法) |
| 序列化控制 | JVM自动处理(反射) | 程序员手动编写 writeExternal/readExternal |
| 性能 | 使用反射,较慢 | 无反射,较快 |
| 无参构造 | 不需要 public | 必须要有 public 无参构造 |
| 粒度 | 只能通过 transient 控制 | 完全手动控制,精确到每个字段 |
| 默认行为 | 序列化所有非transient+非static字段 | 什么都不序列化,完全手动 |
| 适用场景 | 大多数场景 | 需要极致性能或精确控制 |
追问:为什么 Externalizable 必须有无参构造?——因为反序列化时,JVM先调用无参构造创建空对象,再调用 readExternal 填充数据。而 Serializable 是通过 Unsafe.allocateInstance() 直接分配内存,不调用任何构造方法。
Q2: serialVersionUID 如果不写会怎样?底层如何计算?
答:
- 如果不写:JVM在编译/运行时根据类的"形状"自动生成一个64位hash值。形状包括:类名、实现的接口名、所有public/protected成员(方法签名和字段名)、构造方法签名等。
- 计算方式:通过
ObjectStreamClass.computeDefaultSUID()方法,对类结构进行SHA散列。过程大致为:- 获取类的完全限定名
- 获取类修饰符
- 获取所有接口名(排序后)
- 获取所有非静态非transient字段(排序后)
- 获取所有非私有构造方法(排序后)
- 获取所有非私有方法(排序后)
- 对上述信息做DataOutputStream写入字节,再用SHA-1取前8字节
- 风险:哪怕给类增加一个空格再删除后重新编译,UID都可能改变,导致历史序列化数据无法读取。
- 最佳实践:始终显式声明
private static final long serialVersionUID。即使初始值随便写个1L,也比依赖JVM自动生成安全。
Q3: 反序列化时,构造方法会被调用吗?
答:
- Serializable:不会调用任何构造方法。JVM通过
sun.misc.Unsafe.allocateInstance()在堆上直接分配内存并初始化为零值,然后通过反射填充字段。 - Externalizable:会调用 public 无参构造方法。因为Externalizable需要先有一个"空壳"对象,再通过
readExternal填充。 - 两者的父类:
- 如果父类实现了 Serializable,父类的无参构造不会被调用。
- 如果父类没有实现 Serializable,则在反序列化时会调用父类的无参构造方法来初始化父类状态。如果父类没有无参构造 →
InvalidClassException。
Q4: 如何防止某个字段被序列化?transient 和 static 有什么区别?
答:
- transient:实例字段修饰符,标记该字段不参与默认序列化。反序列化后字段值为默认值(对象→null,int→0,boolean→false等)。序列化时完全忽略,不会出现在序列化流中。
- static:类级别修饰符。static 字段属于类,不属于任何实例。序列化针对的是对象实例的状态,所以static字段永远不会被序列化,无论是否加transient。
- 两者组合:
transient static— static就已经决定不被序列化了,transient没有额外作用。 - 自定义控制:如果不满足于transient的默认行为,可以在类中定义
writeObject/readObject私有方法进行自定义处理。
Q5: 序列化安全——为什么反序列化可能导致安全漏洞?
答:
- 核心问题:反序列化会在不调用构造方法的情况下创建对象并填充字段,可能绕过构造方法中的校验逻辑。
- Gadget Chain 攻击:攻击者构造一个精心设计的序列化对象,在反序列化过程中触发一系列方法调用链(如
HashMap.readObject→hashCode()→TemplatesImpl.getOutputProperties()→ 任意代码执行)。这是Java历史上最严重的安全漏洞之一(如2015年的WebLogic/Shiro漏洞)。 - 防御措施:
- 使用
ObjectInputStream的子类覆盖resolveClass()方法,进行类白名单校验。 - JDK 8 中可以使用
ObjectInputFilter(JDK 9+正式引入,JDK 8可以通过backport使用)。 - 优先使用JSON/XML/Protobuf等替代方案进行数据传输。
- 如果必须使用Java序列化,务必对输入来源做严格的身份验证。
- 使用
七、序列化魔术观察
// 查看序列化文件的前几个字节
import java.io.*;
public class SerializationMagicNumber {
public static void main(String[] args) throws Exception {
// 序列化一个简单字符串
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("d:/feixiang/magic.ser"))) {
oos.writeObject("飞翔科技");
}
// 以十六进制读取文件头部
try (FileInputStream fis = new FileInputStream("d:/feixiang/magic.ser")) {
byte[] header = new byte[16];
int len = fis.read(header);
System.out.println("[大翔] 序列化文件头部 (" + len + " 字节):");
for (int i = 0; i < len; i++) {
System.out.printf("%02X ", header[i]);
if ((i + 1) % 4 == 0) System.out.print(" ");
}
// 输出: AC ED 00 05 ... (魔数 + 版本号)
}
}
}
输出:
[大翔] 序列化文件头部 (16 字节):
AC ED 00 05 75 72 00 13 ...
AC ED— Java序列化魔数("JAVA"的某种变体标志)00 05— 版本号(JDK 1.5以来的标准版本)- 后续是
TC_STRING(字符串类型标记)等协议标记
八、最佳实践总结
| 实践 | 说明 |
|---|---|
| 总是显式声明 serialVersionUID | 避免类结构微小变化导致的反序列化失败 |
| 敏感字段使用 transient | 防止密码、密钥等被持久化到磁盘 |
| 优先使用 try-with-resources | 确保 ObjectOutputStream/ObjectInputStream 被正确关闭 |
| 慎重对待对象图中的引用 | 序列化会遍历整个对象图,确保所有可达对象都实现 Serializable |
| 序列化文件不要跨JVM版本随意传输 | 不同JDK版本的序列化格式可能不兼容 |
| 考虑替代方案 | JSON/Protobuf/MessagePack 等跨语言、跨版本的序列化格式更安全 |
| Externalizable 须提供 public 无参构造 | 否则运行时抛出 InvalidClassException |
| 非静态内部类序列化需谨慎 | 确保外部类也实现 Serializable,或改用静态内部类 |
大翔敲了敲白板:"序列化这玩意儿,说到底就是——把活的对象变成死的字节,再从死的字节里复活活的对象。看似简单,但serialVersionUID是灵魂,transient是墓志铭,对象图是牵一发动全身的关系网。明白了这些,序列化就不再是黑魔法。"白歌点头:"还有,面试的时候多提一嘴反序列化安全漏洞,面试官肯定对你刮目相看。"Frank笑着补充:"And Externalizable is like writing your own serialization protocol — powerful but comes with responsibility."小崔合上笔记本:"今天的信息量,我得好好消化一下……"