讲讲Java 对象序列化(Serialization)和反序列化(Deserialization)的底层原理
Java 的对象序列化(Serialization)是将内存中的对象状态转换为字节流的过程,以便将其保存到磁盘或通过网络传输;反序列化(Deserialization)则是相反的过程,将字节流恢复为内存中的 Java 对象。
要深入理解其底层原理,我们需要从 JVM 规范、二进制协议格式、反射机制以及安全/性能等多个维度来剖析。
一、 核心概念:Serializable 与 serialVersionUID
Serializable标记接口java.io.Serializable是一个空接口(Marker Interface)。它不包含任何方法。- 底层原理:在运行时,JVM 在执行序列化操作(如
ObjectOutputStream.writeObject())时,会通过instanceof Serializable检查该对象是否允许序列化。如果未实现该接口,会直接抛出NotSerializableException。
- 底层原理:在运行时,JVM 在执行序列化操作(如
serialVersionUID(版本控制)- 底层原理:每个可序列化的类都有一个版本号。如果显式定义了,就使用定义的值;如果没有显式定义,JVM 会根据类的结构(类名、接口、方法、属性等)通过 SHA-1 算法自动生成一个 64 位的哈希值。
- 反序列化校验:反序列化时,JVM 会比对输入流中的
serialVersionUID和当前类加载器中对应类的serialVersionUID。如果不一致,会抛出InvalidClassException,防止由于类结构不兼容导致程序崩溃。
二、 序列化(Serialization)的底层实现步骤
当我们调用 ObjectOutputStream.writeObject(obj) 时,JVM 底层经历了以下步骤:
[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() 时,底层过程如下:
[ObjectInputStream]
└── readObject()
└── readObject0()
├── 读取流头部并验证
├── 读取类描述符,通过 ClassLoader 加载对应的本地 Class
├── 实例化对象 (不调用构造函数!)
└── 递归读取属性值并填充
1. 实例化对象(不调用构造函数)
这是反序列化最神奇、也是最底层的一点:反序列化创建对象时,通常不调用该类本身的构造函数。
- 底层原理:
- 对于实现了
Serializable的类,JVM 会调用 第一个没有实现Serializable接口的父类 的无参构造函数。 - 一旦找到了这个父类构造器,JVM 会利用
sun.reflect.ReflectionFactory(或更底层的ConstructorAccessor)生成一个特殊的构造器。这个构造器可以绕过当前类的构造器,直接在堆内存中为新对象分配空间。 - 注意:如果第一个没有实现
Serializable的父类没有无参构造函数,反序列化将抛出InvalidClassException。
- 对于实现了
2. 填充属性
- JVM 根据流中读取到的属性值,通过反射或
Unsafe.putObject()直接写入新创建对象的内存地址中(即使这些字段是private的)。
四、 自定义序列化控制:底层钩子(Hooks)
Java 提供了几种机制让开发者干预默认的序列化过程:
1. writeObject 和 readObject(私有方法)
如果在类中定义了以下签名的私有方法,JVM 会通过反射调用它们,而不是执行默认的序列化逻辑:
private void writeObject(java.io.ObjectOutputStream out) throws IOException;
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException;
- 底层原理:在序列化时,
ObjectStreamClass会在初始化时检测类中是否存在这两个方法。如果存在,会将它们缓存起来。在执行writeObject时,通过反射调用用户自定义的方法。用户可以在这些方法内部调用out.defaultWriteObject()来保留默认行为,并手动处理一些特殊字段(如加密敏感数据)。
2. writeReplace 和 readResolve(保护/私有方法)
用于序列化替代和单例保护。
writeReplace:在序列化之前调用,可以返回一个替代对象(比如将一个复杂对象转换成一个轻量级的代理对象写入流中)。readResolve:在反序列化后、返回给用户之前调用。通常用于单例模式。反序列化出的新对象会被丢弃,直接返回已有的单例对象。
3. Externalizable 接口
如果类实现了 Externalizable(继承自 Serializable):
- 底层行为变化:
- 必须实现
writeExternal和readExternal。 - 实例化时会调用公共的无参构造函数(public no-arg constructor),然后调用
readExternal填充数据。 - 性能比
Serializable高,因为省去了大量的反射解析类元数据的开销。
- 必须实现
五、 为什么 Java 原生序列化在现代开发中被弃用?
虽然原生序列化机制设计精妙,但如今在 RPC 框架(如 Dubbo, gRPC)和微服务中,大家更倾向于使用 JSON, Protobuf, Hessian, Kryo 等,原因在于原生序列化存在致命缺陷:
- 安全漏洞(最致命)
- 原理:反序列化时,
ObjectInputStream.readObject()会读取流中的类元数据并实例化。攻击者可以构造恶意的字节流(包含特定的 Gadget Chains / 小工具链,如 Apache Commons Collections 漏洞),在 JVM 反序列化实例化对象、填充属性的过程中,触发特定类的恶意代码执行,导致 RCE(远程代码执行)。
- 原理:反序列化时,
- 性能低下与体积庞大
- 原理:Java 序列化写入了大量的类元数据(全限定名、字段名、类型描述符等),导致产生的字节数组非常大。
- 整个过程严重依赖反射,反射调用比直接的内存读写慢得多。
- 跨语言障碍
- 序列化出来的字节流是 Java 特有的,C++, Python, Go 等其他语言无法直接解析。
总结
Java 序列化底层是一套结合了 类加载机制、JVM 内存分配魔术(绕过构造器)、反射/Unsafe 机制 以及 二进制协议规范 的复杂系统。它的核心在于通过 Handle 表解决对象图的循环引用,并通过特定的二进制协议格式在网络/磁盘与 JVM 内存之间架起桥梁。