序列化与反序列化
一、根本矛盾:内存世界 vs 外部世界
要理解序列化,首先要理解计算机系统里一个根本性的对立:
| 维度 | 内存世界(进程内) | 外部世界(磁盘/网络) |
|---|---|---|
| 存储介质 | RAM(随机访问) | 磁盘、网络传输 |
| 数据载体 | 对象(Object) | 字节流(Byte Stream) |
| 生命周期 | 进程存在则存在,进程消亡则消亡 | 持久存在,可跨进程、跨机器 |
| 访问方式 | 通过引用直接访问 | 通过 I/O 读写访问 |
| 语义 | 有类、有方法、有封装 | 只有 0 和 1,无任何语义 |
核心矛盾就一句话:
内存里精心构造的对象,放到外部世界就只是一堆无意义的字节。
序列化就是用来解决这个矛盾的翻译官:
内存世界 序列化/反序列化 外部世界
┌──────────┐ ┌─────────┐ ┌──────────┐
│ Player │ ─ser──→ │ 字节流 │ ─write──→ │ File/Net │
│ 对象 │ ←deser─ │ 字节流 │ ←read─── │ │
└──────────┘ └─────────┘ └──────────┘
二、词源与本义
2.1 词源
Serialize 来自拉丁语 serere(连接、串联),字面意思是"把东西串成一系列"。
在计算机领域,引申为:把对象的状态,按某种顺序"串"成一串字节。
De- 是表示"反向、去除"的前缀,Deserialize 就是反过来:从一串字节重新组装出对象。
2.2 一句话定义
序列化(Serialization)= 将内存中对象的完整状态信息,转换成可传输/可存储的字节流格式。
反序列化(Deserialization)= 从字节流中还原出原来的对象。
核心要求只有一个:保真。
三、三大刚需场景
为什么非要做"翻译"?因为有三大场景逼着必须把对象变成字节再变回来。
3.1 持久化 —— 让数据"活下去"
痛点:进程一死,内存全丢。写了半天的游戏存档、用户配置、草稿文本,全没了。
序列化的作用:把内存对象变成字节,写入磁盘,进程死了数据还在。
内存中的 User 对象 序列化写入 磁盘文件
┌─────────────────┐ ┌──────────┐
│ name = "张三" │ ─── 序列化 ───→ write ───→ │ { │
│ age = 28 │ │ "name":"张三", │
│ email = "..." │ │ "age":28 │
│ │ │ } │
└─────────────────┘ └──────────┘
重启后:
读取文件 → 反序列化 → 重新得到 User 对象
典型应用:
- 游戏存档(Player 对象 → 存档文件)
- 配置文件(Properties 对象 → .properties 文件)
- 数据库存储(ORM 把对象序列化成行,存进数据库)
- 文档格式(Word、PDF 本质上都是序列化后的字节流)
3.2 分布式通信 —— 让数据"走出去"
痛点:两个进程在不同机器上,它们的内存物理隔离,不能直接共享对象引用。
进程 A 的 User 对象,进程 B 看不到也摸不到。进程 A 想告诉进程 B,只能把对象"翻译成字节",通过网络发过去。
机器 A 的进程 X 机器 B 的进程 Y
┌──────────────┐ ┌──────────────┐
│ User 对象 │ 网络传输 │ User 对象 │
│ │ ───字节流──────→ │ │
└──────────────┘ └──────────────┘
(序列化) (反序列化)
关键洞察:
进程 A 的
User对象和进程 B 的User对象,是两个独立的实例。网络上传过去的不是"对象本身",而是"对象的信息"——B 收到信息后,在自己的内存里重新构造一个新的 User 对象。
这就是为什么叫"序列化/反序列化"——它是复制,不是共享。
典型应用:
- RPC 调用(gRPC、Dubbo:把请求对象序列化成字节,网络传输后反序列化)
- REST API(JSON 本质就是序列化格式)
- 消息队列(Kafka/RabbitMQ:生产者序列化消息,消费者反序列化)
- 集群状态同步(Redis 的 RDB/AOF 就是序列化过程)
3.3 跨语言/跨平台 —— 让数据"说同一种话"
痛点:Java 的 User 对象,Python、Go、C++ 读不懂。每个语言的对象模型不一样。
序列化的作用:用一种中立的格式(如 JSON、Protobuf)做中间翻译,任何语言都能序列化和反序列化。
Java User 对象 ──序列化为JSON──→ JSON 字节流 ──反序列化──→ Python User 对象
(JVM 内存) (Python 内存)
3.4 三大场景汇总
| 场景 | 解决的问题 | 典型应用 |
|---|---|---|
| 持久化 | 让对象超越进程生命周期 | 存档、配置、数据库 |
| 分布式通信 | 让对象跨越进程/机器边界 | RPC、MQ、HTTP |
| 跨语言交流 | 让对象跨越语言边界 | JSON、Protobuf |
3.5 三大场景的实例
下面用三个可运行的实例,展示序列化在真实场景里的实际用法。
实例一:持久化 —— 游戏存档
进程死了数据还在。用 Jackson 把 Player 对象存成 JSON 文件,下次启动读回来。
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.File;
// 游戏角色
public class Player {
public String name; // 角色名
public int level; // 等级
public int hp; // 血量
public String[] inventory; // 背包
public Player() {} // Jackson 反序列化需要无参构造
}
public class GameSave {
private static final ObjectMapper mapper = new ObjectMapper();
private static final File SAVE_FILE = new File("save.json");
// 存档:Player 对象 → JSON 文件
public static void save(Player player) throws Exception {
mapper.writeValue(SAVE_FILE, player);
// save.json 内容:{"name":"勇者","level":25,"hp":800,"inventory":["铁剑","药水"]}
System.out.println("存档成功,进程就算死了,数据还在磁盘上");
}
// 读档:JSON 文件 → Player 对象
public static Player load() throws Exception {
Player player = mapper.readValue(SAVE_FILE, Player.class);
System.out.println("读档成功:" + player.name + " Lv." + player.level);
return player;
}
public static void main(String[] args) throws Exception {
// 第一次运行:创建角色并存档
Player hero = new Player();
hero.name = "勇者";
hero.level = 25;
hero.hp = 800;
hero.inventory = new String[]{"铁剑", "药水"};
save(hero);
// "进程死了" —— 重新启动后读档
// 实际开发中这是另一次程序启动
Player restored = load();
// restored 和 hero 是两个不同的对象,但状态完全一致
System.out.println(hero == restored); // false —— 不是同一个对象
System.out.println(hero.name.equals(restored.name)); // true —— 但状态一样
}
}
hero和restored是两个独立的对象(==为 false),但状态完全一致。序列化的本质是复制,不是共享。
实例二:分布式通信 —— Socket 传对象
两个进程之间传一个 User 对象。发送方序列化成 JSON 字节流,接收方反序列化回对象。
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.*;
import java.net.*;
// 传输的数据对象
public class User {
public String name;
public int age;
public User() {}
}
// 服务端:接收 User 对象
public class Server {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
ServerSocket server = new ServerSocket(9999);
System.out.println("服务端启动,等待接收对象...");
Socket socket = server.accept();
// 从网络流里读字节,反序列化成 User 对象
User user = mapper.readValue(socket.getInputStream(), User.class);
System.out.println("收到对象:" + user.name + "," + user.age + "岁");
// 输出:收到对象:张三,28岁
socket.close();
server.close();
}
}
// 客户端:发送 User 对象
public class Client {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Socket socket = new Socket("127.0.0.1", 9999);
User user = new User();
user.name = "张三";
user.age = 28;
// 把 User 对象序列化成 JSON 字节流,写入网络
mapper.writeValue(socket.getOutputStream(), user);
// 网络上跑的不是"对象",是这串字节:{"name":"张三","age":28}
System.out.println("对象已发送");
socket.close();
}
}
客户端的
user和服务端反序列化出来的user,在两台机器的两个 JVM 里,是完全独立的两个对象。它们之间只通过一串 JSON 字节产生了联系。
实例三:跨语言 —— Java 写,Python 读
同一个 JSON 字节流,Java 写出来,Python 能读,Go 也能读。这就是"中立格式"的威力。
Java 端(序列化):
import com.fasterxml.jackson.databind.ObjectMapper;
public class CrossLangDemo {
public static void main(String[] args) throws Exception {
ObjectMapper mapper = new ObjectMapper();
User user = new User();
user.name = "张三";
user.age = 28;
// 序列化成 JSON 字符串
String json = mapper.writeValueAsString(user);
System.out.println(json);
// 输出:{"name":"张三","age":28}
// 这个字符串可以发给任何语言
}
}
Python 端(反序列化):
import json
# 收到 Java 发来的 JSON 字符串
json_str = '{"name":"张三","age":28}'
# 反序列化成 Python 字典
user = json.loads(json_str)
print(user['name']) # 张三
print(user['age']) # 28
# Python 不需要知道 Java 的 User 类长什么样
# JSON 是中立格式,谁都能读
Java 的
User对象和 Python 的dict字典,结构完全不同。但通过 JSON 这个"中立格式",两个语言的世界被打通了。这就是序列化跨语言的本质。
三个实例的共同模式
不管哪个场景,核心都是同一个闭环:
对象 → 序列化 → 字节流 → [存储/传输] → 字节流 → 反序列化 → 对象
原对象和新对象是两个独立实例,只是状态一致
| 实例 | 字节流去了哪 | 用什么格式 |
|---|---|---|
| 游戏存档 | 磁盘文件 | JSON |
| Socket 通信 | 网络管道 | JSON |
| 跨语言 | 从 Java 进程到 Python 进程 | JSON |
三个实例都用 JSON,是因为它可读、跨语言、示例清晰。实际工程中 RPC 内部通信更常用 Protobuf(更小更快),但原理完全一样。
四、序列化的本质:信息的保真转换
理解了场景,我们来提炼序列化的本质定义:
序列化 = 将内存中对象的完整状态信息,转换成可传输/存储的字节流格式。
反序列化 = 从字节流中还原出原来的对象。
核心要求只有一个:保真。
即 serialize(obj) 后再 deserialize(bytes),得到的新对象应该和原对象状态完全一致:
User original = new User("张三", 28);
byte[] bytes = serialize(original);
User restored = deserialize(bytes);
// 保真要求:
assert restored.getName().equals(original.getName()); // 张三
assert restored.getAge() == original.getAge(); // 28
assert restored.equals(original); // 逻辑相等
// 注意:不是同一个对象!original != restored
4.1 保真的难题
"保真"说起来简单,但做起来有挑战:
| 难题 | 说明 | 例子 |
|---|---|---|
| 引用关系 | 对象 A 引用对象 B,序列化后还要还原这个引用 | 用户订单列表里的每个订单都要关联回原用户 |
| 循环引用 | A 引用 B,B 又引用 A,怎么序列化? | 用户与角色的双向引用 |
| 类型信息 | 反序列化时,要知道字节流对应什么类才能还原 | 字节流里要带上 com.example.User 的类名 |
| 状态 vs 行为 | 对象有状态(字段)和行为(方法),序列化只存状态 | 反序列化后,新对象的方法从哪来?类定义要存在 |
| 瞬态字段 | 有些字段不该序列化(如缓存、临时变量) | transient 关键字标记 |
| 版本演进 | 类结构变了,旧数据反序列化可能失败 | 加了字段后,旧版本序列化的数据读不出来 |
五、常用序列化技术对比
序列化 ≠ JSON。JSON 只是序列化格式的一种。下面按流派系统对比。
5.1 流派划分
序列化技术
│
┌──────────────┼──────────────┐
│ │ │
Java 原生系 JSON 文本系 二进制跨语言系
│ │ │
Serializable Jackson Protobuf
Externalizable Gson Thrift
Fastjson MessagePack
Moshi Hessian
Avro
Kryo
5.2 流派一:Java 原生序列化
JDK 自带,通过 java.io.Serializable 标记接口实现。
// 1. 类实现 Serializable 即可
public class User implements Serializable {
private static final long serialVersionUID = 1L; // 版本号
private String name;
private int age;
private transient String password; // 不序列化
}
// 2. 序列化
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.dat"));
oos.writeObject(user);
oos.close();
// 3. 反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.dat"));
User user = (User) ois.readObject();
ois.close();
特点:
| 维度 | 说明 |
|---|---|
| 优点 | JDK 自带,无需依赖;使用极简;支持对象图(引用、循环引用) |
| 缺点 | 只能 Java 用,跨语言差;体积大(带类信息);性能一般;安全漏洞多 |
| 安全 | 不安全的反序列化是经典攻击面(Log4j 漏洞就是一例) |
| 适用 | Java 内部的 RMI、Java 原生缓存、临时存储 |
Externalizable:Serializable 的子接口,需要自己实现 writeExternal / readExternal,灵活但繁琐,性能略好。
结论:新项目不要用 Java 原生序列化。它存在的意义主要是历史兼容。
5.3 流派二:JSON 文本系
把对象序列化成人类可读的文本格式(JSON 字符串),跨语言、可读性强。
5.3.1 Jackson
Spring 默认集成的 JSON 库,功能最全、生态最广。
ObjectMapper mapper = new ObjectMapper();
// 序列化
String json = mapper.writeValueAsString(user);
// {"name":"张三","age":28}
// 反序列化
User user = mapper.readValue(json, User.class);
| 维度 | 说明 |
|---|---|
| 优点 | 功能强大(注解、流式 API、树模型三套 API);性能好;生态丰富 |
| 缺点 | API 相对重,配置项多,上手有点陡 |
| 适用 | Spring 项目、REST API、通用 JSON 处理 |
5.3.2 Gson
Google 出品,轻量、API 简洁。
Gson gson = new Gson();
String json = gson.toJson(user); // 序列化
User user = gson.fromJson(json, User.class); // 反序列化
| 维度 | 说明 |
|---|---|
| 优点 | 极简 API;零依赖;对泛型支持好 |
| 缺点 | 性能不如 Jackson;扩展性弱 |
| 适用 | Android、轻量场景、快速开发 |
5.3.3 Fastjson
阿里出品,性能优化激进,但历史上漏洞频发。
| 维度 | 说明 |
|---|---|
| 优点 | 性能极好(早期);API 简洁 |
| 缺点 | 历史漏洞多(多次 RCE);AutoType 机制是漏洞根源 |
| 适用 | 不推荐新项目使用;老项目可考虑迁移到 Fastjson2 或 Jackson |
5.3.4 Moshi
Square 出品,Kotlin 友好,相对新潮。
| 维度 | 说明 |
|---|---|
| 优点 | Kotlin 支持好;API 简洁;性能不错 |
| 缺点 | 生态不如 Jackson |
| 适用 | Kotlin/Android 项目 |
5.3.5 JSON 系的共同特性
| 共同点 | 说明 |
|---|---|
| 格式 | 文本,人类可读 |
| 跨语言 | 所有语言都支持 |
| 体积 | 比二进制大(字段名占空间) |
| 性能 | 解析比二进制慢(要解析字符串) |
| Schema | 无强类型 schema,结构靠字段名约定 |
5.4 流派三:二进制跨语言系
把对象序列化成紧凑的二进制字节流,跨语言、性能高、体积小。需要预定义 Schema(IDL,接口定义语言)。
5.4.1 Protobuf(Protocol Buffers)
Google 出品,业界事实标准,gRPC 的默认序列化协议。
// 1. 先定义 .proto schema
syntax = "proto3";
message User {
string name = 1;
int32 age = 2;
}
// 2. 编译生成 Java 类
// 3. 序列化/反序列化
User user = User.newBuilder().setName("张三").setAge(28).build();
byte[] bytes = user.toByteArray(); // 序列化
User restored = User.parseFrom(bytes); // 反序列化
| 维度 | 说明 |
|---|---|
| 优点 | 体积最小;速度最快;跨语言;Schema 强类型;版本演进友好 |
| 缺点 | 不可读;需预编译;改字段要同步改 schema |
| 适用 | RPC(gRPC)、存储、高性能场景 |
5.4.2 Thrift
Facebook 出品,类似 Protobuf,但自带 RPC 框架。
| 维度 | 说明 |
|---|---|
| 优点 | 自带 RPC 框架;跨语言;性能好 |
| 缺点 | 文档少;社区活跃度不如 Protobuf |
| 适用 | 旧系统、内部 RPC |
5.4.3 MessagePack
"二进制 JSON",格式接近 JSON 但用二进制编码。
// 不需要 schema
ObjectMapper mapper = new ObjectMapper(new MessagePackFactory());
byte[] bytes = mapper.writeValueAsBytes(user);
User restored = mapper.readValue(bytes, User.class);
| 维度 | 说明 |
|---|---|
| 优点 | 比 JSON 小、快;无需 schema;API 接近 JSON |
| 缺点 | 比 Protobuf 大、慢;不可读 |
| 适用 | Redis 存储结构化数据、跨语言但不想定义 schema |
5.4.4 Hessian
Caucho 出品,Dubbo 早期默认序列化协议。
| 维度 | 说明 |
|---|---|
| 优点 | 二进制、跨语言;自带 RPC 概念 |
| 缺点 | 性能不如 Protobuf;社区不活跃 |
| 适用 | 老版本 Dubbo |
5.4.5 Avro
Apache 顶级项目,Hadoop/Kafka 生态常用。
| 维度 | 说明 |
|---|---|
| 优点 | Schema 演进最好;大数据生态集成深 |
| 缺点 | 小项目偏重;学习成本高 |
| 适用 | 大数据场景(Kafka、Hadoop) |
5.4.6 Kryo
Java 专用,不跨语言,性能极高。
| 维度 | 说明 |
|---|---|
| 优点 | Java 内性能最好;体积小 |
| 缺点 | 不跨语言;不保证版本兼容 |
| 适用 | 纯 Java 内部的高性能序列化(如 Spark 内部、Flink 状态) |
5.5 三大流派横向对比
| 维度 | Java 原生 | JSON 文本系 | 二进制跨语言系 |
|---|---|---|---|
| 跨语言 | 不支持 | 支持 | 支持 |
| 可读性 | 不可读 | 可读 | 不可读 |
| 体积 | 大 | 较大 | 最小 |
| 速度 | 慢 | 较慢 | 最快 |
| Schema | 不需要 | 不需要 | 需要(除 MessagePack) |
| 类型安全 | 弱 | 弱 | 强 |
| 版本演进 | 差 | 好 | 好 |
| 安全性 | 差 | 一般 | 好 |
| 典型代表 | Serializable | Jackson、Gson | Protobuf、Avro |
5.6 选型决策表
| 场景 | 推荐技术 | 原因 |
|---|---|---|
| REST API、对外接口 | Jackson(JSON) | 可读、跨语言、生态好 |
| 内部 RPC、高性能通信 | Protobuf + gRPC | 体积小、速度快、类型安全 |
| 配置文件、简单存储 | Jackson / Gson(JSON) | 可读、易调试 |
| 缓存(Redis) | Jackson(JSON)或 MessagePack | JSON 可读,MessagePack 紧凑 |
| 大数据(Kafka、Hadoop) | Avro | Schema 演进友好、生态集成深 |
| 纯 Java 内部高性能 | Kryo | 同语言场景下最快 |
| 跨语言但不想定义 schema | MessagePack | 像 JSON 一样灵活,但更紧凑 |
| 历史遗留 Java RMI | Java 原生 | 仅限兼容老系统 |
六、常见误解澄清
6.1 误解 1:"序列化就是把对象转成 JSON"
片面。JSON 只是序列化格式的一种。
序列化格式很多:
| 格式 | 类型 | 特点 |
|---|---|---|
| Java 原生二进制 | 二进制 | Java 专用、紧凑 |
| JSON | 文本 | 可读、跨语言、体积较大 |
| Protobuf | 二进制 | 高效、跨语言、需 schema |
| MessagePack | 二进制 | 介于 JSON 和 Protobuf 之间 |
| Avro | 二进制 | 大数据生态常用 |
| XML | 文本 | 冗长、可读性好、成本高 |
序列化 ≠ JSON。JSON 是序列化的一种实现,序列化是一种思想。
6.2 误解 2:"序列化就是深拷贝"
不对。
深拷贝:User → 直接在内存创建新的 User(同进程内)
序列化:User → 字节流 → 在另一个进程/机器创建新的 User
深拷贝在同一个进程内直接创建新对象,还是在内存里。序列化是跨进程/跨机器的行为,要经过字节流中转。
可以用序列化实现深拷贝(先序列化再反序列化),但序列化的目的不只是深拷贝。
6.3 误解 3:"序列化会序列化方法"
错误。序列化只存对象状态(字段值),不存方法。
反序列化时,新对象的方法来自类定义——要求目标进程必须加载了对应的类。如果类不存在,反序列化就会失败(ClassNotFoundException)。
序列化的是: 对象的状态(字段值)
不序列化: 方法、类定义、static 字段、transient 字段
反序列化时: 方法从已加载的类定义中获取
6.4 误解 4:"JSON 比 Protobuf 通用,所以应该都用 JSON"
错误。
JSON 通用是因为它可读、无 schema、跨语言——适合人机交互、对外 API。
但在内部 RPC、高频通信、大数据存储场景:
- JSON 字段名占大量空间("username":"zhangsan" 比 1:"zhangsan" 多 6 字节)
- JSON 解析慢(要解析字符串、转义、引号匹配)
- JSON 无类型约束(运行时才发现字段错了)
对外用 JSON,对内用 Protobuf——这是工业界的常见做法。
6.5 误解 5:"反序列化就是 new 一个对象然后赋值"
不准确。
反序列化创建对象的方式绕过了构造方法:
// 普通创建对象:调用构造方法
User u = new User("张三", 28);
// 反序列化:通过反射/unsafe 直接分配内存,不调用构造方法
User u = (User) unsafe.allocateInstance(User.class);
// 然后通过反射给字段赋值
这就是为什么:
- 反序列化时构造方法不会执行(单例会被破坏)
- final 字段在反序列化时可能被绕过
- 单例类要实现 readResolve() 防止反序列化破坏单例
七、序列化的代价
任何技术都有代价,序列化也不例外:
| 代价 | 说明 |
|---|---|
| 性能开销 | 序列化/反序列化需要 CPU,格式选型影响速度 |
| 体积膨胀 | 对象变字节后,往往比直接在内存中占用更大 |
| 版本兼容 | 类结构变了,旧数据反序列化可能失败 |
| 安全风险 | 不安全的反序列化是常见漏洞(Log4j 就是反序列化漏洞) |
| 类型耦合 | 原生 Java 序列化绑定了 Java 版本,跨语言困难 |
八、学习路径建议
第一阶段:建立概念
├── 理解序列化的本质(对象与字节流的转换)
├── 搞懂三大场景(持久化、通信、跨语言)
└── 写出第一个 Serializable 程序
第二阶段:掌握工具
├── Jackson / Gson(JSON 处理)
├── Java 原生序列化的机制与坑
├── transient、serialVersionUID 的作用
└── readResolve / writeReplace 的原理
第三阶段:深入原理
├── 二进制序列化格式的设计(Protobuf 编码原理)
├── Schema 演进与版本兼容
├── 反序列化绕过构造方法的机制
└── 序列化安全漏洞的攻击面与防御
第四阶段:工程实践
├── RPC 框架的序列化集成
├── 大规模数据的序列化选型
├── 跨语言通信的 schema 管理
└── 序列化性能调优
九、总结
序列化的根本动机,是为了跨越"内存世界"和"外部世界"的鸿沟。
内存里的对象有类、有引用、有生命周期,一旦要持久化、要传输、要跨语言交流,就必须把它"翻译"成中立的字节流——这就是序列化。
反序列化是反向过程:从字节流重新构造出对象。新对象和原对象不是同一个实例,只是状态一致——所以序列化本质是复制,不是共享。
技术选型上:
- 对外、可读、跨语言 → JSON(Jackson)
- 对内、高性能、跨语言 → Protobuf(gRPC)
- 大数据、Schema 演进 → Avro
- 纯 Java 内部极致性能 → Kryo记住一句话:序列化不是为了"变成字节",而是为了"跨越边界"——让对象的状态能穿越进程、机器、语言的隔阂。