序列化与Serializable

什么是序列化

把Java对象变成一串字节(byte流),这样就可以存到文件里或者通过网络传出去

比喻:你要搬家,把家具拆成零件打包装箱(序列化),到了新家再组装回来(反序列化)

序列化:对象 → 字节流(ObjectOutputStream

反序列化:字节流 → 对象(ObjectInputStream

常见使用场景

把对象存到文件或数据库(持久化)

通过网络传输对象(RPC调用、分布式系统)

深拷贝对象(序列化再反序列化,得到一个全新的副本)

Serializable接口

Java中最简单的接口之一——标记接口,里面一个方法都没有

它的作用就是告诉JVM:”这个类的对象允许被序列化”

1
2
3
4
5
6
7
8
9
10
11
12
// 点进去看源码,就这么点东西
public interface Serializable {
// 空的!没有任何方法
// 就是一个"标记",贴了这个标签就能序列化
}

// 使用:让你的类实现这个接口就行
public class User implements Serializable {
private String name;
private int age;
// getter/setter...
}

如果不实现Serializable就去序列化会怎样?

直接抛 NotSerializableException,程序报错

serialVersionUID

序列化的”版本号”,用来确保序列化和反序列化时是同一个版本的类

比喻:就像软件的版本号,你用v1.0打包的数据,v2.0的程序可能读不了

1
2
3
4
5
6
7
public class User implements Serializable {
// 手动指定版本号(推荐)
private static final long serialVersionUID = 1L;

private String name;
private int age;
}

不写serialVersionUID会怎样?

JVM会根据类的结构自动生成一个

但是!你改了类的任何字段(加个字段、删个字段),自动生成的UID就变了

之前序列化保存的数据就反序列化不回来了,报 InvalidClassException

所以实战建议:只要实现了Serializable,就手动写上serialVersionUID

IDE(IDEA/Eclipse)都有快捷方式自动生成,一般写 1L 就行

transient关键字

标记某个字段不参与序列化

应用场景:密码、临时计算结果、缓存数据等不需要/不应该持久化的字段

1
2
3
4
5
6
7
8
9
public class User implements Serializable {
private static final long serialVersionUID = 1L;

private String name;
private int age;
private transient String password; // ⚠️ 不会被序列化

// 反序列化后 password 的值是 null(引用类型默认值)
}
关键字 效果
普通字段 正常序列化和反序列化
transient 字段 序列化时跳过,反序列化后是默认值(int=0, String=null, boolean=false)
static 字段 不参与序列化(static属于类,不属于对象)

代码示例:序列化和反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
@Test
public void testSerialization() throws Exception {
System.out.println("=== 序列化与反序列化 ===");

// ====== 序列化:对象 → 文件 ======
User user = new User("lucy", 25, "secret123");
System.out.println("序列化前:" + user);

try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream("user.dat"))) {
oos.writeObject(user); // 把对象写到文件
System.out.println("序列化成功,对象已保存到 user.dat");
}

// ====== 反序列化:文件 → 对象 ======
try (ObjectInputStream ois = new ObjectInputStream(
new FileInputStream("user.dat"))) {
User restored = (User) ois.readObject(); // 从文件读回对象
System.out.println("反序列化后:" + restored);
System.out.println("name = " + restored.getName()); // lucy
System.out.println("age = " + restored.getAge()); // 25
System.out.println("password = " + restored.getPassword()); // null!被transient标记了
}
}

// 配套的User类
static class User implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password; // 不序列化

public User(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}

// getter方法省略...
public String getName() { return name; }
public int getAge() { return age; }
public String getPassword() { return password; }

@Override
public String toString() {
return "User{name='" + name + "', age=" + age + ", password='" + password + "'}";
}
}

反序列化安全问题

这是安全领域的重灾区,Java反序列化漏洞多年来一直是Web安全的热点

为什么反序列化危险?

反序列化时,JVM会自动调用类的 readObject() 方法来重建对象

如果攻击者能控制输入的字节流,就能让JVM去执行任意代码

关键点:你以为你在读一个普通对象,其实攻击者塞进来的是一个”炸弹”

Apache Commons Collections 反序列化链(经典利用链)

这是最早被公开的Java反序列化利用链,原理简述:

1
2
3
4
5
6
7
8
9
攻击者构造恶意序列化数据
↓ 包含精心嵌套的对象(InvokerTransformer等)
服务端反序列化这段数据
↓ 自动调用readObject()
触发一系列对象的链式调用(Gadget Chain)
↓ TransformedMap → InvokerTransformer
最终执行 Runtime.getRuntime().exec("恶意命令")

服务器被控制 💀

核心思路:找到一条从 readObject()Runtime.exec() 的调用链

工具:ysoserial(专门生成各种反序列化利用链的payload)

真实案例

漏洞 影响范围 简述
WebLogic反序列化 (CVE-2015-4852等) Oracle WebLogic Server T3协议接收序列化数据,未做过滤,直接反序列化导致RCE
Fastjson反序列化 (多个CVE) 阿里巴巴Fastjson库 JSON反序列化时支持 @type 指定类名,攻击者指定恶意类触发RCE
Apache Shiro (CVE-2016-4437) Apache Shiro认证框架 RememberMe功能用了AES加密+Java序列化,密钥硬编码被破解后可构造恶意数据
Jenkins (CVE-2017-1000353) Jenkins CI/CD CLI通道接收序列化对象,未认证即可触发反序列化RCE

防御措施

1. 不要反序列化不可信数据(最根本的防御)

如果数据来自用户、网络、外部系统,别用Java原生反序列化

2. 用JSON/XML代替Java原生序列化

Jackson、Gson这些JSON库比Java原生序列化安全得多

但注意:Fastjson的autoType功能也出过问题,需要关闭或设白名单

3. 白名单校验

自定义ObjectInputStream,重写 resolveClass() 方法,只允许特定类被反序列化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class SafeObjectInputStream extends ObjectInputStream {
private static final Set<String> ALLOWED_CLASSES = Set.of(
"com.example.User",
"com.example.Order"
);

public SafeObjectInputStream(InputStream in) throws IOException {
super(in);
}

@Override
protected Class<?> resolveClass(ObjectStreamClass desc)
throws IOException, ClassNotFoundException {
if (!ALLOWED_CLASSES.contains(desc.getName())) {
throw new InvalidClassException("不允许反序列化的类: " + desc.getName());
}
return super.resolveClass(desc);
}
}

4. Java 9+ 反序列化过滤器(ObjectInputFilter)

1
2
3
4
5
6
7
8
ObjectInputStream ois = new ObjectInputStream(inputStream);
ois.setObjectInputFilter(info -> {
if (info.serialClass() != null
&& !info.serialClass().getName().startsWith("com.example.")) {
return ObjectInputFilter.Status.REJECTED;
}
return ObjectInputFilter.Status.ALLOWED;
});

5. 及时更新依赖库,移除不必要的Gadget库(如老版本的commons-collections)

序列化方式对比

对比项 Java原生序列化 JSON(Jackson/Gson) Protobuf
可读性 二进制,不可读 文本,人类可读 二进制,不可读
性能 一般 一般 很快
体积 较大 中等 很小
跨语言 仅Java 几乎所有语言 几乎所有语言
安全性 (反序列化漏洞多) 较好(注意autoType)
使用场景 几乎不推荐使用 Web API、配置文件 微服务通信、高性能场景

结论:实际项目中,优先用JSON或Protobuf,避免Java原生序列化

常见坑

坑1:类实现了Serializable但内部字段的类没有实现

1
2
3
public class Order implements Serializable {
private User user; // 如果User没有实现Serializable,序列化Order时就会报错!
}

规则:对象图中所有被引用的对象都必须是可序列化的

坑2:修改了类的字段结构但没改serialVersionUID

如果你加了字段、删了字段,但serialVersionUID没变

反序列化时:新加的字段会是默认值,删掉的字段被忽略——不会报错但数据可能不对

如果serialVersionUID变了——直接 InvalidClassException

坑3:单例类被反序列化破坏

反序列化会创建新对象,绕过构造方法,所以单例模式会被破坏

解决办法:在类中添加 readResolve() 方法

1
2
3
4
5
6
7
8
9
public class Singleton implements Serializable {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}

// 反序列化时调用这个方法,返回已有的实例
private Object readResolve() {
return INSTANCE;
}
}

坑4:用Serializable传输敏感数据没加transient

密码、密钥、token等敏感信息,序列化后就是明文字节流

一定要用 transient 标记,或者干脆用JSON+字段过滤

练习题

题1:以下代码序列化后反序列化,各字段的值是什么?

1
2
3
4
5
6
7
8
9
10
public class Student implements Serializable {
private static final long serialVersionUID = 1L;
private String name = "Tom";
private transient int score = 100;
private static String school = "MIT";
}
// 答案:
// name = "Tom"(正常序列化和反序列化)
// score = 0(transient字段不序列化,反序列化后是int的默认值0)
// school = 取决于当前JVM中的值(static不参与序列化,它属于类不属于对象)

题2:serialVersionUID的作用是什么?不写会有什么问题?

作用:序列化版本控制,确保序列化和反序列化的类版本一致

不写:JVM自动生成,一旦类结构改变,UID跟着变,之前序列化的数据就读不回来了

题3:为什么说Java原生反序列化不安全?如何防御?

因为反序列化会自动调用 readObject(),攻击者可以构造恶意序列化数据触发任意代码执行

防御:白名单校验、用JSON代替原生序列化、使用ObjectInputFilter、不反序列化不可信数据

相关笔记

异常体系 | 自定义异常 | try-catch-finally


上一章 目录 下一章
自定义异常 java基础 IO流