目录

  1. 概述
  2. Java 序列化细节
    1. 序列化
    2. 反序列化
    3. transient 关键字
    4. 序列化的安全性
      1. 无法跨语言
      2. 易被攻击
        1. 实现攻击的原理
        2. 如何解决这个漏洞?
      3. 序列化后的流太大
      4. 序列化性能太差
  3. 各种序列化技术
    1. XML 序列化框架
    2. JSON 序列化框架
    3. Hessian 序列化框架
    4. Avro 序列化
    5. kyro 序列化框架
    6. Protobuf 序列化框架
  4. 序列化的技术选型
  5. 附录

概述

❓什么是序列化与反序列化

  • 序列化就是把对象的状态信息转化为可存储或传输的形式过程,把对象转化为字节序列的过程称为序列化

  • 反序列化是序列化的逆向过程,把字节数组转化为为对象,把字节序列恢复为对象的过程称为对象的反序列化

📓通常将序列化和反序列这两个操作,简称为序列化,具体的指代根据上下文确定可以确认

❓为什么需要序列化与反序列化?

有序列化,就有反序列化,即可以将二进制内容通过网络传输到远程,这样,就相当于将对象存储到文件(网络也是一种文件系统)中

在高并发系统中,序列化的速度快慢,会影响请求的响应时间,序列化后的传输数据体积大,会导致网络吞吐量下降,所以,一个优秀的序列化框架可以提高系统的整体性能,但是 Java 的序列化性能实在不怎么好

❓Java 是如何实现序列化和反序列化的?

Java 提供了一种对象序列化的机制,可以将一个对象转化为字节序列(byte[]),该字节序列包括该对象的数据、对象的类型信息和成员类型。将序列化对象写入文件之后,可以从文件中读取出来,并且对它进行反序列化,也就是说,对象的类型信息、对象的数据,还有成员的数据类型可以用来在内存中新建对象

🎶在序列化的整个过程中都是 Java 虚拟机(JVM)独立的,也就是说,在一个平台上序列化的对象可以在另一个完全不同的平台上反序列化该对象

Java 序列化细节

一个 Java 对象要能序列化,必须实现一个特殊的java.io.Serializable接口,Serializable接口没有定义任何方法,它是一个空接口,这样的空接口称为标记接口(Marker Interface),实现了标记接口的类相当于给自身贴了个标记,本质上并没有增加任何代码逻辑

也可以实现Externalizable接口,用于自定义序列化字段,但是由于Externalizable增加了编码的复杂度,所以推荐使用Serializable接口

序列化

把一个 Java 对象变为 byte[] 数组,需要使用 ObjectOutputStream,它负责把一个 Java 对象写入一个字节流:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) throws IOException {
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(buffer)) {
// 写入int:
output.writeInt(12345);
// 写入String:
output.writeUTF("Hello");
// 写入Object:
output.writeObject(Double.valueOf(123.456));
}
System.out.println(Arrays.toString(buffer.toByteArray()));
}
}

ObjectOutputStream 既可以写入基本类型,如 intboolean,也可以写入String(以 UTF-8 编码),还可以写入实现了 Serializable 接口的 Object,因为写入 Object 时需要大量的类型信息,所以写入的内容很大

反序列化

和 ObjectOutputStream 相反,ObjectInputStream 负责从一个字节流读取 Java 对象:

1
2
3
4
5
try (ObjectInputStream input = new ObjectInputStream(...)) {
int n = input.readInt();
String s = input.readUTF();
Double d = (Double) input.readObject();
}

除了能读取基本类型和 String 类型外,调用 readObject() 可以直接返回一个 Object 对象,要把它变成一个特定类型,必须强制转型
readObject()可能抛出的异常有:

  • ClassNotFoundException:没有找到对应的 Class,这种情况常见于一台电脑上的 Java 程序把一个 Java 对象,例如,Person 对象序列化以后,通过网络传给另一台电脑上的另一个 Java 程序,但是这台电脑的 Java 程序并没有定义 Person 类,所以无法反序列化

  • InvalidClassException:Class 不匹配,这种情况常见于序列化的Person对象定义了一个int类型的age字段,但是反序列化时,Person类定义的age字段被改成了long类型,所以导致 class 不兼容

为了避免 class 定义变动导致的不兼容,Java 的序列化允许 class 定义一个特殊的serialVersionUID静态变量,用于标识 Java 类的序列化“版本”,通常可以由 IDE 自动生成。如果增加或修改了字段,可以改变serialVersionUID的值,这样就能自动阻止不匹配的 class 版本:

1
2
3
public class Person implements Serializable {
private static final long serialVersionUID = 2709425275741743919L;
}

🎶反序列化的几个重要特点:反序列化时,由 JVM 直接构造出 Java 对象,不调用构造方法,构造方法内部的代码,在反序列化时根本不可能执行

transient 关键字

❓为什么要用transient关键字

在持久化对象时,对于一些特殊的数据成员(如用户的密码,银行卡号等),我们不想用序列化机制来保存它。为了在一个特定对象的一个成员变量上关闭序列化,可以在这个成员变量前加上关键字transient。对于 transient 修饰的成员变量,在类的实例化对象序列化处理过程中会被忽略。因此,transient 变量不会贯穿对象的序列化和反序列化,生命周期仅存于调用者的内存中而不会写到磁盘里进行持久化

🎶static 修饰的静态变量天然就是不可序列化的

✨transient 的作用

  • transient 是 Java 语言的关键字,用来表示一个成员变量不是该对象序列化的一部分。当一个对象被序列化的时候,transient 型变量的值不包括在序列化的结果中。而非 transient 型的变量是被包括进去的

  • transient 关键字只能修饰变量,而不能修饰方法和类,局部变量是不能被 transient 关键字修饰的

  • 一个静态变量不管是否被 transient 修饰,均不能被序列化(如果反序列化后类中 static 变量还有值,则值为当前 JVM 中对应 static 变量的值)。序列化保存的是对象状态,静态变量保存的是类状态,因此序列化并不保存静态变量

序列化的安全性

虽然 Java 提供了 RMI 框架可以实现服务与服务之间的接口暴露和调用,RMI 中对数据对象的序列化采用的是 Java 序列化。而目前主流的框架却很少使用到 Java 序列化,如 SpringCloud 使用的 Json 序列化,Dubbo 虽然兼容了 Java 序列化,但是默认还是使用的 Hessian 序列化。

实际上,Java 本身提供的基于对象的序列化和反序列化机制既存在安全性问题,也存在兼容性问题。更好的序列化方法是通过 JSON 这样的通用数据结构来实现,只输出基本类型(包括 String )的内容,而不存储任何与代码相关的信息。

常见的 RPC 通信框架中,很少会发现使用 JDK 提供的序列化,主要是因为 JDK 默认的序列化存在着如下一些缺陷:无法跨语言易被攻击序列化后的流太大序列化性能太差等

无法跨语言

现在很多系统的复杂度很高,采用多种语言来编码,而 Java 序列化目前只支持 Java 语言实现的框架,其它语言大部分都没有使用 Java 的序列化框架,也没有实现 Java 序列化这套协议,因此,如果两个基于不同语言编写的应用程序之间通信,使用 Java 序列化,则无法实现两个应用服务之间传输对象的序列化和反序列化

易被攻击

对于不信任数据的反序列化,从本质上来说是危险的,应该避免

Java 官网安全编码指导

对于需要长时间进行反序列化的对象,不需要执行任何代码,也可以发起一次攻击。攻击者可以创建循环对象链,然后将序列化后的对象传输到程序中反序列化,这种情况会导致 hashCode 方法被调用次数呈次方爆发式增长, 从而引发栈溢出异常。

例如下面这个案例就可以很好地说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Set root = new HashSet();
Set s1 = root;
Set s2 = new HashSet();
for (int i = 0; i < 100; i++) {
Set t1 = new HashSet();
Set t2 = new HashSet();
t1.add("test"); //使t2不等于t1
s1.add(t1);
s1.add(t2);
s2.add(t1);
s2.add(t2);
s1 = t1;
s2 = t2;
}

FoxGlove Security 安全团队的一篇论文中提到的:通过 Apache Commons Collections,Java 反序列化漏洞可以实现攻击。Apache Commons Collections 就是一个第三方基础库,它扩展了 Java 标准库里的 Collection 结构,提供了很多强大的数据结构类型,并且实现了各种集合工具类

实现攻击的原理

Apache Commons Collections 允许链式的任意的类函数反射调用,攻击者通过实现了 Java 序列化协议的端口,把攻击代码上传到服务器上,再由 Apache Commons Collections 里的 TransformedMap 来执行

如何解决这个漏洞?

很多序列化协议都制定了一套数据结构来保存和获取对象。例如,JSON 序列化、ProtocolBuf 等,它们只支持一些基本类型和数组数据类型,这样可以避免反序列化创建一些不确定的实例。虽然它们的设计简单,但足以满足当前大部分系统的数据传输需求。我们也可以通过反序列化对象白名单来控制反序列化对象,可以重写 resolveClass 方法,并在该方法中校验对象名字

序列化后的流太大

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

序列化性能太差

序列化的速度也是体现序列化性能的重要指标,如果序列化的速度慢,就会影响网络通信的效率,从而增加系统的响应时间

各种序列化技术

序列化只是一种思想,将序列化转换为不同的二进制流就会产生不同的技术,常见的技术有:XML 序列化框架、JSON 序列化框架、Hessian 序列化框架、Avro 序列化、kyro 序列化框架 和 Protobuf 序列化框架

XML 序列化框架

👍优点:可读性好,方便阅读和调试

😣缺点:序列化以后字节码比较大;效率不高

✨使用场景

  • 性能不高,QPS 较低的企业级内部系统之间的数据交换场景;
  • 用于异构系统之间的数据交换和协议

📚实现方式

  • XStream
  • Java 自带的 XML 序列化和反序列化

JSON 序列化框架

👍优点:传输的字节流更小,可读性非常好

📚实现方式

  • Jackson
  • FastJson(😅谁用谁知道)
  • Gson

🆚常用框架对比

  • Jackson 与 fastjson 要比 GSON 的性能要好
  • Jackson、GSON 的稳定性要比 Fastjson 好
  • FastJson 调用的 API 非常容易使用

Hessian 序列化框架

✨Hessian 序列化特点

  • 跨语言传输的二进制序列化协议
  • 比 Java 默认的序列化机制有着更好的性能和易用性

📚Dubbo 就是使用重构后Hessian序列化机制,达到更高的性能

Avro 序列化

✨Avro 序列化特点

  • 支持大批量数据交换的应用
  • 支持二进制序列化方式
  • 可以便捷,快速地处理大量数据
  • 动态语言优化

kyro 序列化框架

✨Kryo 是一种非常成熟的序列化实现,已经在 Hive、 Storm 中使用得比较广泛,不过它不能跨语言

📚Dubbo 已经在 2.6 版本支持 kyto 序列化机制,它的性能要优于Hessian2

Protobuf 序列化框架

Protobuf 是由 Google 推出且支持多语言的序列化框架,目前在主流网站上的序列化框架性能对比测试报告中,Protobuf 无论是编解码耗时,还是二进制流压缩大小,都名列前茅

✨Protobuf 序列化框架特点

  • Protobuf 是 Google 的一种数据交换格式,它独立于语言、独立于平台
  • Protobuf 是一个纯粹的表示层协议,可以和各种传输层协议一起使用
  • Protobuf 使用比较广泛,主要是空间开销小和性能比较好,非常适合用于公司内部对性能要求高的 RPC 调用
  • 序列化以后数据量相对较少,也可以应用在对象的持久化场景中

😣protobuf 有个缺点就是要传输的每一个类的结构都要生成对应的 proto 文件,如果某个类发生修改,还得重新生成该类对应的 proto 文件

序列化的技术选型

👴选型建议

  • 对性能要求不高的场景,可以采用基于 XML 的 SOAP 协议
  • 对性能和间接性有比较高要求的场景,那么 Hessian、 Protobuf、 Thrift、 Avro 都可以
  • 基于前后端分离,或者独立的对外的 api 服务,选用 JSON 是比较好的,对于调试、可读性都很不错
  • 动态类型语言使用 Avro

附录

菜鸟教程
廖雪峰教程
为什么我不建议你使用 Java 序列化
transient 关键字