当两个进程进行远程通信时,彼此可以发送各种类型的数据,如文本、图片、语音、视频等。但无论何种类型的数据,最终都会以二进制序列的形式在网络上传送。当两个 Java 进程进行远程通信时,发送方需要把 Java 对象转换成字节序列,才能在网络上传送,这个过程称为对象的序列化。接收方则需要把字节序列再恢复为 Java 对象,这个过程称为对象的反序列化。
当程序运行时,程序所创建的各种对象都位于内存中,当程序运行结束,这些对象就结束了生命周期。因此,对象的序列化主要有两个用途:
- 持久化 Java 对象的字节序列,如文件
- 在网络上传输对象的字节序列
JDK 序列化
在 Java 中,默认执行序列化和反序列化的操作由 ObjectOutputStream 和 ObjectInputStream 来完成:
public final void writeObject(Object obj) throws IOException
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。
- 在反序列化时不会调用类的任何构造函数。
如果用户希望控制类的序列化方式,可以在可序列化类中提供以下形式的 writeObject 和 readObject 方法:
private void writeObject(ObjectOutputStream out) throws IOException;
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 接口中声明了两个方法:
public interface Externalizable extends java.io.Serializable {
void writeExternal(ObjectOutput out) throws IOException;
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}
writeExternal() 方法负责序列化操作,readExternal() 方法负责反序列化操作。在对实现了 Externalizable 接口的类的对象进行反序列化时,会先调用类的不带参数的构造方法,这是有别于默认反序列化方式的。如果实现了该接口的对象没有提供无参构造方法,或者无参构造方法的访问权限不是 public 类型的,则在反序列化时会抛出一个 InvalidClassException 异常。
综上所述,JDK 的序列化方式为:
如果一个类仅实现了 Serializable 接口,那么 ObjectOutputStream、ObjectInputStream 会采用默认的序列化方式,对非 transient 修饰的实例变量进行序列化和反序列化。如果这个类还定义了 readObject、writeObject 方法,那么将会采用该方法进行序列化和反序列化。
如果一个类实现了 Externalizable 接口,那么该类必须实现 writeExternal() 和 readExternal() 方法。在这种情况下,会采用实现的方法来进行序列化和反序列化。
3. serialVersionUID
凡是实现 Serializable 接口的类都有一个表示序列化版本标识符的静态常量,Java 的序列化与反序列化机制就是通过判断该值来验证版本一致性的。
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。
private final ThreadLocal<FSTConfiguration> conf = ThreadLocal.withInitial(() -> {
FSTConfiguration conf = FSTConfiguration.createDefaultConfiguration();
return conf;
});
public byte[] encoder(Object object) {
return conf.get().asByteArray(object);
}
public <T> T decoder(byte[] bytes) {
Object ob = conf.get().asObject(bytes);
return (T)ob;
}
2. Kryo
Kryo 是一个快速有效的 Java 二进制序列化框架,它依赖底层 ASM 库用于字节码生成,因此有比较好的运行速度。Kryo 的目标就是提供一个序列化速度快、结果体积小、API 简单易用的序列化框架。Kryo 支持自动深/浅拷贝,它是直接通过对象 -> 对象的深度拷贝,而不是对象 -> 字节 -> 对象的过程。
下面是使用 Kryo 进行序列化的 Demo:
private static final ThreadLocal<Kryo> kryoLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
// 不需要提前预注册类
kryo.setRegistrationRequired(false);
return kryo;
});
public static byte[] encoder(Object object) {
Output output = new Output();
kryoLocal.get().readClassAndObject(output, object);
output.flush();
return output.toBytes();
}
public static <T> T decoder(byte[] bytes) {
Input input = new Input(bytes);
Object ob = kryoLocal.get().readClassAndObject(input);
return (T) ob;
}
在使用方式上 Kryo 提供的 API 非常简洁易用,Input 和 Output 封装了你几乎能够想到的所有流操作。需要注意的是,在使用 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 序列化非常简单,只需要通过 HessianInput 和 HessianOutput 即可完成对象的序列化与反序列化操作,下面是 Hessian 序列化的 Demo:
public static <T> byte[] encoder2(T obj) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output hessian2Output = new Hessian2Output(bos);
hessian2Output.writeObject(obj);
return bos.toByteArray();
}
public static <T> T decoder2(byte[] bytes) throws Exception {
ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
Hessian2Input hessian2Input = new Hessian2Input(bis);
Object obj = hessian2Input.readObject();
return (T) obj;
}
Hession 序列化类虽然需要实现 Serializable 接口,但是它并不受 serialVersionUID 的影响,因此能够轻松支持字段的扩展。
- 新增、修改字段名称:反序列化后新字段名称为 null 或 0(受类型影响)。
- 删除字段:能够正常反序列化。
- 修改字段类型:如果字段类型兼容能够正常反序列化,如果不兼容则直接抛出异常。
4. Protobuf
Protobuf 是由 Google 推出且支持多语言的序列化框架,Protobuf 以一个 .proto 后缀的文件为基础,这个文件描述了字段以及字段类型,通过工具可以生成不同语言的数据结构文件。在序列化该数据对象的时候,Protobuf 通过 .proto 文件描述来生成 Protocol Buffers 格式的编码。