基于本文回答
0
评论

讲讲Java 对象序列化(Serialization)和反序列化(Deserialization)的底层原理

Java 的对象序列化(Serialization)是将内存中的对象状态转换为字节流的过程,以便将其保存到磁盘或通过网络传输;反序列化(Deserialization)则是相反的过程,将字节流恢复为内存中的 Java 对象。

要深入理解其底层原理,我们需要从 JVM 规范、二进制协议格式、反射机制以及安全/性能等多个维度来剖析。


一、 核心概念:Serializable 与 serialVersionUID

  1. Serializable 标记接口
    java.io.Serializable 是一个空接口(Marker Interface)。它不包含任何方法。

    • 底层原理:在运行时,JVM 在执行序列化操作(如 ObjectOutputStream.writeObject())时,会通过 instanceof Serializable 检查该对象是否允许序列化。如果未实现该接口,会直接抛出 NotSerializableException
  2. serialVersionUID(版本控制)

    • 底层原理:每个可序列化的类都有一个版本号。如果显式定义了,就使用定义的值;如果没有显式定义,JVM 会根据类的结构(类名、接口、方法、属性等)通过 SHA-1 算法自动生成一个 64 位的哈希值。
    • 反序列化校验:反序列化时,JVM 会比对输入流中的 serialVersionUID 和当前类加载器中对应类的 serialVersionUID。如果不一致,会抛出 InvalidClassException,防止由于类结构不兼容导致程序崩溃。

二、 序列化(Serialization)的底层实现步骤

当我们调用 ObjectOutputStream.writeObject(obj) 时,JVM 底层经历了以下步骤:

plaintext
[ObjectOutputStream] 
   └── writeObject()
        └── writeObject0() (核心递归方法)
             ├── 检查对象是否为 String/Array/Enum
             ├── 检查是否实现 Serializable
             ├── 写入魔数与版本号 (如果是流头部)
             ├── 写入类元数据 (Class Descriptor)
             └── 递归写入对象属性 (通过反射获取)

1. 写入流头部(Stream Header)

任何 Java 序列化流的开头都是固定的 4 个字节:

  • AC ED (STREAM_MAGIC):魔数,声明这是一个 Java 序列化流。
  • 00 05 (STREAM_VERSION):版本号,目前主流是 5。
  • 底层验证:如果读取到的字节流前两个字节不是 AC ED,反序列化会直接报错。

2. 写入类描述符(Class Descriptor)

JVM 不能只写数据,它必须告诉反序列化端这个数据属于哪个类。

  • 写入类的全限定名(如 com.example.User)。
  • 写入 serialVersionUID
  • 写入类的特征标志(Flags),例如:是否支持 Serializable、是否支持 Externalizable、是否包含自定义的 writeObject 方法。
  • 写入字段的元数据:字段类型(如 I 代表 int,Ljava/lang/String; 代表 String)和字段名称。

3. 递归处理对象图(Object Graph)与引用表(Handle Table)

如果一个对象 A 引用了对象 B,而 B 又引用了 C,这就是一个对象图。

  • 规避循环引用和重复写入:JVM 内部维护了一个句柄表(Handle Table / Wire Handle),它是一个哈希表,记录了所有已经写入过序列化流的对象。
  • 原理
    • 当准备写入一个对象时,先在句柄表中查找。
    • 如果是第一次写入,给它分配一个递增的 Handle(从 0x7E0000 开始),并将完整的对象数据写入流。
    • 如果该对象已经写入过(比如循环引用),则不再写入对象数据,而是写入一个引用标记(TC_REFERENCE和之前分配的 Handle。这既节省了空间,又解决了循环引用的死循环问题。

4. 写入对象属性的实际值

  • JVM 从当前类开始,沿着继承树向上寻找,直到找到第一个没有实现 Serializable 的父类。
  • 通过反射(或内部高效的 sun.misc.Unsafe)获取非 static、非 transient 的实例变量的值。
  • 依次将这些值写入字节流。

三、 反序列化(Deserialization)的底层实现步骤

当我们调用 ObjectInputStream.readObject() 时,底层过程如下:

plaintext
[ObjectInputStream]
   └── readObject()
        └── readObject0()
             ├── 读取流头部并验证
             ├── 读取类描述符,通过 ClassLoader 加载对应的本地 Class
             ├── 实例化对象 (不调用构造函数!)
             └── 递归读取属性值并填充

1. 实例化对象(不调用构造函数)

这是反序列化最神奇、也是最底层的一点:反序列化创建对象时,通常不调用该类本身的构造函数

  • 底层原理
    • 对于实现了 Serializable 的类,JVM 会调用 第一个没有实现 Serializable 接口的父类无参构造函数
    • 一旦找到了这个父类构造器,JVM 会利用 sun.reflect.ReflectionFactory(或更底层的 ConstructorAccessor)生成一个特殊的构造器。这个构造器可以绕过当前类的构造器,直接在堆内存中为新对象分配空间。
    • 注意:如果第一个没有实现 Serializable 的父类没有无参构造函数,反序列化将抛出 InvalidClassException

2. 填充属性

  • JVM 根据流中读取到的属性值,通过反射或 Unsafe.putObject() 直接写入新创建对象的内存地址中(即使这些字段是 private 的)。

四、 自定义序列化控制:底层钩子(Hooks)

Java 提供了几种机制让开发者干预默认的序列化过程:

1. writeObjectreadObject(私有方法)

如果在类中定义了以下签名的私有方法,JVM 会通过反射调用它们,而不是执行默认的序列化逻辑:

java
private void writeObject(java.io.ObjectOutputStream out) throws IOException;
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException;
  • 底层原理:在序列化时,ObjectStreamClass 会在初始化时检测类中是否存在这两个方法。如果存在,会将它们缓存起来。在执行 writeObject 时,通过反射调用用户自定义的方法。用户可以在这些方法内部调用 out.defaultWriteObject() 来保留默认行为,并手动处理一些特殊字段(如加密敏感数据)。

2. writeReplacereadResolve(保护/私有方法)

用于序列化替代和单例保护。

  • writeReplace:在序列化之前调用,可以返回一个替代对象(比如将一个复杂对象转换成一个轻量级的代理对象写入流中)。
  • readResolve:在反序列化后、返回给用户之前调用。通常用于单例模式。反序列化出的新对象会被丢弃,直接返回已有的单例对象。

3. Externalizable 接口

如果类实现了 Externalizable(继承自 Serializable):

  • 底层行为变化
    1. 必须实现 writeExternalreadExternal
    2. 实例化时会调用公共的无参构造函数(public no-arg constructor),然后调用 readExternal 填充数据。
    3. 性能比 Serializable 高,因为省去了大量的反射解析类元数据的开销。

五、 为什么 Java 原生序列化在现代开发中被弃用?

虽然原生序列化机制设计精妙,但如今在 RPC 框架(如 Dubbo, gRPC)和微服务中,大家更倾向于使用 JSON, Protobuf, Hessian, Kryo 等,原因在于原生序列化存在致命缺陷:

  1. 安全漏洞(最致命)
    • 原理:反序列化时,ObjectInputStream.readObject() 会读取流中的类元数据并实例化。攻击者可以构造恶意的字节流(包含特定的 Gadget Chains / 小工具链,如 Apache Commons Collections 漏洞),在 JVM 反序列化实例化对象、填充属性的过程中,触发特定类的恶意代码执行,导致 RCE(远程代码执行)
  2. 性能低下与体积庞大
    • 原理:Java 序列化写入了大量的类元数据(全限定名、字段名、类型描述符等),导致产生的字节数组非常大。
    • 整个过程严重依赖反射,反射调用比直接的内存读写慢得多。
  3. 跨语言障碍
    • 序列化出来的字节流是 Java 特有的,C++, Python, Go 等其他语言无法直接解析。

总结

Java 序列化底层是一套结合了 类加载机制、JVM 内存分配魔术(绕过构造器)、反射/Unsafe 机制 以及 二进制协议规范 的复杂系统。它的核心在于通过 Handle 表解决对象图的循环引用,并通过特定的二进制协议格式在网络/磁盘与 JVM 内存之间架起桥梁。

右滑查看面试常问