Skip to content

Java 序列化

序列化是把内存中的对象转换成可存储、可传输的数据;反序列化是把数据还原成对象。缓存、RPC、消息队列、文件存储、Session 复制都涉及序列化思想。

如果要深入理解 Java 原生序列化写入/读取全过程、对象图、serialVersionUID 兼容性、transientstatic、自定义 writeObject/readObject、反序列化安全、Redis/MQ/RPC 中的协议选择,建议继续看:Java序列化全过程原理

为什么需要序列化

Java 对象存在内存里,不能直接写入文件或通过网络传输。要跨进程、跨机器、持久化保存,就需要转换格式。

mermaid
flowchart TD
    A["Java 对象"] --> B["序列化"]
    B --> C["字节数组、JSON、二进制协议"]
    C --> D["文件、网络、缓存、消息队列"]
    D --> E["反序列化"]
    E --> F["Java 对象"]

Java 原生序列化

对象要支持 Java 原生序列化,需要实现 Serializable

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

Serializable 是一个标记接口,里面没有方法。它的作用是告诉 Java 序列化机制:这个类允许被序列化。

java
public interface Serializable {
}

为什么设计成标记接口?因为序列化流程由 ObjectOutputStreamObjectInputStream 执行,业务类不需要实现某个写入方法。它只需要明确声明“我允许自己的对象状态被写出去”。

写入文件:

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["ObjectOutputStream.writeObject"] --> B{"对象是否实现 Serializable"}
    B -- "否" --> C["抛 NotSerializableException"]
    B -- "是" --> D["写入类描述信息"]
    D --> E["写入 serialVersionUID"]
    E --> F["遍历可序列化字段"]
    F --> G{"字段是否是对象引用"}
    G -- "否" --> H["写入基本值或字符串"]
    G -- "是" --> I["递归写入被引用对象"]
    H --> J["生成字节流"]
    I --> J

反序列化流程:

mermaid
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

对象图和引用关系

对象通常不是孤立的,它会引用其他对象。

java
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 对象。

mermaid
flowchart TD
    A["Order对象"] --> B["id=1001"]
    A --> C["user引用"]
    C --> D["User对象"]
    D --> E["username=Tom"]

这就是为什么缓存对象、MQ 消息对象不能随便挂一堆复杂引用。对象图越大,序列化数据越大,网络、Redis、MQ 和 GC 压力都会上升。

反序列化会不会调用构造方法

Java 原生反序列化恢复 Serializable 对象时,通常不会走普通构造方法初始化对象状态,而是通过特殊机制创建对象,再把字节流中的字段值填回去。

Demo 思路:

java
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() 那样按普通构造流程重新设置所有状态。

这件事的后果很重要:

  1. 构造方法里的校验逻辑可能不会在反序列化时执行。
  2. 构造方法里设置的默认值可能被字节流覆盖。
  3. 如果反序列化数据不可信,就可能构造出违反业务约束的对象。

所以不要把“对象一定合法”的希望只放在构造方法里。对外部输入反序列化后仍要做校验。

serialVersionUID 是什么

serialVersionUID 是序列化版本号。反序列化时,JVM 会检查字节流里的版本号和当前类的版本号是否一致。

mermaid
flowchart TD
    A["序列化数据中的 serialVersionUID"] --> C["版本比较"]
    B["当前 User 类的 serialVersionUID"] --> C
    C --> D["一致则尝试反序列化"]
    C --> E["不一致则抛 InvalidClassException"]

如果不显式声明,JVM 会根据类结构自动计算。类字段一变,版本号可能变化,导致旧数据无法反序列化。

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

哪些变更通常更安全

序列化兼容性不是只看 serialVersionUID。即使版本号一致,字段结构变化也要考虑旧数据怎么恢复。

类变更兼容性倾向说明
新增字段通常较安全旧数据没有该字段,反序列化后是默认值
删除字段可能兼容但有语义风险旧数据里的字段会被忽略
字段改名高风险等价于删除旧字段、新增新字段
字段类型改变高风险可能无法按旧类型恢复
改变字段含义高风险技术上能读,业务语义错了

如果对象已经进入 Redis、MQ、文件或远程接口,就不要随便改字段含义。必要时引入 DTO 版本号:

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

    private int version = 1;
    private Long orderId;
    private String status;
}

消费者根据 version 做兼容处理,比偷偷改字段语义安全得多。

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

适合场景:

字段为什么不序列化
密码安全风险
临时计算结果可重新计算
本地资源句柄文件流、Socket 无法跨机器还原
大对象缓存避免序列化数据过大

反序列化后,transient 字段会恢复为默认值。

java
LoginUser user = deserialize(...);
// user.password 反序列化后通常是 null

如果这个字段是可重新计算的缓存值,应在读取后重新计算;如果是本地资源,比如 SocketInputStream、数据库连接,它本来就不应该跨机器恢复。

static 字段为什么不序列化

static 字段属于类,不属于某个对象实例。序列化保存的是对象实例状态,所以默认不会保存 static 字段。

java
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

有时默认序列化不够用,可以在类里定义私有方法,定制写入和读取逻辑。

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 token;

    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();
        // 不写 token,或者写加密后的派生数据
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        this.token = null;
    }
}

defaultWriteObjectdefaultReadObject 表示仍然按默认规则处理普通字段,你可以在前后追加自己的逻辑。

不要在这里写复杂业务逻辑,也不要信任输入数据。反序列化入口仍然要做安全控制。

readResolve:单例和枚举

反序列化会创建对象,这可能破坏单例。

java
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 可以在反序列化后返回已有单例对象,避免产生新实例。更推荐的单例写法是枚举单例,因为枚举天然处理序列化单例问题。

java
public enum AppSingleton {
    INSTANCE
}

原生序列化的缺点

缺点说明
数据体积较大包含较多 Java 元信息
跨语言差其他语言很难直接使用
性能一般高性能 RPC 通常不用原生序列化
安全风险反序列化不可信数据可能导致漏洞
版本兼容复杂类结构变更要谨慎

所以商业系统里,更常见的是 JSON、Protobuf、Hessian、Kryo 等格式或协议。

JSON、Protobuf、Hessian、Kryo 怎么选

协议优点缺点常见场景
JSON可读性强、跨语言好、排查方便体积较大、性能一般HTTP API、MQ 事件、配置
Protobuf体积小、性能好、跨语言强需要 schema,排查不如 JSON 直观高性能 RPC、跨语言消息
HessianJava 生态常见,使用简单跨语言和生态不如 JSON/Protobuf部分 Java RPC
Kryo性能较好、体积较小版本兼容和安全要谨慎内部高性能缓存或 RPC
Java 原生JDK 自带、学习成本低体积大、跨语言差、安全风险高本地临时文件、学习原理

协议选择不是只看性能。商业项目还要考虑:

  1. 是否跨语言。
  2. 是否需要人肉排查消息内容。
  3. 消息是否长期保留。
  4. 是否要求强版本兼容。
  5. 是否暴露给外部不可信来源。

对外接口优先 JSON。内部高性能、强 schema 的系统可以考虑 Protobuf。不要把 Java 原生序列化暴露给外部请求。

JSON 序列化思想

即使不直接使用第三方库,也要理解 JSON 序列化的思路:

text
User(id=1, username=Tom)
转换为
{"id":1,"username":"Tom"}

JSON 优点是可读性好、跨语言方便;缺点是体积比紧凑二进制协议大。

Redis、MQ、RPC 中的序列化

Redis 缓存

缓存对象时要考虑两件事:可读性和兼容性。

text
order:1001 -> {"id":1001,"status":"PAID","amount":"99.00"}

JSON 方便排查,运维或开发可以直接看缓存内容。二进制协议体积可能更小,但排查成本高。

缓存对象结构变化时要小心:

  1. 新字段最好有默认值。
  2. 删除字段前确认旧缓存是否会被读取。
  3. 改字段类型前考虑清缓存或做双读兼容。
  4. 缓存 key 可以带版本,例如 order:v2:1001

MQ 消息

MQ 消息更强调兼容性,因为生产者和消费者可能不是同一时间发布。

json
{
  "version": 1,
  "eventId": "evt-1001",
  "orderId": 1001,
  "status": "PAID"
}

消费者应该能容忍未知字段,生产者不要随意删除字段或改变字段含义。消息体不要太大,大对象、大列表、大文本应存储到数据库或对象存储,消息里只传业务 ID。

RPC 调用

RPC 更关注性能、类型和版本。内部 Java RPC 可能使用 Hessian、Kryo 等,跨语言 RPC 常用 Protobuf、JSON 等。RPC DTO 要和数据库实体分开,避免把内部字段、懒加载代理、敏感字段直接暴露给调用方。

商业 Demo:订单支付消息 DTO

java
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 的设计重点:

  1. eventId 用于幂等,消费者可以防重复消费。
  2. version 用于消息结构演进。
  3. 金额用 BigDecimal,不要用 double
  4. 时间用毫秒时间戳或明确格式,避免时区歧义。
  5. 消息里只放必要字段,不把整个 Order 聚合对象塞进去。

如果直接把数据库实体序列化发 MQ,会出现字段过多、敏感字段泄露、懒加载对象异常、消费者强依赖内部表结构等问题。

反序列化安全风险

反序列化不可信数据很危险。攻击者可能构造特殊字节流,让应用在反序列化过程中创建危险对象,并触发某些类的 readObjectreadResolve 或调用链,最终造成远程代码执行等严重问题。

mermaid
flowchart TD
    A["外部不可信字节流"] --> B["ObjectInputStream.readObject"]
    B --> C["按字节流创建对象"]
    C --> D["触发 readObject/readResolve 等逻辑"]
    D --> E{"classpath 是否存在危险调用链"}
    E -- "是" --> F["可能执行非预期代码"]
    E -- "否" --> G["仍可能造成资源消耗或类型绕过"]

生产建议:

  1. 不要对外部请求使用 Java 原生反序列化。
  2. 不要反序列化不可信来源的数据。
  3. 必须使用时,要做类白名单、输入长度限制、隔离环境和依赖治理。
  4. 对外接口使用 JSON/Protobuf,并配合字段校验。
  5. 关注依赖库中的反序列化漏洞公告。

序列化不是单纯性能问题,也是安全边界问题。

商业场景选择

场景常见序列化方式原因
HTTP APIJSON可读、跨语言、生态成熟
RPC 内部调用Protobuf、Hessian、Kryo性能和体积更好
Redis 缓存JSON、二进制看可读性和性能要求
消息队列JSON、Protobuf需要兼容消费者
本地临时文件Java 原生或 JSON看是否只给 Java 使用

版本兼容原则

对象结构一旦进入缓存、消息队列、文件或远程接口,就不能随便改。

安全改法:

  1. 新增字段通常比删除字段安全。
  2. 字段含义不要随意改变。
  3. 字段类型变更要考虑旧数据。
  4. 消息体要有版本号或兼容策略。
  5. 不同系统之间传输不要依赖 Java 原生序列化。

线上问题排查

mermaid
flowchart TD
    A["序列化相关问题"] --> B{"表现是什么"}
    B -- "InvalidClassException" --> C["检查 serialVersionUID 和类结构变更"]
    B -- "NotSerializableException" --> D["检查对象图中哪个类没实现 Serializable"]
    B -- "字段变 null" --> E["检查 transient、字段改名、旧数据缺字段"]
    B -- "缓存读取失败" --> F["检查缓存版本、字段类型、协议是否变化"]
    B -- "MQ 消费失败" --> G["检查消息版本、消费者兼容、消息体大小"]
    B -- "安全告警" --> H["检查是否反序列化不可信原生字节流"]

排查建议:

  1. 先确认使用的序列化协议:JDK 原生、JSON、Protobuf、Kryo 还是 Hessian。
  2. 看异常类型:InvalidClassException 多半是版本兼容,NotSerializableException 多半是对象图里有类不支持序列化。
  3. 对 MQ 和 Redis,确认旧数据是否还在,是否需要清理或做兼容读取。
  4. 对字段缺失,确认是历史数据没有、新字段默认值、字段改名,还是 transient
  5. 对安全问题,先确认数据来源是否可信,是否存在原生反序列化入口。

常见风险

问题后果建议
反序列化不可信数据安全漏洞不接收任意来源原生序列化数据
忘记 serialVersionUID类变更后旧数据读取失败显式声明
序列化敏感字段泄露密码、token使用 transient 或 DTO
缓存对象结构随意改老缓存反序列化失败加版本或清理缓存
大对象直接进 MQ消息堆积、网络压力大控制消息体大小
直接序列化数据库实体泄露内部字段、强耦合表结构使用专门 DTO
消息无版本号生产者消费者升级不兼容消息体带 version
反序列化后不校验构造出非法对象反序列化后做业务校验

面试常问

Serializable 为什么没有方法?
它是标记接口,表示这个类允许被 Java 原生序列化。真正写入和读取逻辑由 ObjectOutputStreamObjectInputStream 负责,业务类只声明自己可以被序列化。

序列化会把对象引用也写进去吗?
会。序列化会沿对象图写入被引用对象,所以对象图里的类也要能序列化。对象图越复杂,序列化数据越大,也越容易把不该传输的字段带出去。

反序列化会调用构造方法吗?
Serializable 对象反序列化时通常不会按普通 new 的方式执行构造方法,而是通过特殊机制创建对象并恢复字段值。所以构造方法里的校验不能替代反序列化后的安全校验。

serialVersionUID 有什么用?
它是序列化版本号。反序列化时会比较字节流里的版本号和当前类版本号,不一致会抛 InvalidClassException。显式声明它可以避免类结构轻微变化导致自动计算值变化。

transient 和 static 为什么不会正常序列化?
transient 表示字段不参与默认序列化,适合密码、token、临时缓存、本地资源句柄;static 属于类,不属于对象实例,而序列化保存的是对象状态,所以不会作为普通字段写入。

为什么商业系统很少直接用 Java 原生序列化做外部协议?
因为它体积较大、跨语言差、版本兼容复杂,并且反序列化不可信数据有安全风险。对外接口通常用 JSON,跨语言高性能场景常用 Protobuf,内部 Java RPC 才可能使用 Hessian、Kryo 等。

本章小结

序列化解决对象跨内存边界的问题。Java 原生序列化适合理解原理,但商业系统更常用 JSON 或高性能二进制协议。学习序列化要重点理解版本兼容、安全风险、字段变更和跨系统传输协议选择。