Java 序列化的原理是什么?为什么不推荐原生序列化?

2026年 阅读约 8 分钟 面试指南 · Java面试

深入解析Java序列化机制:Serializable原理、serialVersionUID版本控制、transient关键字、自定义序列化、原生序列化安全漏洞、JSON与Protobuf选型,分三层讲解。

一句话总结

序列化把对象转成字节流以便存储或网络传输。Java 原生序列化实现 Serializable 标记接口、按类元数据 + 字段值编码;serialVersionUID 控制版本兼容,transient 排除敏感/不可序列化字段,static 字段不属于对象不参与序列化。生产环境不推荐原生序列化:体积大、跨语言差、有反序列化安全漏洞——一般用 JSON(Jackson/Fastjson)或 Protobuf。

初级理解

基本用法:类实现 Serializable(纯标记接口,无方法),用 ObjectOutputStream.writeObject 写、ObjectInputStream.readObject 读。

class User implements Serializable { @Serial private static final long serialVersionUID = 1L; private String name; private transient String password; // 不参与序列化 } // 写 try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("u.bin"))) { oos.writeObject(new User("张三", "123")); } // 读 User u = (User) new ObjectInputStream(new FileInputStream("u.bin")).readObject(); System.out.println(u.getPassword()); // null(transient 字段为默认值)
三个必背规则:① static 字段属于类不属于对象,不会被序列化;② transient 修饰的字段被跳过,反序列化为默认值(0/null/false);③ 子类序列化时父类若未实现 Serializable,父类字段不会被写出,反序列化时调用父类无参构造。

中级深入

serialVersionUID 的作用:类结构写入字节流时带上这个版本号,反序列化时两边 UID 不一致直接抛 InvalidClassException。不显式声明时 JVM 按类结构自动计算——改动任何字段/方法都会导致 UID 变化,旧数据瞬间不可读。所以线上持久化/传输的 DTO 必须显式声明,并在"兼容性变更"时保持不变。

// 兼容性变更:新增字段、删除 transient 字段 → UID 不变,可兼容 // 不兼容变更:修改字段类型、改类层级 → UID 也该换新版本

自定义序列化:类里定义 private 的 writeObject/readObject 方法,JVM 会反射调用,用于加密字段、字段重映射、集合容量控制等场景。

private void writeObject(ObjectOutputStream out) throws IOException { out.defaultWriteObject(); // 先写常规字段 out.writeObject(encrypt(password)); // 再写自定义处理过的敏感字段 } private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { in.defaultReadObject(); this.password = decrypt((String) in.readObject()); }

引用与循环引用:同一对象被多次写出时只写一次,后续用引用编号代替(防死循环也省体积);这也意味着 writeObject 两次同一对象,第二次读回的是同一个实例,但改了对象再写需要注意 reset()。

高级拓展

为什么生产不用原生序列化(三宗罪):

  • 体积大:携带完整类元数据(全限定类名、字段描述),比 JSON/Protobuf 大数倍
  • 跨语言差:编码格式是 JVM 私有约定,Go/Python 无法直接消费
  • 安全漏洞:readObject 恢复对象时执行类自带逻辑,攻击者构造恶意字节流(Gadget Chain)可实现远程代码执行——历史上著名的 Apache Commons-Collections 反序列化 RCE、Weblogic/JBoss 反序列化漏洞皆源于此

主流替代方案选型:

方案体积/性能跨语言场景
JSON(Jackson/Fastjson2)中等/一般好,可读HTTP 接口、调试友好
Protobuf最小/最快好,需 .protogRPC、内部 RPC、高吞吐
Hessian较小/较快较好Dubbo 默认之一
Java 原生大/慢无仅限可信内部场景

框架层的序列化坑:Jackson 的多态序列化默认丢失类型信息,需 @JsonTypeInfo + @JsonSubTypes(开启时注意指定合法子类白名单,防反序列化注入);Fastjson 曾因 autoType 全开出重大漏洞,Fastjson2 默认关闭并校验;Redis 场景用 GenericJackson2JsonRedisSerializer(自带 @class 信息)而非 JdkSerializationRedisSerializer。

注意:JSON 序列化不认 transient 和 static——Jackson 认注解(@JsonIgnore/@JsonProperty(access = WRITE_ONLY)),这是两套体系的差异点,常被追问。

实战场景

场景一:缓存反序列化 ClassNotFound

// 现象:发版后从 Redis 读旧对象报 InvalidClassException / ClassNotFound // 根因:DTO 没固定 serialVersionUID,类结构一变 UID 变化 // 规范: // 1. 所有入缓存 DTO 显式 private static final long serialVersionUID = 1L; // 2. 变更走"新增字段"而非改类型;读端对缺失字段给默认值 // 3. 长期方案:缓存值换 JSON,字段演进天然兼容

场景二:DTO 升级但消费方未同步发布

// 生产者新增 status 字段,旧消费者不认识 → JSON 反序列化默认忽略未知字段,兼容 ✓ // 但 Jackson 配置 FAIL_ON_UNKNOWN_PROPERTIES=true 时会炸 —— Spring Boot 默认已关闭 // 原生序列化则必须 UID 不变 + 字段可缺省,否则两边必须同步发版

场景三:接口日志脱敏

// Jackson 自定义序列化器,手机号/身份证打码后输出 @JsonSerialize(using = PhoneMaskSerializer.class) private String phone; // 输出:138****5678 —— 序列化层做脱敏比在 20 个日志点手工处理更可靠

面试模拟

Q:Serializable 是空接口,序列化机制是怎么被触发的?

A:ObjectOutputStream.writeObject 里会 instanceof 检查,非 Serializable 抛 NotSerializableException。写流程先写类元描述(ObjectStreamClass:类名、UID、字段签名),再按描述逐字段写值;读流程按元描述恢复。若类中定义了 private writeObject/readObject,会通过反射优先调用实现自定义逻辑(Externalizable 则是完全手动的另一套接口,要求显式写所有字段并提供 public 无参构造)。

Q:transient 和 static 的区别?为什么 static 不需要 transient?

A:两者都不参与序列化,但语义不同——transient 是"这个实例字段不持久化"(敏感信息、可推导的缓存字段、不可序列化的资源句柄如流/连接);static 属于类,序列化本来就只针对对象状态,static 字段天然不在对象图里,反序列化后读到的是当前 JVM 里该类的 static 值。

Q:反序列化漏洞的原理和防御?

A:readObject 不只是"填字段",会执行类路径上相关类的读逻辑(readObject/readResolve/代理构造等)。攻击者把精心挑选的"利用链"(如 Commons-Collections 的 Transformer 链)序列化后投给服务端,服务端反序列化即触发任意代码执行。防御:① 禁止反序列化不可信来源的数据;② 生产环境弃用 Java 原生序列化,用 JSON/Protobuf;③ 必须用时配 ObjectInputFilter 白名单(JEP 290)限制可恢复的类型;④ 升级依赖,封堵已知 Gadget。