当两个进程进行远程通信时,彼此可以发送各种类型的数据,如文本、图片、语音、视频等。但无论何种类型的数据,最终都会以二进制序列的形式在网络上传送。当两个 Java 进程进行远程通信时,发送方需要把 Java 对象转换成字节序列,才能在网络上传送,这个过程称为对象的序列化。接收方则需要把字节序列再恢复为 Java 对象,这个过程称为对象的反序列化。

当程序运行时,程序所创建的各种对象都位于内存中,当程序运行结束,这些对象就结束了生命周期。因此,对象的序列化主要有两个用途:

  • 持久化 Java 对象的字节序列,如文件
  • 在网络上传输对象的字节序列

JDK 序列化

在 Java 中,默认执行序列化和反序列化的操作由 ObjectOutputStreamObjectInputStream 来完成:

  1. public final void writeObject(Object obj) throws IOException
  2. public final Object readObject() throws IOException, ClassNotFoundException

ObjectOutputStream 代表对象输出流,它提供的 writeObject() 方法可对指定的 obj 对象进行序列化,然后把得到的字节序列写到一个目标输出流中。ObjectInputStream 代表对象输入流,它提供的 readObject() 方法从源输入流中读取字节序列,把它们反序列化成一个 object 对象,然后将其返回。

只有实现了 Serializable Externalizable 接口的类的对象才能被序列化,否则 writeObject() 方法会抛出一个 IOException 异常。其中 Externalizable 接口继承自 Serializable 接口,实现 Serializable 接口的类可以采用默认的序列化方式,而实现了 Externalizable 接口的类可以完全由自身来控制序列化的行为。

要想将父类对象也序列化,就需要让父类也实现 Serializable 接口。如果父类不实现该接口的话,就需要有默认的无参构造函数。因为在父类没有实现 Serializable 接口时,虚拟机是不会序列化父对象的,而一个 Java 对象的构造必须先有父对象,才有子对象,反序列化也不例外。所以反序列化时,为了构造父对象,只能调用父类的无参构造函数作为默认的父对象(此时父类变量值都是默认声明的值)。

1. 实现 Serializable 接口

ObjectOutputStream 只能对实现了 Serializable 接口的类的对象进行序列化,它的默认序列化方式仅仅对对象的非 transient 修饰的实例变量进行序列化,不会对 transient 修饰的实例变量及静态变量序列化,因为序列化是针对对象而言的。并且在默认情况下会对整个对象图进行序列化,反序列化时也会对整个对象图反序列化。

当 ObjectInputStream 按照默认方式反序列化时,有以下特点:

  • 如果在内存中对象所属的类还没有被加载,那么会先加载并初始化这个类。如果在 classpath 中不存在相应的类文件,则会抛出 ClassNotFoundException。


  • 在反序列化时不会调用类的任何构造函数。

如果用户希望控制类的序列化方式,可以在可序列化类中提供以下形式的 writeObjectreadObject 方法:

  1. private void writeObject(ObjectOutputStream out) throws IOException;
  2. private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException;

当 ObjectOutputStream 对一个对象进行序列化时,如果该对象具有上述格式的 writeObject 方法,那么就会执行这一方法,否则就按默认方式序列化。此外 ObjectOutputStream 还提供了 defaultWriteObject 方法,使得对象输出流可以先执行默认的序列化操作。

当 ObjectInputStream 对一个对象进行反序列化时,如果该对象具有上述格式的 readObject 方法,那么就会执行这一方法,否则就按默认方式反序列化。此外 ObjectInputStream 还提供了 defaultReadObject 方法,使得对象输入流可以先执行默认的序列化操作。

针对单例类,如果不想反序列化的时候每次都生成新的对象,你需要定义另外一种称为 readResolve 的特殊序列化方法。如果定义了 readResolve 方法,在对象被序列化之后就会调用它。而它必须返回一个对象,该对象之后就会成为 readObject 的返回值。这样,我们就可以在 readResolve 方法中返回同一个单例对象。

2. 实现 Externalizable 接口

Externalizable 接口继承自 Serializable 接口。如果一个类实现了 Externalizable 接口,那么将完全由这个类控制自身的序列化行为,Externalizable 接口中声明了两个方法:

  1. public interface Externalizable extends java.io.Serializable {
  2. void writeExternal(ObjectOutput out) throws IOException;
  3. void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
  4. }

writeExternal() 方法负责序列化操作,readExternal() 方法负责反序列化操作。在对实现了 Externalizable 接口的类的对象进行反序列化时,会先调用类的不带参数的构造方法,这是有别于默认反序列化方式的。如果实现了该接口的对象没有提供无参构造方法,或者无参构造方法的访问权限不是 public 类型的,则在反序列化时会抛出一个 InvalidClassException 异常。

综上所述,JDK 的序列化方式为:

如果一个类仅实现了 Serializable 接口,那么 ObjectOutputStream、ObjectInputStream 会采用默认的序列化方式,对非 transient 修饰的实例变量进行序列化和反序列化。如果这个类还定义了 readObject、writeObject 方法,那么将会采用该方法进行序列化和反序列化。

如果一个类实现了 Externalizable 接口,那么该类必须实现 writeExternal()readExternal() 方法。在这种情况下,会采用实现的方法来进行序列化和反序列化。

3. serialVersionUID

凡是实现 Serializable 接口的类都有一个表示序列化版本标识符的静态常量,Java 的序列化与反序列化机制就是通过判断该值来验证版本一致性的。

  1. private static final long serialVersionUID

当实现 Serializable 接口的类没有显式地定义 serialVersionUID 时,Java 在进行序列化时会根据编译的 Class 的内部细节自动生成一个 serialVersionUID。在反序列化时,JVM 还会再根据字节流信息自动生成一个新版的 serialVersionUID,然后与序列化生成的 serialVersionUID 进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常。

如果对类的源代码修改后重新编译,新生成的类文件的 serialVersionUID 的取值有可能会发生变化,在反序列化时就会出现问题。如果希望类的不同版本对序列化兼容,强烈建议在一个可序列化类中显式地定义 serialVersionUID 的值,不修改这个值的序列化实体都可以相互进行序列化和反序列化。

4. JDK 序列化的缺陷

如果你用过一些 RPC 通信框架,你就会发现这些框架很少使用 JDK 提供的序列化。其实不用和不好用多半是挂钩的,下面我们就一起来看看 JDK 默认的序列化到底存在着哪些缺陷。

1)无法跨语言
现在的系统设计越来越多元化,很多系统都使用了多种语言来编写应用程序。比如使用 C++ 写游戏服务,Java、Go 写周边服务,Python 写一些监控应用。

而 Java 序列化目前只适用基于 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议。因此,如果是两个基于不同语言编写的应用程序相互通信,则无法实现两个应用服务之间传输对象的序列化与反序列化。

2)易被攻击
Java 官网安全编码指导方针中说明:“对不信任数据的反序列化,从本质上来说是危险的,应该予以避免”。可见 Java 序列化是不安全的。

我们知道对象是通过在 ObjectInputStream 上调用 readObject() 方法进行反序列化的,这个方法其实是一个神奇的构造器,它可以将类路径上几乎所有实现了 Serializable 接口的对象都实例化。这也就意味着,在反序列化字节流的过程中,该方法可以执行任意类型的代码,这是非常危险的。

比如,攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。

3)序列化后的流太大
序列化后的二进制流大小能体现序列化的性能。序列化后的二进制数组越大,占用的存储空间就越多,存储硬件的成本就越高。如果我们是进行网络传输,则占用的带宽就更多,这时就会影响到系统的吞吐量。

Java 序列化中使用了 ObjectOutputStream 来实现对象转二进制编码,通过这种序列化机制得到的二进制数组的大小,相比 NIO 中的 ByteBuffer 实现的二进制编码得到的数组要大上好几倍。因此,Java 序列后的流会变大,最终会影响到系统的吞吐量。

4)序列化性能太差
序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间。而 Java 序列化相比 NIO 中的 ByteBuffer 编码的性能要低很多。

序列化框架

参考链接:https://mp.weixin.qq.com/s/DiphH8bvR8TtLyQmnTkpuw

1. FST

FST(fast-serialization)是完全兼容 JDK 序列化协议的 Java 序列化框架,它在序列化速度上能达到 JDK 的 10 倍,而序列化结果的大小只有 JDK 的 1/3。

下面是使用 FST 序列化的 Demo,FSTConfiguration 是线程安全的,但是为了防止频繁调用使其成为性能瓶颈,一般会使用 TreadLocal 为每个线程分配一个 FSTConfiguration

  1. private final ThreadLocal<FSTConfiguration> conf = ThreadLocal.withInitial(() -> {
  2. FSTConfiguration conf = FSTConfiguration.createDefaultConfiguration();
  3. return conf;
  4. });
  5. public byte[] encoder(Object object) {
  6. return conf.get().asByteArray(object);
  7. }
  8. public <T> T decoder(byte[] bytes) {
  9. Object ob = conf.get().asObject(bytes);
  10. return (T)ob;
  11. }

2. Kryo

Kryo 是一个快速有效的 Java 二进制序列化框架,它依赖底层 ASM 库用于字节码生成,因此有比较好的运行速度。Kryo 的目标就是提供一个序列化速度快、结果体积小、API 简单易用的序列化框架。Kryo 支持自动深/浅拷贝,它是直接通过对象 -> 对象的深度拷贝,而不是对象 -> 字节 -> 对象的过程。

下面是使用 Kryo 进行序列化的 Demo:

  1. private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
  2. Kryo kryo = new Kryo();
  3. // 不需要提前预注册类
  4. kryo.setRegistrationRequired(false);
  5. return kryo;
  6. });
  7. public static byte[] encoder(Object object) {
  8. Output output = new Output();
  9. kryoLocal.get().readClassAndObject(output, object);
  10. output.flush();
  11. return output.toBytes();
  12. }
  13. public static <T> T decoder(byte[] bytes) {
  14. Input input = new Input(bytes);
  15. Object ob = kryoLocal.get().readClassAndObject(input);
  16. return (T) ob;
  17. }

在使用方式上 Kryo 提供的 API 非常简洁易用,InputOutput 封装了你几乎能够想到的所有流操作。需要注意的是,在使用 Output.writeXxx 的时候一定要用对应的 Input.readxxx,比如 Output.writeClassAndObject() 要与 Input.readClassAndObject() 对应。

3. Hessian

Hessian 是 caucho 公司开发的轻量级 RPC 框架,它使用 HTTP 协议进行传输,使用 Hessian 二进制进行序列化。Hessian 由于其支持跨语言、高效的二进制序列化协议,被经常用于序列化框架使用。Hessian 序列化协议分为 Hessian1.0 和 Hessian2.0,Hessian2.0 协议对序列化过程进行了优化,在性能上相较 Hessian1.0 有明显的提升。

使用 Hessian 序列化非常简单,只需要通过 HessianInputHessianOutput 即可完成对象的序列化与反序列化操作,下面是 Hessian 序列化的 Demo:

  1. public static <T> byte[] encoder2(T obj) throws Exception {
  2. ByteArrayOutputStream bos = new ByteArrayOutputStream();
  3. Hessian2Output hessian2Output = new Hessian2Output(bos);
  4. hessian2Output.writeObject(obj);
  5. return bos.toByteArray();
  6. }
  7. public static <T> T decoder2(byte[] bytes) throws Exception {
  8. ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
  9. Hessian2Input hessian2Input = new Hessian2Input(bis);
  10. Object obj = hessian2Input.readObject();
  11. return (T) obj;
  12. }

Hession 序列化类虽然需要实现 Serializable 接口,但是它并不受 serialVersionUID 的影响,因此能够轻松支持字段的扩展。

  • 新增、修改字段名称:反序列化后新字段名称为 null 或 0(受类型影响)。
  • 删除字段:能够正常反序列化。
  • 修改字段类型:如果字段类型兼容能够正常反序列化,如果不兼容则直接抛出异常。

4. Protobuf

Protobuf 是由 Google 推出且支持多语言的序列化框架,Protobuf 以一个 .proto 后缀的文件为基础,这个文件描述了字段以及字段类型,通过工具可以生成不同语言的数据结构文件。在序列化该数据对象的时候,Protobuf 通过 .proto 文件描述来生成 Protocol Buffers 格式的编码。