Skip to content

Java 序列化全过程原理

序列化不是“实现 Serializable 就完事”。真正要掌握它,必须理解对象为什么不能直接跨进程传输,字节流里保存了什么,反序列化为什么不走普通构造方法,serialVersionUID 为什么能决定兼容性,transient 为什么能保护敏感字段,以及为什么商业系统很少直接把 Java 原生序列化暴露给外部请求。

一句话先建立直觉:

序列化是把内存里的对象状态转换成可存储、可传输的数据;反序列化是按协议把这些数据重新组装成对象。

学习目标

学完这一页,你要能说清楚:

  1. 为什么 Java 对象不能直接写入文件、Redis、MQ 或网络。
  2. Java 原生序列化完整写入和读取流程。
  3. Serializable 为什么只是标记接口。
  4. ObjectOutputStream.writeObject 大概会写入哪些信息。
  5. 反序列化为什么可以不调用普通构造方法就创建对象。
  6. serialVersionUID 怎么影响兼容性。
  7. transientstatic 字段为什么不会按普通字段序列化。
  8. writeObjectreadObjectreadResolvewriteReplace 有什么作用。
  9. 为什么反序列化不可信数据有安全风险。
  10. 商业系统如何选择 JSON、Protobuf、Hessian、Kryo、JDK 原生序列化。

为什么需要序列化

Java 对象存在于某个 JVM 进程的堆内存中。对象里不只有字段值,还包含对象头、类型指针、对其他对象的引用等运行时结构。

这些东西不能直接跨机器使用:

text
JVM A 堆内存里的 User 对象地址:0x00000123
JVM B 不认识这个地址,也没有这块堆内存

所以对象要离开当前 JVM,就必须变成某种通用数据格式:

mermaid
flowchart TD
    A["JVM堆内存中的对象"] --> B["提取对象状态"]
    B --> C["按协议编码"]
    C --> D["字节数组、JSON、二进制数据"]
    D --> E["文件、网络、Redis、MQ"]
    E --> F["按协议解码"]
    F --> G["重新组装对象"]

序列化解决的核心问题是:对象跨内存边界。

常见边界包括:

边界示例
内存到文件把对象保存到磁盘
JVM 到 JVMRPC 远程调用
应用到 Redis缓存对象
应用到 MQ发送订单消息
Web 容器节点之间Session 复制

Java 原生序列化最小 Demo

对象类:

java
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 + "'}";
    }
}

写入文件:

java
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);
        }
    }
}

读取文件:

java
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 原生序列化的基本链路。

总体流程图

mermaid
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 源码里没有方法:

java
public interface Serializable {
}

它是标记接口。它的作用不是让你实现某个方法,而是告诉 Java 序列化机制:这个类允许被序列化。

如果对象没有实现 Serializable,直接序列化会抛:

text
java.io.NotSerializableException

为什么要有这个标记?

因为不是所有对象都适合序列化,比如:

  1. 文件流、Socket、数据库连接这类本地资源。
  2. 线程、锁、线程池这类运行时对象。
  3. 包含敏感信息的安全对象。
  4. 和当前机器环境强绑定的对象。

实现 Serializable 等于类作者显式声明:“这个类的对象状态可以被序列化。”

字节流里大概有什么

Java 原生序列化不是简单把字段拼起来。它会写入对象图和类描述信息。

可以近似理解为:

信息说明
流头信息标识这是 Java 序列化流
类描述类名、字段列表、父类信息
serialVersionUID版本兼容检查
字段值static、非 transient 字段
对象引用关系避免重复对象无限写入
特殊标记null、引用、数组、字符串等标记

如果对象里引用了另一个对象:

java
public class Order implements Serializable {
    private User user;
}

那么 Order 序列化时,也会尝试序列化 user。如果 User 没实现 Serializable,也会失败。

mermaid
flowchart TD
    A["Order对象"] --> B["orderNo字段"]
    A --> C["User引用"]
    C --> D["User对象也必须可序列化"]
    D --> E["写入User字段"]

所以序列化关注的是整个对象图,不只是当前对象本身。

反序列化为什么不走普通构造方法

看这个例子:

java
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 会用特殊机制分配对象,然后直接恢复字段。

这会带来两个重要影响:

  1. 构造方法里的校验逻辑可能不会执行。
  2. 对象不变量可能被恶意字节流破坏。

所以反序列化不可信数据很危险,不能把它当成普通 new 对象。

serialVersionUID 是什么

serialVersionUID 是类的序列化版本号。

java
private static final long serialVersionUID = 1L;

反序列化时,JVM 会比较:

  1. 字节流里保存的 serialVersionUID
  2. 当前 class 文件里的 serialVersionUID
mermaid
flowchart TD
    A["旧数据中的 serialVersionUID"] --> C["版本比较"]
    B["当前类的 serialVersionUID"] --> C
    C --> D["一致:继续恢复字段"]
    C --> E["不一致:InvalidClassException"]

如果不显式声明,JVM 会根据类名、字段、方法等结构计算一个值。类结构一改,自动计算值可能变化,旧数据就读不回来。

所以可序列化类建议显式声明:

java
private static final long serialVersionUID = 1L;

类结构变化怎么影响兼容性

假设旧版本:

java
public class User implements Serializable {
    private static final long serialVersionUID = 1L;
    private Long id;
    private String username;
}

新版本新增字段:

java
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 表示字段不参与默认序列化。

java
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 字段不会作为对象字段序列化进去。

java
public class Config implements Serializable {
    private static final long serialVersionUID = 1L;

    public static String globalName = "A";
    private String instanceName = "B";
}

序列化保存的是 instanceName,不是 globalName

如果反序列化后看到 static 值,那是当前 JVM 里类静态字段的当前值,不是从字节流恢复出来的。

自定义序列化:writeObjectreadObject

有时默认序列化不够,需要自己控制字段写入和读出。

java
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 "***";
    }
}

这两个方法有特殊签名,序列化机制会通过反射识别它们:

java
private void writeObject(ObjectOutputStream out)
private void readObject(ObjectInputStream in)

注意:不要在里面写危险逻辑,不要信任反序列化输入。

writeReplacereadResolve

writeReplace 可以在序列化前替换对象。

readResolve 可以在反序列化后替换对象,常用于单例保护。

单例示例:

java
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:版本兼容问题

第一版类:

java
public class OrderMessage implements Serializable {
    private static final long serialVersionUID = 1L;

    private String orderNo;
    private Integer status;
}

写入 MQ 或 Redis 后,第二版把 status 改成:

java
private String status;

这就可能导致反序列化失败或业务解析异常。更稳妥的做法是新增字段:

java
private Integer status;
private String statusText;

然后灰度迁移,等旧数据都消费完或缓存都过期,再考虑清理旧字段。

商业系统里尤其要注意:

  1. MQ 消息可能滞留。
  2. Redis 缓存可能有长 TTL。
  3. 文件归档可能保存很久。
  4. 多个服务可能不是同一时间发布。

序列化安全风险

反序列化不可信数据是 Java 安全里非常重要的风险点。

危险点在于:

  1. 反序列化会根据字节流创建对象。
  2. 某些类在 readObjectreadResolve、动态代理、集合比较等过程中会执行逻辑。
  3. 如果 classpath 中存在可利用的调用链,就可能导致远程代码执行等漏洞。

所以不要这样做:

java
ObjectInputStream in = new ObjectInputStream(request.getInputStream());
Object obj = in.readObject();

尤其不能对外部请求、MQ 不可信来源、用户上传文件直接使用 Java 原生反序列化。

安全建议:

  1. 不接收不可信来源的 Java 原生序列化数据。
  2. 优先使用 JSON、Protobuf 等格式,并做字段级校验。
  3. 使用 JDK 9+ 的反序列化过滤器限制允许的类。
  4. 对历史系统做白名单过滤。
  5. 依赖库及时升级,减少可利用 gadget 链。

JDK 9+ 反序列化过滤器

JDK 9 引入了对象输入过滤机制,可以限制反序列化允许的类、深度、数组大小等。

示例:

java
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();

含义:

  1. 允许 com.example.dto 下的 DTO。
  2. 允许 java.base 里的基础类。
  3. 其他全部拒绝。

这不是让原生反序列化变得“绝对安全”,而是降低攻击面。

JSON、Protobuf、Hessian、Kryo 对比

方式可读性跨语言性能/体积常见场景
Java 原生序列化一般Java 内部临时存储、老系统
JSON体积偏大HTTP API、MQ 通用消息
ProtobufRPC、跨语言、高性能消息
Hessian一般一般较好Java RPC、老 Dubbo 场景
KryoJava 内部高性能序列化

商业系统选择时看四个维度:

  1. 是否跨语言。
  2. 是否需要可读性。
  3. 性能和体积要求。
  4. 版本兼容和治理能力。

Redis 缓存中的序列化

Redis 保存的是字节数据,不懂 Java 对象。

mermaid
flowchart TD
    A["User对象"] --> B["序列化为JSON或二进制"]
    B --> C["写入Redis"]
    C --> D["读取字节数据"]
    D --> E["反序列化为User对象"]

常见问题:

  1. 改了类字段,老缓存反序列化失败。
  2. 使用 JDK 序列化导致 Redis 里数据不可读。
  3. 多语言系统无法读取 Java 原生序列化数据。
  4. 缓存里存大对象导致网络和内存压力。

建议:

  1. 跨服务缓存优先 JSON 或明确协议。
  2. key 加版本前缀,例如 user:v2:1001
  3. 对大对象拆分或只缓存必要字段。
  4. DTO 和数据库实体不要混用,避免字段变更影响缓存协议。

MQ 消息中的序列化

MQ 消息体本质也是字节数组。生产者序列化,消费者反序列化。

mermaid
flowchart TD
    A["生产者创建OrderPaidMessage"] --> B["序列化消息体"]
    B --> C["发送到MQ"]
    C --> D["消费者拉取消息"]
    D --> E["反序列化消息体"]
    E --> F["执行业务处理"]

MQ 场景尤其注意兼容性,因为:

  1. 消息可能堆积,旧版本消息过很久才消费。
  2. 消费者可能比生产者晚发布。
  3. 不同消费者可能使用不同语言。
  4. 消息一旦发出就很难修改。

建议:

  1. 消息体显式带版本号。
  2. 新增字段要给默认值。
  3. 不随便删除字段和改变字段含义。
  4. 消费者要兼容未知字段。
  5. 不发送超大对象,传 ID 再查详情通常更稳。

RPC 中的序列化

RPC 调用看起来像本地方法:

java
UserDTO user = userService.getById(1L);

实际要做:

mermaid
flowchart TD
    A["客户端方法调用"] --> B["参数序列化"]
    B --> C["网络传输"]
    C --> D["服务端反序列化参数"]
    D --> E["调用真实服务"]
    E --> F["返回值序列化"]
    F --> G["网络返回"]
    G --> H["客户端反序列化结果"]

所以 RPC 框架必须关心:

  1. 序列化性能。
  2. 协议兼容性。
  3. 跨语言能力。
  4. 类加载和泛型类型。
  5. 安全边界。

Dubbo、gRPC 等框架的协议和序列化选择,直接影响吞吐、延迟和兼容性。

商业项目怎么设计序列化对象

用 DTO/Message,不直接用 Entity

不建议直接把数据库实体发到 MQ 或暴露给外部 API:

java
public class OrderEntity {
    private Long id;
    private String orderNo;
    private String internalRemark;
    private Date createTime;
}

更建议定义消息对象:

java
public class OrderPaidMessage {
    private String messageVersion;
    private String orderNo;
    private Long userId;
    private Integer amount;
    private Long paidAt;
}

原因:

  1. Entity 字段会随着数据库变化,不适合作为外部协议。
  2. Message 可以明确版本和业务语义。
  3. 避免泄露内部字段。
  4. 消费者不需要依赖生产者数据库模型。

消息体要小而稳定

推荐:

json
{
  "version": "1.0",
  "orderNo": "PO1001",
  "userId": 10001,
  "paidAt": 1783310400000
}

不推荐:

json
{
  "order": { "包含几十个字段": "..." },
  "user": { "包含几十个字段": "..." },
  "productList": [ "大量商品快照" ]
}

消息太大会带来:

  1. 网络传输慢。
  2. Broker 存储压力大。
  3. 消费者反序列化慢。
  4. 堆内存和 GC 压力增大。
  5. 堆积时影响更明显。

常见坑

忘记 serialVersionUID

类结构变更后自动计算的版本号可能变化,旧数据反序列化失败。

建议显式声明,并把协议兼容性作为发布评审项。

把敏感字段序列化出去

密码、token、身份证号、密钥不要随便进入缓存、日志、MQ。

使用:

java
private transient String password;

或者更好:使用专门 DTO,不包含敏感字段。

对外使用 Java 原生序列化

外部请求、跨语言接口、开放 API 不应使用 Java 原生序列化。它跨语言差、可读性差、安全风险高。

缓存对象结构随便改

Redis 中老数据还在,应用升级后新类读老缓存可能失败。可以:

  1. 改 key 版本。
  2. 缩短 TTL。
  3. 发布前清理旧缓存。
  4. 保持兼容字段。

MQ 消息字段改名

字段改名对 JSON 来说就是旧字段消失、新字段新增。旧消费者可能读不到新字段,新消费者也可能读不到旧消息字段。

更稳的方式是保留旧字段一段时间,新增字段灰度迁移。

线上排查流程

mermaid
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["检查字段名、类型、版本兼容"]

排查建议:

  1. NotSerializableException:不仅查当前类,还要查它引用的对象。
  2. InvalidClassException:重点查 serialVersionUID 和类结构变更。
  3. ClassNotFoundException:消费者是否缺少消息类依赖。
  4. ClassCastException:查版本不一致、泛型丢失、类加载器不同。
  5. JSON 字段为 null:查字段名变更、命名策略、旧消息兼容。
  6. Redis 反序列化失败:查老缓存是否需要清理或 key 升级。
  7. 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 字段作为对象字段写入字节流。

反序列化为什么有安全风险

反序列化会根据输入数据创建对象并可能触发 readObjectreadResolve 等逻辑。如果数据来自不可信来源,并且 classpath 中存在可利用调用链,就可能导致远程代码执行等漏洞。生产环境不应对外部不可信数据使用 Java 原生反序列化。

Redis、MQ、RPC 中怎么选择序列化

HTTP API 和需要可读性的消息通常用 JSON;跨语言、高性能 RPC 或消息可用 Protobuf;Java 内部高性能场景可能用 Kryo、Hessian 等;Java 原生序列化跨语言差、安全风险高,通常不建议用于开放接口和跨系统协议。

消息体版本兼容怎么做

消息体要小而稳定,显式带版本号。新增字段比删除字段安全,字段含义和类型不要随便改;消费者要兼容未知字段和缺失字段;MQ 消息可能堆积,所以生产者和消费者版本不一致时也要能处理旧消息。

关联知识点