Java 序列化
序列化是把内存中的对象转换成可存储、可传输的数据;反序列化是把数据还原成对象。缓存、RPC、消息队列、文件存储、Session 复制都涉及序列化思想。
如果要深入理解 Java 原生序列化写入/读取全过程、对象图、serialVersionUID 兼容性、transient、static、自定义 writeObject/readObject、反序列化安全、Redis/MQ/RPC 中的协议选择,建议继续看:Java序列化全过程原理。
为什么需要序列化
Java 对象存在内存里,不能直接写入文件或通过网络传输。要跨进程、跨机器、持久化保存,就需要转换格式。
flowchart TD
A["Java 对象"] --> B["序列化"]
B --> C["字节数组、JSON、二进制协议"]
C --> D["文件、网络、缓存、消息队列"]
D --> E["反序列化"]
E --> F["Java 对象"]Java 原生序列化
对象要支持 Java 原生序列化,需要实现 Serializable。
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 + "'}";
}
}Serializable 是一个标记接口,里面没有方法。它的作用是告诉 Java 序列化机制:这个类允许被序列化。
public interface Serializable {
}为什么设计成标记接口?因为序列化流程由 ObjectOutputStream 和 ObjectInputStream 执行,业务类不需要实现某个写入方法。它只需要明确声明“我允许自己的对象状态被写出去”。
写入文件:
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["ObjectOutputStream.writeObject"] --> B{"对象是否实现 Serializable"}
B -- "否" --> C["抛 NotSerializableException"]
B -- "是" --> D["写入类描述信息"]
D --> E["写入 serialVersionUID"]
E --> F["遍历可序列化字段"]
F --> G{"字段是否是对象引用"}
G -- "否" --> H["写入基本值或字符串"]
G -- "是" --> I["递归写入被引用对象"]
H --> J["生成字节流"]
I --> J反序列化流程:
flowchart TD
A["ObjectInputStream.readObject"] --> B["读取类描述信息"]
B --> C["查找当前 JVM 中的类"]
C --> D["比较 serialVersionUID"]
D --> E{"是否兼容"}
E -- "否" --> F["抛 InvalidClassException"]
E -- "是" --> G["创建对象实例"]
G --> H["按字段恢复对象状态"]
H --> I["恢复对象引用关系"]注意:对象图中被引用的对象也必须能序列化。比如 Order 里有 User user,那么 User 也要实现 Serializable,否则会抛 NotSerializableException。
对象图和引用关系
对象通常不是孤立的,它会引用其他对象。
import java.io.Serializable;
class User implements Serializable {
private static final long serialVersionUID = 1L;
private String username;
User(String username) {
this.username = username;
}
}
class Order implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private User user;
Order(Long id, User user) {
this.id = id;
this.user = user;
}
}序列化 Order 时,不只写 id,还会沿着 user 引用继续写入 User 对象。
flowchart TD
A["Order对象"] --> B["id=1001"]
A --> C["user引用"]
C --> D["User对象"]
D --> E["username=Tom"]这就是为什么缓存对象、MQ 消息对象不能随便挂一堆复杂引用。对象图越大,序列化数据越大,网络、Redis、MQ 和 GC 压力都会上升。
反序列化会不会调用构造方法
Java 原生反序列化恢复 Serializable 对象时,通常不会走普通构造方法初始化对象状态,而是通过特殊机制创建对象,再把字节流中的字段值填回去。
Demo 思路:
import java.io.Serializable;
public class ConstructorDemo implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
public ConstructorDemo() {
System.out.println("constructor run");
this.name = "default";
}
}如果对象已经序列化,反序列化时字段值主要来自字节流,不会像 new ConstructorDemo() 那样按普通构造流程重新设置所有状态。
这件事的后果很重要:
- 构造方法里的校验逻辑可能不会在反序列化时执行。
- 构造方法里设置的默认值可能被字节流覆盖。
- 如果反序列化数据不可信,就可能构造出违反业务约束的对象。
所以不要把“对象一定合法”的希望只放在构造方法里。对外部输入反序列化后仍要做校验。
serialVersionUID 是什么
serialVersionUID 是序列化版本号。反序列化时,JVM 会检查字节流里的版本号和当前类的版本号是否一致。
flowchart TD
A["序列化数据中的 serialVersionUID"] --> C["版本比较"]
B["当前 User 类的 serialVersionUID"] --> C
C --> D["一致则尝试反序列化"]
C --> E["不一致则抛 InvalidClassException"]如果不显式声明,JVM 会根据类结构自动计算。类字段一变,版本号可能变化,导致旧数据无法反序列化。
建议:可序列化类显式声明 serialVersionUID。
哪些变更通常更安全
序列化兼容性不是只看 serialVersionUID。即使版本号一致,字段结构变化也要考虑旧数据怎么恢复。
| 类变更 | 兼容性倾向 | 说明 |
|---|---|---|
| 新增字段 | 通常较安全 | 旧数据没有该字段,反序列化后是默认值 |
| 删除字段 | 可能兼容但有语义风险 | 旧数据里的字段会被忽略 |
| 字段改名 | 高风险 | 等价于删除旧字段、新增新字段 |
| 字段类型改变 | 高风险 | 可能无法按旧类型恢复 |
| 改变字段含义 | 高风险 | 技术上能读,业务语义错了 |
如果对象已经进入 Redis、MQ、文件或远程接口,就不要随便改字段含义。必要时引入 DTO 版本号:
class OrderMessage implements Serializable {
private static final long serialVersionUID = 1L;
private int version = 1;
private Long orderId;
private String status;
}消费者根据 version 做兼容处理,比偷偷改字段语义安全得多。
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;
}适合场景:
| 字段 | 为什么不序列化 |
|---|---|
| 密码 | 安全风险 |
| 临时计算结果 | 可重新计算 |
| 本地资源句柄 | 文件流、Socket 无法跨机器还原 |
| 大对象缓存 | 避免序列化数据过大 |
反序列化后,transient 字段会恢复为默认值。
LoginUser user = deserialize(...);
// user.password 反序列化后通常是 null如果这个字段是可重新计算的缓存值,应在读取后重新计算;如果是本地资源,比如 Socket、InputStream、数据库连接,它本来就不应该跨机器恢复。
static 字段为什么不序列化
static 字段属于类,不属于某个对象实例。序列化保存的是对象实例状态,所以默认不会保存 static 字段。
import java.io.Serializable;
public class StaticFieldDemo implements Serializable {
private static final long serialVersionUID = 1L;
public static String systemName = "A";
private String username;
}即使序列化了对象,反序列化后看到的 systemName 也是当前 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 token;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
// 不写 token,或者写加密后的派生数据
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.token = null;
}
}defaultWriteObject 和 defaultReadObject 表示仍然按默认规则处理普通字段,你可以在前后追加自己的逻辑。
不要在这里写复杂业务逻辑,也不要信任输入数据。反序列化入口仍然要做安全控制。
readResolve:单例和枚举
反序列化会创建对象,这可能破坏单例。
import java.io.ObjectStreamException;
import java.io.Serializable;
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}readResolve 可以在反序列化后返回已有单例对象,避免产生新实例。更推荐的单例写法是枚举单例,因为枚举天然处理序列化单例问题。
public enum AppSingleton {
INSTANCE
}原生序列化的缺点
| 缺点 | 说明 |
|---|---|
| 数据体积较大 | 包含较多 Java 元信息 |
| 跨语言差 | 其他语言很难直接使用 |
| 性能一般 | 高性能 RPC 通常不用原生序列化 |
| 安全风险 | 反序列化不可信数据可能导致漏洞 |
| 版本兼容复杂 | 类结构变更要谨慎 |
所以商业系统里,更常见的是 JSON、Protobuf、Hessian、Kryo 等格式或协议。
JSON、Protobuf、Hessian、Kryo 怎么选
| 协议 | 优点 | 缺点 | 常见场景 |
|---|---|---|---|
| JSON | 可读性强、跨语言好、排查方便 | 体积较大、性能一般 | HTTP API、MQ 事件、配置 |
| Protobuf | 体积小、性能好、跨语言强 | 需要 schema,排查不如 JSON 直观 | 高性能 RPC、跨语言消息 |
| Hessian | Java 生态常见,使用简单 | 跨语言和生态不如 JSON/Protobuf | 部分 Java RPC |
| Kryo | 性能较好、体积较小 | 版本兼容和安全要谨慎 | 内部高性能缓存或 RPC |
| Java 原生 | JDK 自带、学习成本低 | 体积大、跨语言差、安全风险高 | 本地临时文件、学习原理 |
协议选择不是只看性能。商业项目还要考虑:
- 是否跨语言。
- 是否需要人肉排查消息内容。
- 消息是否长期保留。
- 是否要求强版本兼容。
- 是否暴露给外部不可信来源。
对外接口优先 JSON。内部高性能、强 schema 的系统可以考虑 Protobuf。不要把 Java 原生序列化暴露给外部请求。
JSON 序列化思想
即使不直接使用第三方库,也要理解 JSON 序列化的思路:
User(id=1, username=Tom)
转换为
{"id":1,"username":"Tom"}JSON 优点是可读性好、跨语言方便;缺点是体积比紧凑二进制协议大。
Redis、MQ、RPC 中的序列化
Redis 缓存
缓存对象时要考虑两件事:可读性和兼容性。
order:1001 -> {"id":1001,"status":"PAID","amount":"99.00"}JSON 方便排查,运维或开发可以直接看缓存内容。二进制协议体积可能更小,但排查成本高。
缓存对象结构变化时要小心:
- 新字段最好有默认值。
- 删除字段前确认旧缓存是否会被读取。
- 改字段类型前考虑清缓存或做双读兼容。
- 缓存 key 可以带版本,例如
order:v2:1001。
MQ 消息
MQ 消息更强调兼容性,因为生产者和消费者可能不是同一时间发布。
{
"version": 1,
"eventId": "evt-1001",
"orderId": 1001,
"status": "PAID"
}消费者应该能容忍未知字段,生产者不要随意删除字段或改变字段含义。消息体不要太大,大对象、大列表、大文本应存储到数据库或对象存储,消息里只传业务 ID。
RPC 调用
RPC 更关注性能、类型和版本。内部 Java RPC 可能使用 Hessian、Kryo 等,跨语言 RPC 常用 Protobuf、JSON 等。RPC DTO 要和数据库实体分开,避免把内部字段、懒加载代理、敏感字段直接暴露给调用方。
商业 Demo:订单支付消息 DTO
import java.io.Serializable;
import java.math.BigDecimal;
public class OrderPaidMessage implements Serializable {
private static final long serialVersionUID = 1L;
private int version = 1;
private String eventId;
private Long orderId;
private Long userId;
private BigDecimal paidAmount;
private Long paidTimeMillis;
public OrderPaidMessage(String eventId,
Long orderId,
Long userId,
BigDecimal paidAmount,
Long paidTimeMillis) {
if (eventId == null || eventId.trim().isEmpty()) {
throw new IllegalArgumentException("eventId不能为空");
}
this.eventId = eventId;
this.orderId = orderId;
this.userId = userId;
this.paidAmount = paidAmount;
this.paidTimeMillis = paidTimeMillis;
}
}这个 DTO 的设计重点:
eventId用于幂等,消费者可以防重复消费。version用于消息结构演进。- 金额用
BigDecimal,不要用double。 - 时间用毫秒时间戳或明确格式,避免时区歧义。
- 消息里只放必要字段,不把整个 Order 聚合对象塞进去。
如果直接把数据库实体序列化发 MQ,会出现字段过多、敏感字段泄露、懒加载对象异常、消费者强依赖内部表结构等问题。
反序列化安全风险
反序列化不可信数据很危险。攻击者可能构造特殊字节流,让应用在反序列化过程中创建危险对象,并触发某些类的 readObject、readResolve 或调用链,最终造成远程代码执行等严重问题。
flowchart TD
A["外部不可信字节流"] --> B["ObjectInputStream.readObject"]
B --> C["按字节流创建对象"]
C --> D["触发 readObject/readResolve 等逻辑"]
D --> E{"classpath 是否存在危险调用链"}
E -- "是" --> F["可能执行非预期代码"]
E -- "否" --> G["仍可能造成资源消耗或类型绕过"]生产建议:
- 不要对外部请求使用 Java 原生反序列化。
- 不要反序列化不可信来源的数据。
- 必须使用时,要做类白名单、输入长度限制、隔离环境和依赖治理。
- 对外接口使用 JSON/Protobuf,并配合字段校验。
- 关注依赖库中的反序列化漏洞公告。
序列化不是单纯性能问题,也是安全边界问题。
商业场景选择
| 场景 | 常见序列化方式 | 原因 |
|---|---|---|
| HTTP API | JSON | 可读、跨语言、生态成熟 |
| RPC 内部调用 | Protobuf、Hessian、Kryo | 性能和体积更好 |
| Redis 缓存 | JSON、二进制 | 看可读性和性能要求 |
| 消息队列 | JSON、Protobuf | 需要兼容消费者 |
| 本地临时文件 | Java 原生或 JSON | 看是否只给 Java 使用 |
版本兼容原则
对象结构一旦进入缓存、消息队列、文件或远程接口,就不能随便改。
安全改法:
- 新增字段通常比删除字段安全。
- 字段含义不要随意改变。
- 字段类型变更要考虑旧数据。
- 消息体要有版本号或兼容策略。
- 不同系统之间传输不要依赖 Java 原生序列化。
线上问题排查
flowchart TD
A["序列化相关问题"] --> B{"表现是什么"}
B -- "InvalidClassException" --> C["检查 serialVersionUID 和类结构变更"]
B -- "NotSerializableException" --> D["检查对象图中哪个类没实现 Serializable"]
B -- "字段变 null" --> E["检查 transient、字段改名、旧数据缺字段"]
B -- "缓存读取失败" --> F["检查缓存版本、字段类型、协议是否变化"]
B -- "MQ 消费失败" --> G["检查消息版本、消费者兼容、消息体大小"]
B -- "安全告警" --> H["检查是否反序列化不可信原生字节流"]排查建议:
- 先确认使用的序列化协议:JDK 原生、JSON、Protobuf、Kryo 还是 Hessian。
- 看异常类型:
InvalidClassException多半是版本兼容,NotSerializableException多半是对象图里有类不支持序列化。 - 对 MQ 和 Redis,确认旧数据是否还在,是否需要清理或做兼容读取。
- 对字段缺失,确认是历史数据没有、新字段默认值、字段改名,还是
transient。 - 对安全问题,先确认数据来源是否可信,是否存在原生反序列化入口。
常见风险
| 问题 | 后果 | 建议 |
|---|---|---|
| 反序列化不可信数据 | 安全漏洞 | 不接收任意来源原生序列化数据 |
忘记 serialVersionUID | 类变更后旧数据读取失败 | 显式声明 |
| 序列化敏感字段 | 泄露密码、token | 使用 transient 或 DTO |
| 缓存对象结构随意改 | 老缓存反序列化失败 | 加版本或清理缓存 |
| 大对象直接进 MQ | 消息堆积、网络压力大 | 控制消息体大小 |
| 直接序列化数据库实体 | 泄露内部字段、强耦合表结构 | 使用专门 DTO |
| 消息无版本号 | 生产者消费者升级不兼容 | 消息体带 version |
| 反序列化后不校验 | 构造出非法对象 | 反序列化后做业务校验 |
面试常问
Serializable 为什么没有方法?
它是标记接口,表示这个类允许被 Java 原生序列化。真正写入和读取逻辑由 ObjectOutputStream 和 ObjectInputStream 负责,业务类只声明自己可以被序列化。
序列化会把对象引用也写进去吗?
会。序列化会沿对象图写入被引用对象,所以对象图里的类也要能序列化。对象图越复杂,序列化数据越大,也越容易把不该传输的字段带出去。
反序列化会调用构造方法吗?
Serializable 对象反序列化时通常不会按普通 new 的方式执行构造方法,而是通过特殊机制创建对象并恢复字段值。所以构造方法里的校验不能替代反序列化后的安全校验。
serialVersionUID 有什么用?
它是序列化版本号。反序列化时会比较字节流里的版本号和当前类版本号,不一致会抛 InvalidClassException。显式声明它可以避免类结构轻微变化导致自动计算值变化。
transient 和 static 为什么不会正常序列化?transient 表示字段不参与默认序列化,适合密码、token、临时缓存、本地资源句柄;static 属于类,不属于对象实例,而序列化保存的是对象状态,所以不会作为普通字段写入。
为什么商业系统很少直接用 Java 原生序列化做外部协议?
因为它体积较大、跨语言差、版本兼容复杂,并且反序列化不可信数据有安全风险。对外接口通常用 JSON,跨语言高性能场景常用 Protobuf,内部 Java RPC 才可能使用 Hessian、Kryo 等。
本章小结
序列化解决对象跨内存边界的问题。Java 原生序列化适合理解原理,但商业系统更常用 JSON 或高性能二进制协议。学习序列化要重点理解版本兼容、安全风险、字段变更和跨系统传输协议选择。
