Java 序列化是什么?transient 和 serialVersionUID 有什么用?
简化版
序列化是把对象转成字节流(便于存磁盘、网络传输),反序列化是把字节流还原成对象。Java 里让类实现 Serializable(一个没有方法的标记接口)就能被 ObjectOutputStream 序列化。transient 修饰的字段不会被序列化(如密码、临时缓存、不可序列化的字段)。serialVersionUID 是版本号,用于校验「序列化时的类」和「反序列化时的类」是否兼容——不显式指定,JVM 会按类结构自动生成,一旦类改动就变,导致老数据反序列化失败,所以建议手动固定它。
详细版
基本用法:
class User implements Serializable {
private static final long serialVersionUID = 1L; // 建议手动指定
private String name;
private transient String password; // 不序列化,反序列化后为 null
}
// 序列化:对象 → 字节流
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("user.dat"))) {
oos.writeObject(user);
}
// 反序列化:字节流 → 对象
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("user.dat"))) {
User u = (User) ois.readObject(); // password 字段是 null
}
三个核心概念:
| 概念 | 作用 |
|---|---|
Serializable | 标记接口(无方法),表示「这个类允许被序列化」,不实现会抛 NotSerializableException |
transient | 修饰字段,标记「此字段不参与序列化」,反序列化后是默认值(null/0) |
serialVersionUID | 版本标识,反序列化时校验类版本是否兼容,不一致抛 InvalidClassException |
serialVersionUID 的关键作用:
不显式声明 → JVM 根据类的字段、方法等自动计算一个 UID
问题:类一旦改动(加个字段、改个方法签名)→ UID 变了
→ 用旧类序列化的字节流,用新类反序列化 → UID 不匹配 → InvalidClassException
显式声明固定值 → 类结构小改动不影响 UID,老数据仍能反序列化(缺失字段用默认值)
⚠️
static字段和transient字段都不会被序列化——static 属于类不属于对象,transient 是显式排除。反序列化后这两类字段的值来自当前类定义(static)或默认值(transient),不是序列化时的值。
完整版教学
一、序列化解决什么:对象的「持久化」与「传输」
对象活在 JVM 内存里,进程一结束就没了。但很多场景需要让对象「跨越时间或空间」存在:
跨时间:把对象存到磁盘/Redis,下次启动再读回来(持久化、缓存)
跨空间:把对象通过网络发到另一台机器(RPC 传参、分布式会话)
内存里的对象是一堆带引用关系的结构,没法直接写文件或塞进网络——必须先「拍平」成一串字节(序列化),到对面再「还原」成对象(反序列化)。Serializable 就是 Java 内置的这套机制。理解它是「对象 ↔ 字节流的双向转换」,后面的 transient、UID 都是围绕这个转换的细节。
二、Serializable 为什么是「空接口」
Serializable 里一个方法都没有,它是标记接口(marker interface)——不提供行为,只作为一个「标签」告诉 JVM「这个类允许序列化」:
public interface Serializable {} // 真的什么都没有
为什么用空接口而不是别的方式?因为序列化的实际逻辑由 ObjectOutputStream 通过反射完成(它去读对象的所有字段写进流),类本身不需要实现任何方法。JVM 在序列化前先 instanceof Serializable 检查这个「标签」,没有就抛 NotSerializableException——相当于一道「你是否授权我序列化你」的开关。这是标记接口的经典用法(Cloneable 也是同理)。
三、transient:为什么有些字段不能/不该序列化
transient 让字段被序列化机制跳过,反序列化后是默认值。三类字段该用它:
① 敏感信息:password、密钥 —— 不该被写进文件/传输,防泄露
② 可重新计算的缓存:如根据其他字段算出的值 —— 存了浪费空间,还可能不一致
③ 不可序列化的字段:如 Thread、Socket、数据库连接 —— 强行序列化会抛异常
class Session implements Serializable {
private String userId;
private transient Connection dbConn; // 连接不能序列化,必须 transient
private transient String cachedToken; // 缓存,反序列化后重新生成
}
一个高频追问:如果一个不可序列化的字段忘了加 transient 会怎样? 序列化时会抛 NotSerializableException。所以对象里但凡有「不能被序列化的成员」,要么让它实现 Serializable,要么用 transient 排除,否则整个对象都序列化不了。
四、serialVersionUID:版本兼容的守门人
这是本题最核心的考点。UID 是类的「序列化版本指纹」,反序列化时会比对流里的 UID 和当前类的 UID:
序列化时(类 v1,UID=100):写对象 → 字节流里带上 UID=100
反序列化时:
当前类 UID == 100 → 版本兼容,正常还原
当前类 UID != 100 → 抛 InvalidClassException(类不兼容)
不显式声明时,JVM 会根据类的字段、方法、修饰符等自动计算一个 UID。灾难在于:类只要有任何改动(哪怕加个方法、改个访问修饰符),自动算出的 UID 就变了:
场景:User v1 序列化存进 Redis(自动 UID=A)
后来 User 加了个字段 → v2 自动 UID=B
读 Redis 里的老数据反序列化 → UID A≠B → InvalidClassException,线上炸
显式固定 serialVersionUID = 1L 就避免了这个问题:类小改动(加字段、加方法)UID 不变,老字节流仍能反序列化——多出来的新字段用默认值,缺失的字段忽略。这就是「必须手动声明 UID」的原因:把版本兼容的控制权从「JVM 自动算」拿回到「你手动定」。
五、序列化的进阶控制:writeObject 与 Externalizable
默认序列化由 JVM 反射处理所有非 transient 字段,但有时需要自定义:
// 方式1:在类里定义特殊方法,JVM 序列化时会自动调用(可加密、可补充 transient 字段)
private void writeObject(ObjectOutputStream oos) throws IOException {
oos.defaultWriteObject(); // 先写默认字段
oos.writeObject(encrypt(password)); // 再手动写加密后的密码
}
private void readObject(ObjectInputStream ois) throws IOException, ClassNotFoundException {
ois.defaultReadObject();
this.password = decrypt((String) ois.readObject());
}
- Serializable + writeObject/readObject:JVM 自动序列化为主,用这俩方法微调(如给 transient 字段加密后手动存)。
- Externalizable:完全手动控制,必须实现
writeExternal/readExternal,性能更高但要自己写全部字段逻辑,容易漏。
这解释了「transient 字段怎么还能被保存」——通过 writeObject 手动写。默认序列化跳过 transient,但你能在 writeObject 里加密后手动写进去,反序列化时再解密,兼顾安全和持久化。
六、Java 原生序列化的问题与替代
面试常问「为什么很多框架不用 Java 原生序列化」,几个硬伤:
| 问题 | 说明 |
|---|---|
| 体积大 | 字节流带完整类元信息,比 JSON/Protobuf 大很多 |
| 慢 | 基于反射,性能差 |
| 跨语言差 | 只能 Java 读,无法和其他语言互通 |
| 安全漏洞 | 反序列化不可信数据可能触发 RCE(著名的反序列化漏洞,如 fastjson/shiro 系列) |
所以实际项目多用 JSON(Jackson/Gson,可读、跨语言)、Protobuf/Thrift(高性能、跨语言,RPC 常用)、Hessian/Kryo(比原生快)等。Java 原生序列化主要用在 JDK 内部(如 RMI)和一些简单持久化。安全建议:绝不反序列化不可信来源的字节流——这是很多安全事故的根源。
记忆钩子:「Serializable 是空标记接口、transient 排除字段(密码/连接/缓存)、serialVersionUID 是版本指纹(必须手动固定,否则类一改老数据就反序列化失败);原生序列化体积大/慢/有安全漏洞,生产多用 JSON/Protobuf」。
七、常见误区与追问
- 误区:实现 Serializable 要重写方法。 它是空的标记接口,无需实现任何方法;序列化逻辑由 ObjectOutputStream 反射完成。
- 误区:serialVersionUID 不写也没关系。 不写时 JVM 自动生成,类一改就变,导致老字节流反序列化抛 InvalidClassException;生产必须手动固定。
- 误区:static 字段会被序列化。 不会,static 属于类不属于对象;反序列化后 static 字段的值来自当前类定义,不是序列化时的值。
- 误区:transient 字段反序列化后还是原值。 是默认值(null/0),因为它根本没被写进字节流;除非用 writeObject 手动处理。
- 追问:一个类的字段是另一个自定义对象,序列化要注意什么? 该字段的类也必须实现 Serializable,否则抛 NotSerializableException;序列化是递归的,整个对象图都要可序列化。
- 追问:如何序列化 transient 字段(如加密存密码)? 定义 writeObject/readObject 方法,在里面手动 writeObject(加密值) 和 readObject 后解密,绕过默认的 transient 跳过。
- 追问:为什么反序列化有安全风险? readObject 会执行对象重建逻辑,攻击者构造恶意字节流可触发任意代码执行(RCE);绝不反序列化不可信数据,是 fastjson/shiro 等漏洞的根源。
八、加强记忆
序列化是「对象 ↔ 字节流的双向转换」,为了让对象跨时间(存磁盘/缓存)或跨空间(网络传输)存在。核心三件套:Serializable 是没有任何方法的标记接口,只作「允许序列化」的授权开关,实际逻辑由 ObjectOutputStream 反射完成;transient 让字段被序列化跳过(用于密码等敏感信息、数据库连接等不可序列化字段、可重算的缓存),反序列化后是默认值;serialVersionUID 是版本指纹——不手动固定就由 JVM 按类结构自动算,类一改 UID 就变,导致老字节流反序列化抛 InvalidClassException,所以必须显式声明把版本兼容控制权握在手里。进阶用 writeObject/readObject 自定义(如给 transient 字段加密后手动存),或 Externalizable 全手动。最后记住 Java 原生序列化体积大、慢、不跨语言、有反序列化 RCE 漏洞,生产多用 JSON/Protobuf,且绝不反序列化不可信数据。一句话「空接口授权、transient 排除、UID 定版本,原生序列化有坑生产用 JSON/Protobuf」。