Java 序列化全过程原理
序列化不是“实现 Serializable 就完事”。真正要掌握它,必须理解对象为什么不能直接跨进程传输,字节流里保存了什么,反序列化为什么不走普通构造方法,serialVersionUID 为什么能决定兼容性,transient 为什么能保护敏感字段,以及为什么商业系统很少直接把 Java 原生序列化暴露给外部请求。
一句话先建立直觉:
序列化是把内存里的对象状态转换成可存储、可传输的数据;反序列化是按协议把这些数据重新组装成对象。
学习目标
学完这一页,你要能说清楚:
- 为什么 Java 对象不能直接写入文件、Redis、MQ 或网络。
- Java 原生序列化完整写入和读取流程。
Serializable为什么只是标记接口。ObjectOutputStream.writeObject大概会写入哪些信息。- 反序列化为什么可以不调用普通构造方法就创建对象。
serialVersionUID怎么影响兼容性。transient、static字段为什么不会按普通字段序列化。writeObject、readObject、readResolve、writeReplace有什么作用。- 为什么反序列化不可信数据有安全风险。
- 商业系统如何选择 JSON、Protobuf、Hessian、Kryo、JDK 原生序列化。
为什么需要序列化
Java 对象存在于某个 JVM 进程的堆内存中。对象里不只有字段值,还包含对象头、类型指针、对其他对象的引用等运行时结构。
这些东西不能直接跨机器使用:
JVM A 堆内存里的 User 对象地址:0x00000123
JVM B 不认识这个地址,也没有这块堆内存所以对象要离开当前 JVM,就必须变成某种通用数据格式:
flowchart TD
A["JVM堆内存中的对象"] --> B["提取对象状态"]
B --> C["按协议编码"]
C --> D["字节数组、JSON、二进制数据"]
D --> E["文件、网络、Redis、MQ"]
E --> F["按协议解码"]
F --> G["重新组装对象"]序列化解决的核心问题是:对象跨内存边界。
常见边界包括:
| 边界 | 示例 |
|---|---|
| 内存到文件 | 把对象保存到磁盘 |
| JVM 到 JVM | RPC 远程调用 |
| 应用到 Redis | 缓存对象 |
| 应用到 MQ | 发送订单消息 |
| Web 容器节点之间 | Session 复制 |
Java 原生序列化最小 Demo
对象类:
import java.io.Serializable;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
public User(Long id, String username) {
this.id = id;
this.username = username;
}
@Override
public String toString() {
return "User{id=" + id + ", username='" + username + "'}";
}
}写入文件:
import java.io.FileOutputStream;
import java.io.ObjectOutputStream;
public class SerializeDemo {
public static void main(String[] args) throws Exception {
User user = new User(1L, "Tom");
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("user.bin"))) {
out.writeObject(user);
}
}
}读取文件:
import java.io.FileInputStream;
import java.io.ObjectInputStream;
public class DeserializeDemo {
public static void main(String[] args) throws Exception {
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("user.bin"))) {
User user = (User) in.readObject();
System.out.println(user);
}
}
}这就是 Java 原生序列化的基本链路。
总体流程图
flowchart TD
A["对象实现 Serializable"] --> B["ObjectOutputStream.writeObject"]
B --> C["检查对象类型和序列化权限"]
C --> D["写入类描述信息"]
D --> E["写入 serialVersionUID"]
E --> F["写入非 transient、非 static 字段值"]
F --> G["递归写入引用对象"]
G --> H["生成字节流"]
H --> I["ObjectInputStream.readObject"]
I --> J["读取类描述信息"]
J --> K["匹配本地 Class 和 serialVersionUID"]
K --> L["创建对象并恢复字段"]注意:序列化保存的是对象状态,不是保存完整 JVM 内存布局。
Serializable 为什么是标记接口
Serializable 源码里没有方法:
public interface Serializable {
}它是标记接口。它的作用不是让你实现某个方法,而是告诉 Java 序列化机制:这个类允许被序列化。
如果对象没有实现 Serializable,直接序列化会抛:
java.io.NotSerializableException为什么要有这个标记?
因为不是所有对象都适合序列化,比如:
- 文件流、Socket、数据库连接这类本地资源。
- 线程、锁、线程池这类运行时对象。
- 包含敏感信息的安全对象。
- 和当前机器环境强绑定的对象。
实现 Serializable 等于类作者显式声明:“这个类的对象状态可以被序列化。”
字节流里大概有什么
Java 原生序列化不是简单把字段拼起来。它会写入对象图和类描述信息。
可以近似理解为:
| 信息 | 说明 |
|---|---|
| 流头信息 | 标识这是 Java 序列化流 |
| 类描述 | 类名、字段列表、父类信息 |
serialVersionUID | 版本兼容检查 |
| 字段值 | 非 static、非 transient 字段 |
| 对象引用关系 | 避免重复对象无限写入 |
| 特殊标记 | null、引用、数组、字符串等标记 |
如果对象里引用了另一个对象:
public class Order implements Serializable {
private User user;
}那么 Order 序列化时,也会尝试序列化 user。如果 User 没实现 Serializable,也会失败。
flowchart TD
A["Order对象"] --> B["orderNo字段"]
A --> C["User引用"]
C --> D["User对象也必须可序列化"]
D --> E["写入User字段"]所以序列化关注的是整个对象图,不只是当前对象本身。
反序列化为什么不走普通构造方法
看这个例子:
import java.io.Serializable;
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
public User() {
System.out.println("User constructor");
}
public User(String username) {
this.username = username;
System.out.println("User constructor with username");
}
}反序列化时,你通常不会看到普通构造方法里的打印。
原因是:反序列化要恢复“之前保存的对象状态”,不是按普通业务构造流程创建一个全新对象。JVM 会用特殊机制分配对象,然后直接恢复字段。
这会带来两个重要影响:
- 构造方法里的校验逻辑可能不会执行。
- 对象不变量可能被恶意字节流破坏。
所以反序列化不可信数据很危险,不能把它当成普通 new 对象。
serialVersionUID 是什么
serialVersionUID 是类的序列化版本号。
private static final long serialVersionUID = 1L;反序列化时,JVM 会比较:
- 字节流里保存的
serialVersionUID。 - 当前 class 文件里的
serialVersionUID。
flowchart TD
A["旧数据中的 serialVersionUID"] --> C["版本比较"]
B["当前类的 serialVersionUID"] --> C
C --> D["一致:继续恢复字段"]
C --> E["不一致:InvalidClassException"]如果不显式声明,JVM 会根据类名、字段、方法等结构计算一个值。类结构一改,自动计算值可能变化,旧数据就读不回来。
所以可序列化类建议显式声明:
private static final long serialVersionUID = 1L;类结构变化怎么影响兼容性
假设旧版本:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
}新版本新增字段:
public class User implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private String phone;
}如果 serialVersionUID 不变,旧数据通常还能反序列化,新字段是默认值 null。
常见兼容性:
| 变更 | 通常影响 |
|---|---|
| 新增字段 | 通常可兼容,新字段为默认值 |
| 删除字段 | 旧数据里的字段被忽略,但业务含义要小心 |
| 字段改名 | 等价于删除旧字段、新增新字段,旧值丢失 |
| 字段类型改变 | 容易失败或数据异常 |
修改 serialVersionUID | 直接视为不兼容 |
商业项目里,进入 Redis、MQ、文件、远程接口的数据结构都要按协议演进,不要随手改。
transient 的作用
transient 表示字段不参与默认序列化。
import java.io.Serializable;
public class LoginUser implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String username;
private transient String password;
}反序列化后,password 会是默认值 null。
适合标记为 transient 的字段:
| 字段 | 原因 |
|---|---|
| 密码、token | 安全敏感,不应落盘或传输 |
| 临时计算结果 | 可以重新计算 |
| 本地资源句柄 | 文件流、Socket、连接不能跨机器恢复 |
| 大对象缓存 | 避免序列化体积过大 |
| 运行时上下文 | 线程、请求上下文不应持久化 |
static 字段会序列化吗
static 字段属于类,不属于某个对象实例。默认序列化保存的是对象实例状态,所以 static 字段不会作为对象字段序列化进去。
public class Config implements Serializable {
private static final long serialVersionUID = 1L;
public static String globalName = "A";
private String instanceName = "B";
}序列化保存的是 instanceName,不是 globalName。
如果反序列化后看到 static 值,那是当前 JVM 里类静态字段的当前值,不是从字节流恢复出来的。
自定义序列化:writeObject 和 readObject
有时默认序列化不够,需要自己控制字段写入和读出。
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
public class SecureUser implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
private transient String password;
public SecureUser(String username, String password) {
this.username = username;
this.password = password;
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(mask(password));
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
String maskedPassword = (String) in.readObject();
this.password = null;
System.out.println("读取到脱敏密码: " + maskedPassword);
}
private String mask(String value) {
if (value == null) {
return null;
}
return "***";
}
}这两个方法有特殊签名,序列化机制会通过反射识别它们:
private void writeObject(ObjectOutputStream out)
private void readObject(ObjectInputStream in)注意:不要在里面写危险逻辑,不要信任反序列化输入。
writeReplace 和 readResolve
writeReplace 可以在序列化前替换对象。
readResolve 可以在反序列化后替换对象,常用于单例保护。
单例示例:
import java.io.Serializable;
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
public static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
private Object readResolve() {
return INSTANCE;
}
}如果没有 readResolve,反序列化可能创建出一个新的单例对象,破坏“全局只有一个实例”的语义。
Demo:版本兼容问题
第一版类:
public class OrderMessage implements Serializable {
private static final long serialVersionUID = 1L;
private String orderNo;
private Integer status;
}写入 MQ 或 Redis 后,第二版把 status 改成:
private String status;这就可能导致反序列化失败或业务解析异常。更稳妥的做法是新增字段:
private Integer status;
private String statusText;然后灰度迁移,等旧数据都消费完或缓存都过期,再考虑清理旧字段。
商业系统里尤其要注意:
- MQ 消息可能滞留。
- Redis 缓存可能有长 TTL。
- 文件归档可能保存很久。
- 多个服务可能不是同一时间发布。
序列化安全风险
反序列化不可信数据是 Java 安全里非常重要的风险点。
危险点在于:
- 反序列化会根据字节流创建对象。
- 某些类在
readObject、readResolve、动态代理、集合比较等过程中会执行逻辑。 - 如果 classpath 中存在可利用的调用链,就可能导致远程代码执行等漏洞。
所以不要这样做:
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Object obj = in.readObject();尤其不能对外部请求、MQ 不可信来源、用户上传文件直接使用 Java 原生反序列化。
安全建议:
- 不接收不可信来源的 Java 原生序列化数据。
- 优先使用 JSON、Protobuf 等格式,并做字段级校验。
- 使用 JDK 9+ 的反序列化过滤器限制允许的类。
- 对历史系统做白名单过滤。
- 依赖库及时升级,减少可利用 gadget 链。
JDK 9+ 反序列化过滤器
JDK 9 引入了对象输入过滤机制,可以限制反序列化允许的类、深度、数组大小等。
示例:
import java.io.ObjectInputFilter;
import java.io.ObjectInputStream;
ObjectInputStream in = new ObjectInputStream(inputStream);
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.dto.*;java.base/*;!*"
);
in.setObjectInputFilter(filter);
Object value = in.readObject();含义:
- 允许
com.example.dto下的 DTO。 - 允许
java.base里的基础类。 - 其他全部拒绝。
这不是让原生反序列化变得“绝对安全”,而是降低攻击面。
JSON、Protobuf、Hessian、Kryo 对比
| 方式 | 可读性 | 跨语言 | 性能/体积 | 常见场景 |
|---|---|---|---|---|
| Java 原生序列化 | 差 | 差 | 一般 | Java 内部临时存储、老系统 |
| JSON | 好 | 好 | 体积偏大 | HTTP API、MQ 通用消息 |
| Protobuf | 差 | 好 | 好 | RPC、跨语言、高性能消息 |
| Hessian | 一般 | 一般 | 较好 | Java RPC、老 Dubbo 场景 |
| Kryo | 差 | 差 | 好 | Java 内部高性能序列化 |
商业系统选择时看四个维度:
- 是否跨语言。
- 是否需要可读性。
- 性能和体积要求。
- 版本兼容和治理能力。
Redis 缓存中的序列化
Redis 保存的是字节数据,不懂 Java 对象。
flowchart TD
A["User对象"] --> B["序列化为JSON或二进制"]
B --> C["写入Redis"]
C --> D["读取字节数据"]
D --> E["反序列化为User对象"]常见问题:
- 改了类字段,老缓存反序列化失败。
- 使用 JDK 序列化导致 Redis 里数据不可读。
- 多语言系统无法读取 Java 原生序列化数据。
- 缓存里存大对象导致网络和内存压力。
建议:
- 跨服务缓存优先 JSON 或明确协议。
- key 加版本前缀,例如
user:v2:1001。 - 对大对象拆分或只缓存必要字段。
- DTO 和数据库实体不要混用,避免字段变更影响缓存协议。
MQ 消息中的序列化
MQ 消息体本质也是字节数组。生产者序列化,消费者反序列化。
flowchart TD
A["生产者创建OrderPaidMessage"] --> B["序列化消息体"]
B --> C["发送到MQ"]
C --> D["消费者拉取消息"]
D --> E["反序列化消息体"]
E --> F["执行业务处理"]MQ 场景尤其注意兼容性,因为:
- 消息可能堆积,旧版本消息过很久才消费。
- 消费者可能比生产者晚发布。
- 不同消费者可能使用不同语言。
- 消息一旦发出就很难修改。
建议:
- 消息体显式带版本号。
- 新增字段要给默认值。
- 不随便删除字段和改变字段含义。
- 消费者要兼容未知字段。
- 不发送超大对象,传 ID 再查详情通常更稳。
RPC 中的序列化
RPC 调用看起来像本地方法:
UserDTO user = userService.getById(1L);实际要做:
flowchart TD
A["客户端方法调用"] --> B["参数序列化"]
B --> C["网络传输"]
C --> D["服务端反序列化参数"]
D --> E["调用真实服务"]
E --> F["返回值序列化"]
F --> G["网络返回"]
G --> H["客户端反序列化结果"]所以 RPC 框架必须关心:
- 序列化性能。
- 协议兼容性。
- 跨语言能力。
- 类加载和泛型类型。
- 安全边界。
Dubbo、gRPC 等框架的协议和序列化选择,直接影响吞吐、延迟和兼容性。
商业项目怎么设计序列化对象
用 DTO/Message,不直接用 Entity
不建议直接把数据库实体发到 MQ 或暴露给外部 API:
public class OrderEntity {
private Long id;
private String orderNo;
private String internalRemark;
private Date createTime;
}更建议定义消息对象:
public class OrderPaidMessage {
private String messageVersion;
private String orderNo;
private Long userId;
private Integer amount;
private Long paidAt;
}原因:
- Entity 字段会随着数据库变化,不适合作为外部协议。
- Message 可以明确版本和业务语义。
- 避免泄露内部字段。
- 消费者不需要依赖生产者数据库模型。
消息体要小而稳定
推荐:
{
"version": "1.0",
"orderNo": "PO1001",
"userId": 10001,
"paidAt": 1783310400000
}不推荐:
{
"order": { "包含几十个字段": "..." },
"user": { "包含几十个字段": "..." },
"productList": [ "大量商品快照" ]
}消息太大会带来:
- 网络传输慢。
- Broker 存储压力大。
- 消费者反序列化慢。
- 堆内存和 GC 压力增大。
- 堆积时影响更明显。
常见坑
忘记 serialVersionUID
类结构变更后自动计算的版本号可能变化,旧数据反序列化失败。
建议显式声明,并把协议兼容性作为发布评审项。
把敏感字段序列化出去
密码、token、身份证号、密钥不要随便进入缓存、日志、MQ。
使用:
private transient String password;或者更好:使用专门 DTO,不包含敏感字段。
对外使用 Java 原生序列化
外部请求、跨语言接口、开放 API 不应使用 Java 原生序列化。它跨语言差、可读性差、安全风险高。
缓存对象结构随便改
Redis 中老数据还在,应用升级后新类读老缓存可能失败。可以:
- 改 key 版本。
- 缩短 TTL。
- 发布前清理旧缓存。
- 保持兼容字段。
MQ 消息字段改名
字段改名对 JSON 来说就是旧字段消失、新字段新增。旧消费者可能读不到新字段,新消费者也可能读不到旧消息字段。
更稳的方式是保留旧字段一段时间,新增字段灰度迁移。
线上排查流程
flowchart TD
A["序列化/反序列化异常"] --> B["NotSerializableException"]
A --> C["InvalidClassException"]
A --> D["ClassNotFoundException"]
A --> E["ClassCastException"]
A --> F["JSON字段缺失或类型错误"]
B --> G["检查对象图中是否所有对象可序列化"]
C --> H["检查 serialVersionUID 和类结构变更"]
D --> I["检查消费者依赖和类名包名"]
E --> J["检查协议版本、泛型、类加载器"]
F --> K["检查字段名、类型、版本兼容"]排查建议:
NotSerializableException:不仅查当前类,还要查它引用的对象。InvalidClassException:重点查serialVersionUID和类结构变更。ClassNotFoundException:消费者是否缺少消息类依赖。ClassCastException:查版本不一致、泛型丢失、类加载器不同。- JSON 字段为 null:查字段名变更、命名策略、旧消息兼容。
- Redis 反序列化失败:查老缓存是否需要清理或 key 升级。
- MQ 反序列化失败:先保存原始消息体,确认生产者版本和消费者版本。
面试标准回答
序列化是什么
序列化是把内存中的对象状态转换成可存储、可传输的数据,比如字节数组、JSON 或二进制协议;反序列化是把这些数据按协议重新组装成对象。它常用于文件存储、Redis 缓存、MQ 消息、RPC 调用和 Session 复制。
Java 原生序列化怎么用
类需要实现 Serializable,通常显式声明 serialVersionUID,然后用 ObjectOutputStream.writeObject 写出对象,用 ObjectInputStream.readObject 读回对象。对象图里被引用的对象也要能序列化,否则会抛 NotSerializableException。
serialVersionUID 有什么用
它是序列化版本号。反序列化时 JVM 会比较字节流里的 serialVersionUID 和当前类里的 serialVersionUID。一致才继续恢复字段,不一致会抛 InvalidClassException。显式声明它可以避免类结构轻微变化导致自动计算值变化。
transient 有什么用
transient 修饰的字段不会参与默认序列化,反序列化后是默认值。它常用于密码、token、临时计算结果、本地资源句柄、大对象缓存等不应该或不能被持久化传输的字段。
static 字段会被序列化吗
不会。static 字段属于类,不属于对象实例。默认序列化保存的是对象实例状态,所以不会把 static 字段作为对象字段写入字节流。
反序列化为什么有安全风险
反序列化会根据输入数据创建对象并可能触发 readObject、readResolve 等逻辑。如果数据来自不可信来源,并且 classpath 中存在可利用调用链,就可能导致远程代码执行等漏洞。生产环境不应对外部不可信数据使用 Java 原生反序列化。
Redis、MQ、RPC 中怎么选择序列化
HTTP API 和需要可读性的消息通常用 JSON;跨语言、高性能 RPC 或消息可用 Protobuf;Java 内部高性能场景可能用 Kryo、Hessian 等;Java 原生序列化跨语言差、安全风险高,通常不建议用于开放接口和跨系统协议。
消息体版本兼容怎么做
消息体要小而稳定,显式带版本号。新增字段比删除字段安全,字段含义和类型不要随便改;消费者要兼容未知字段和缺失字段;MQ 消息可能堆积,所以生产者和消费者版本不一致时也要能处理旧消息。
