Lesson 49 · Java 高级特性

序列化机制:Serializable、transient 与 serialVersionUID

中级·#Java·#序列化

第 1 站

Serializable 接口:一个空标记接口背后的魔法

Java 序列化是指将对象转换为字节流(序列化)和从字节流恢复对象(反序列化)的过程。核心接口是 java.io.Serializable——一个没有任何方法的标记接口(Marker Interface)。

SerializeDemo.java · 序列化与反序列化基础
public class User implements Serializable {
    private String name;
    private int age;
    private transient String password;  // ★ transient 字段不参与序列化
}

// 序列化:对象 → 字节流
User user = new User("张三", 28, "secret");
try (ObjectOutputStream oos = new ObjectOutputStream(
        new FileOutputStream("user.dat"))) {
    oos.writeObject(user);
}

// 反序列化:字节流 → 对象
try (ObjectInputStream ois = new ObjectInputStream(
        new FileInputStream("user.dat"))) {
    User restored = (User) ois.readObject();
    System.out.println(restored.name);      // 张三
    System.out.println(restored.age);       // 28
    System.out.println(restored.password);  // null ★ transient 字段丢失
}

/*
 * 为什么 Serializable 是空接口?
 * 它只是一个"标记",告诉 JVM:这个类允许被序列化。
 * 实际的序列化逻辑在 ObjectOutputStream 内部实现。
 * 如果一个类没有 implements Serializable,序列化时会抛出 NotSerializableException。
 */
反序列化时构造函数会被调用吗?

不会。反序列化通过 Unsafe.allocateInstance() 直接分配内存,跳过构造函数。但会调用最近的非 Serializable 父类的无参构造函数。所以:

- 如果父类没有实现 Serializable,父类字段会被初始化为默认值(不是反序列化的值)
- 如果父类也实现了 Serializable,父类字段正常恢复

第 2 站

serialVersionUID:版本兼容的"通行证"

serialVersionUID 是序列化流的版本号。反序列化时,JVM 会比较字节流中的 UID 和当前类的 UID,如果不一致就抛出 InvalidClassException

SerialVersionUIDDemo.java · UID 不匹配的灾难
// ═══ 版本 1:原始类 ═══
public class Order implements Serializable {
    // 没有显式声明 serialVersionUID
    private String orderId;
    private double amount;
}

// 序列化一个 Order 对象,保存到文件/Redis/数据库

// ═══ 版本 2:新增字段 ═══
public class Order implements Serializable {
    // JVM 自动生成的 UID 变了!(因为类结构变了)
    private String orderId;
    private double amount;
    private String status;  // 新增字段
}

// 反序列化之前保存的 Order 对象:
// ❌ InvalidClassException: Order;
//    local class incompatible:
//    stream classdesc serialVersionUID = -3287764903...
//    local class serialVersionUID   =  4829105732...

/*
 * 正确做法:显式声明 serialVersionUID
 */
public class Order implements Serializable {
    private static final long serialVersionUID = 1L;  // ★ 显式声明!
    private String orderId;
    private double amount;
    private String status;  // 新增字段,反序列化时为 null(默认值)
}
serialVersionUID 的两种来源:

1. 显式声明private static final long serialVersionUID = 1L;——开发者控制,类结构变化不影响
2. JVM 自动生成:根据类名、字段、方法签名等计算一个 hash 值。只要类结构有任何变化(增删字段、修改方法),UID 就变

规则:永远显式声明 serialVersionUID。这是 Java 编码规范的铁律。
类结构变更有显式 UID无显式 UID(自动生成)
新增字段反序列化成功,新字段为默认值UID 变化 → 反序列化失败
删除字段反序列化成功,旧数据丢弃UID 变化 → 反序列化失败
修改字段类型可能 ClassCastExceptionUID 变化 → 反序列化失败
新增方法无影响UID 可能变化 → 反序列化可能失败
分布式系统中,serialVersionUID 的问题会更严重吗?

非常严重。假设 Redis 中缓存了序列化后的 Order 对象,新版本上线后 Order 类增加了字段。如果没显式声明 UID:
① 灰度发布期间,新旧版本的 UID 不同 → 反序列化报错
② 回滚版本后,之前新版本写入的数据也无法反序列化
③ 不同服务用不同版本的类 → 互相通信失败

这就是为什么生产环境不推荐 Java 原生序列化的原因之一。
第 3 站

transient 关键字:哪些字段不该序列化?

transient 修饰的字段不参与序列化。反序列化时,transient 字段被初始化为默认值(null / 0 / false)。

TransientDemo.java · transient 的使用场景
public class UserSession implements Serializable {
    private static final long serialVersionUID = 1L;

    private String userId;
    private String username;

    // ★ 场景 1:敏感数据不应序列化(安全)
    private transient String password;
    private transient String token;

    // ★ 场景 2:可以重新计算的派生字段
    private transient int age;  // 可以从 birthday 重新算

    // ★ 场景 3:非序列化类型的字段(Thread、Socket 等)
    private transient Thread worker;
    private transient Socket connection;

    // ★ 场景 4:static 字段天然不参与序列化
    // (static 属于类,不属于对象实例,不需要 transient)
    private static String appName = "MyApp";
}

如果 transient 字段需要在反序列化后恢复值怎么办?JDK 提供了两个"钩子方法":

CustomSerialize.java · writeObject / readObject 钩子
public class SensitiveData implements Serializable {
    private static final long serialVersionUID = 1L;

    private String name;
    private transient String secret;  // 不直接序列化

    // ★ 自定义序列化:加密后写入
    private void writeObject(ObjectOutputStream oos) throws Exception {
        oos.defaultWriteObject();  // 先序列化非 transient 字段
        oos.writeUTF(encrypt(secret));  // 再手动序列化 transient 字段(加密后)
    }

    // ★ 自定义反序列化:读取后解密
    private void readObject(ObjectInputStream ois) throws Exception {
        ois.defaultReadObject();  // 先反序列化非 transient 字段
        this.secret = decrypt(ois.readUTF());  // 再手动恢复 transient 字段
    }

    // ★ 另一个钩子:readResolve()——控制反序列化返回的对象
    // 常用于实现序列化安全的单例
    private Object readResolve() {
        return Singleton.INSTANCE;  // 反序列化时返回已有单例,而非新建对象
    }
}
writeObject/readObject 为什么是 private 的却能被调用?

ObjectOutputStream 在序列化时,会通过反射查找类中是否有 private void writeObject(ObjectOutputStream) 方法。如果有,就调用它而不是默认的序列化逻辑。这是一种"约定优于配置"的设计——虽然没有接口约束,但方法签名必须严格匹配。

第 4 站

Externalizable:完全自定义的序列化控制

Externalizable 是 Serializable 的子接口,提供了两个必须实现的抽象方法:writeExternal()readExternal()。与 writeObject/readObject 钩子不同,Externalizable 要求你完全控制序列化过程。

ExternalizableDemo.java · Externalizable 的使用
public class Config implements Externalizable {
    // ★ Externalizable 反序列化时会调用无参构造函数!
    // 所以必须提供 public 无参构造(Serializable 不需要)
    public Config() { }

    public Config(String key, String value) {
        this.key = key;
        this.value = value;
    }

    private String key;
    private String value;

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        // ★ 完全由你控制写什么、怎么写
        out.writeUTF(key);
        out.writeUTF(encrypt(value));  // 可以加密
        out.writeInt(1);              // 可以写版本号
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException {
        // ★ 读取顺序必须和写入顺序完全一致
        this.key = in.readUTF();
        this.value = decrypt(in.readUTF());
        int version = in.readInt();
        // 可以根据 version 做兼容性处理
    }
}
特性SerializableExternalizable
接口方法无(标记接口)writeExternal() / readExternal()
序列化控制自动 + 可选钩子完全手动
无参构造不需要必须提供 public 无参构造
transient 有效?有效无效(你自己控制写什么)
serialVersionUID需要需要
性能较慢(反射探测)较快(直接调用)
第 5 站

安全隐患:Java 反序列化漏洞

Java 原生序列化有一个著名的安全问题:反序列化可以执行任意代码。攻击者构造恶意字节流,在 readObject() 过程中触发危险操作。

DeserializationAttack.java · 反序列化攻击原理(简化)
/*
 * 攻击原理(以 Apache Commons Collections 为例):
 *
 * 1. 攻击者构造一个特殊的 HashMap:
 *    HashMap → TiedMapEntry → LazyMap → ChainedTransformer
 *      → InvokerTransformer → Runtime.exec("rm -rf /")
 *
 * 2. HashMap 的 readObject() 会调用 hash() → hashCode()
 *    触发 TiedMapEntry.hashCode() → LazyMap.get()
 *    → ChainedTransformer.transform() → Runtime.exec()
 *
 * 3. 只要服务端对输入的字节流执行 readObject(),就会触发命令执行
 *
 * 这是 2015 年 Gabriel Lawrence 发现的经典攻击链
 * 影响了大量使用 Commons Collections 3.x 的系统
 */

// 防御措施:
// ① 使用 ObjectInputFilter(JDK 9+)限制可反序列化的类
ObjectInputStream ois = new ObjectInputStream(in);
ois.setObjectInputFilter(filterInfo -> {
    if (filterInfo.serialClass() != null) {
        String name = filterInfo.serialClass().getName();
        // 只允许特定类被反序列化
        if (name.startsWith("com.myapp.model."))
            return ObjectInputFilter.Status.ALLOWED;
        return ObjectInputFilter.Status.REJECTED;
    }
    return ObjectInputFilter.Status.UNDECIDED;
});
反序列化安全规则:

1. 不要反序列化不可信的数据——来自网络的字节流、用户提交的数据都不应使用 Java 原生反序列化
2. 升级依赖——Commons Collections 3.x 已修复,升级到 4.x+
3. 使用白名单——JDK 9+ 的 ObjectInputFilter 限制可反序列化的类
4. 根本方案——使用 JSON/Protobuf 等不包含"执行逻辑"的序列化格式
第 6 站

现代替代方案与总结

Java 原生序列化在性能、安全性、跨语言兼容性上都有明显不足。生产环境中几乎不会直接使用,而是采用以下替代方案:

方案格式跨语言性能Schema 演进典型场景
Java 原生二进制不推荐
Protobuf二进制极好好(字段编号)gRPC、微服务通信
Hessian二进制Dubbo RPC
Jackson JSON文本REST API、配置
Kryo二进制极好Spark、Flink 内部
MessagePack二进制Redis 缓存
Alternatives.java · 各方案使用示例
/* ═══ Protobuf(需要定义 .proto 文件)═══ */
// user.proto:
// message User { string name = 1; int32 age = 2; }
//
// protoc --java_out=. user.proto  生成 Java 类
User user = User.newBuilder().setName("张三").setAge(28).build();
byte[] data = user.toByteArray();           // 序列化(极小体积)
User restored = User.parseFrom(data);       // 反序列化

/* ═══ Hessian(Dubbo 默认序列化)═══ */
Hessian2Output ho = new Hessian2Output(outputStream);
ho.writeObject(user);  // 不需要 implements Serializable!
ho.flush();

Hessian2Input hi = new Hessian2Input(inputStream);
User u = (User) hi.readObject();

/* ═══ Jackson JSON ═══ */
ObjectMapper mapper = new ObjectMapper();
String json = mapper.writeValueAsString(user);    // {"name":"张三","age":28}
User u2 = mapper.readValue(json, User.class);

/* ═══ Kryo(高性能,但不跨语言)═══ */
Kryo kryo = new Kryo();
kryo.register(User.class);
kryo.writeObject(output, user);    // 序列化(速度极快)
User u3 = kryo.readObject(input, User.class);
为什么 Protobuf 的 Schema 演进能力最强?

Protobuf 使用字段编号(而非字段名)来标识字段。新增字段只需分配新编号,旧数据反序列化时忽略未知字段。删除字段只需标记为 reserved,不会冲突。这使得不同版本的服务可以同时运行,非常适合微服务的灰度发布。

序列化面试要点速记
  • "为什么要显式声明 serialVersionUID?"——不声明时 JVM 会根据类结构自动生成,任何类结构变化都会导致 UID 变化,反序列化旧数据时抛出 InvalidClassException
  • "transient 有什么用?"——标记不参与序列化的字段。反序列化时为默认值。可以配合 writeObject/readObject 自定义序列化逻辑
  • "Serializable 和 Externalizable 的区别?"——Serializable 是自动序列化 + 可选钩子,Externalizable 是完全手动控制。Externalizable 必须有无参构造函数
  • "为什么生产不推荐 Java 原生序列化?"——性能差(反射+冗余信息)、安全漏洞(反序列化执行任意代码)、不跨语言、版本兼容性差
  • "生产用什么替代?"——RPC 用 Protobuf/Hessian,HTTP API 用 Jackson JSON,内部框架用 Kryo

面试回答模板:

"Java 序列化通过 Serializable 标记接口启用,ObjectOutputStream/ObjectInputStream 执行序列化/反序列化。serialVersionUID 用于版本兼容——不显式声明时 JVM 自动生成,类结构变化会导致 UID 不匹配而反序列化失败。transient 字段不参与序列化,适合标记敏感数据和非序列化资源。Java 原生序列化存在安全漏洞(反序列化可执行任意代码),性能也较差,生产环境推荐 Protobuf(gRPC 场景)、Hessian(Dubbo 场景)或 Jackson(REST API 场景)。"