序列化
一、系列化的意义
简单来说,系列化就是把对象的状态信息转化为可存储或传输的形式过程,也就是把对象转换为字节系列的过程成为系列化。而反序列化是序列化的逆向过程,把字节数组反序列化为对象,把字节序列恢复为对象的过程成为对象的反序列化 。
系列化的挑战
评价一个系列化算法优劣的两个重要指标是:系列化之后的数据大小;系列化操作本身的速度以及系统资源开销(CPU、内存)
二、java中的系列化
首先,代码演示下Java中的系列化
实现接口方法
测试一下
系列化反系列化成功
serialVersionUID 的作用
Java 的序列化机制是通过判断类的 serialVersionUID 来验证版本一致性的。在进行反序列化时, JVM 会把传来的字节流中的 serialVersionUID与本地相应实体类的 serialVersionUID 进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是 InvalidCastException如果没有为指定的 class 配置 serialVersionUID,那么 java 编译器会自动给这个 class 进行一个摘要算法,类似于指纹算法,只要这个文件有任何改动,得到的 UID 就会截然不同的,可以保证在这么多类中,这个编号是唯一的。
serialVersionUIDu有两种生成方式,一是默认的1L,比如: private static final long serialVersionUID = 1L;二是根据类名、接口名、成员方法及属性等来生成一个 64 位的哈希字段。
当 实 现 java.io.Serializable 接 口 的 类 没 有 显 式 地 定 义 一 个serialVersionUID 变量时候, Java 序列化机制会根据编译的 Class 自动生成一个 serialVersionUID 作序列化版本比较用,这种情况下,如果Class 文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次, serialVersionUID 也不会变化的。
静态变量系列化:静态变量不会参与系列化。
关于父类的系列化:
1. 当一个父类没有实现序列化时,子类继承该父类并且实现了序列化。在反序列化该子类后,是没办法获取到父类的属性值的。
2. 当一个父类实现序列化,子类自动实现序列化,不需要再显示实现Serializable 接口。
3. 当一个对象的实例变量引用了其他对象,序列化该对象时也会把引用对象进行序列化,但是前提是该引用对象必须实现序列化接口。
transient关键字:被transient修饰的成员变量不会参与系列化。但是我们也可以绕开这个机制:
这里我们思考一个问题,这两个私有方法为什么在系列化时会被调用。这是因为ObjectOutputStream使用了反射来寻找是否声明了这两个方法。因为 ObjectOutputStream使用 getPrivateMethod,所以这些方法必须声明为 priate 以至于供ObjectOutputStream 来使用。
系列化的存储规则
当我们对同一对象系列化两次时,内容并不会叠加。Java 序列化机制为了节省磁盘空间,具有特定的存储规则,当写入文件的为同一对象时,并不会再将对象的内容进行存储,而只是再次存储一份引用,第二次系列化增加的字节的存储空间就是新增引用和一些控制信息的空间。反序列化时,恢复引用关系.该存储规则极大的节省了存储空间。
系列化实现深克隆
在Java中,克隆分为浅克隆和深克隆
浅克隆:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。
示例:
查看输出结果,两个用户的Email内容变为一样了,这是因为浅克隆只复制了对象的引用,即引用的是同一个Email,所以我们看到了这样的结果。这并不是与我们的想要的结果,所以我们需要深克隆。
深克隆
被复制对象的所有变量都含有与原来的对象相同的值,除去那些引用其他对象的变量。那些引用其他对象的变量将指向被复制过的新对象,而不再是原有的那些被引用的对象。换言之,深拷贝把要复制的对象所引用的对象都复制了一遍。
而java中的系列化正好可以实现深克隆:
在User类中新增一个deepClone方法:
根据运行结果,已经实现了我们预期的效果。即实现了深克隆。
三、常见的系列化技术java系列化:
优点: JAVA 语言本身提供,使用比较方便和简单。
缺点:不支持跨语言处理、 性能相对不是很好,序列化以后产生的数据相对较大 。
Xml系列化框架:
XML 序列化的好处在于可读性好,方便阅读和调试。但是序列化以后的字节码文件比较大,而且效率不高,适用于对性能不高,而且 QPS 较低的企业级内部系统之间的数据交换的场景,同时 XML 又具有语言无关性,所以还可以用于异构系统之间的数据交换和协议。比如我们熟知的 Webservice,就是采用 XML 格式对数据进行序列化的。
JSON系列化框架
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,相对于 XML 来说, JSON 的字节流更小,而且可读性也非常好。现在 JSON数据格式在企业运用是最普遍的 。
JSON 序列化常用的开源工具有很多
1. Jackson (https://github.com/FasterXML/jackson)
2. 阿里开源的 FastJson (https://github.com/alibaba/fastjon)
3. Google 的 GSON (https://github.com/google/gson)
这几种 json 序列化工具中, Jackson 与 fastjson 要比 GSON 的性能要好,但是 Jackson、 GSON 的稳定性要比 Fastjson 好。而 fastjson 的优势在于提供的 api 非常容易使用 。
Hessian 序列化框架
Hessian 是一个支持跨语言传输的二进制序列化协议,相对于 Java 默认的序列化机制来说, Hessian 具有更好的性能和易用性,而且支持多种不同的语言。实际上 Dubbo 采用的就是 Hessian 序列化来实现,只不过 Dubbo 对Hessian 进行了重构,性能更高 。
Protobuf 序列化框架
Protobuf是 Google的一种数据交换格式,它独立于语言、独立于平台。
Google 提供了多种语言来实现,比如 Java、 C、 Go、 Python,每一种实现都包含了相应语言的编译器和库文件Protobuf 使用比较广泛,主要是空间开销小和性能比较好,非常适合用于公司内部对性能要求高的 RPC 调用。 另外由于解析性能比较高,序列化以后数据量相对较少,所以也可以应用在对象的持久化场景中但是但是要使用 Protobuf 会相对来说麻烦些,因为他有自己的语法,有自己的编译器。
protobuf使用
安装protobuf 工具
编写proto文件
1. 包名
2. option 选项
3. 消息模型(消息对象、字段(字段修饰符-required/optional/repeated)字段类型(基本数据类型、枚举、消息对象)、字段名、 标识号)。
生成实体类
在 protoc.exe 安装目录下执行如下命令
编写代码测试,在这之前需要引入proto包依赖:
查看输出,系列化成功。
四、Protobuf原理分析
核心原理:protobuf使用varint(zigzag)作为编码方式,使用T-L-V作为存储方式
varint编码方式
varint 是一种数据压缩算法,其核心思想是利用 bit 位来实现数据压缩。 比如: 对于 int32 类型的数字,一般需要 4 个字节 表示; 若采用Varint 编码,对于很小的 int32 类型 数字,则可以用 1 个字节。假设我们定义了一个 int32 字段值=296
第一步,转换为二进制编码
第二步,提取字节
规则:按照从字符串末尾选取7位,并在最高位补1,构成一个字节
第三步,继续提取字节
整体右移 7 位,继续截取 7 个比特位,并且在最高位补 0 。因为这个是最后一个有意义的字节了。补 0 不影响结果
第四步,拼接成一个新的字符串
将原来用 4 个字节表示的整数,经过 varint 编码以后只需要 2 个字节了。
varint编码对于小于127(2^8-1)的数,可以最大化压缩,例:
我们压缩一个var32=104的数据
1.转换为2进制编码
2.提取字节
从末尾开始提取 7 个字节并且在最高位最高位补 0,因为这个是最后的 7 位。
3.形成新的字节
也就是通过 varint对于小于 127以下的数字编码,只需要占用1个字节 。
zigzag编码方式
对于负数的处理, protobuf 使用 zigzag 的形式来存储。 为什么负数需要用 zigzag 算法?
首先我们了解下计算机语言如何表示负整数
在计算机中,定义了原码、反码和补码。来实现负数的表示。我们以一个字节 8 个 bit 来演示这几个概念数字 8 的二进制表示为 0000 1000。
原码
通过第一个位表示符号(0 表示非负数、 1 表示负数)
(+8) = {0000 1000}
(-8) = {1000 1000}
反码
因为第一位表示符号位,保持不变。剩下的位,非负数保持不变、负数按位取反。那对于上面的原码按照这个规则得到的结果
(+8) = {0000 1000}原 ={0000 1000}反 非负数,剩下的位不变。所以和原码是保持一致
(-8) = {1000 1000}原 ={1111 0111} 反 负数,符号位不动,剩下为取反
但是通过原码和反码方式来表示二进制,还存在一些问题。
第一个问题:
0 这个数字,按照上面的反码计算,会存在两种表示
(+0) ={0000 0000}原= {0000 0000}反
(-0) ={1000 0000}原= {1111 1111}反
第二个问题:
符号位参与运算,会得到一个错误的结果,比如
1 + (-1)=
{0000 0001}原 +{1 0000 0001}原 ={1000 0010}原 =-2
不管是原码计算还是反码计算。得到的结果都是错误的。所以为了解决这个问题,引入了补码的概念。
补码
补码的概念:第一位符号位保持不变,剩下的位非负数保持不变,负数按位取反且末位加 1。
(+8) = {0000 1000}原 = {0000 1000}原 ={0000 1000} 补
(-8) = {1000 1000}原 ={1111 0111} 反={1111 1000}末位加一(补码)
8+(-8) = {0000 1000} 补 +{1111 1000}末位加一(补码) ={0000 0000}=0
通过补码的方式,在进行符号运算的时候,计算机就不需要关心符号的问题,统一按照这个规则来计算。就没问题。
zigzag原理
比如存储-2,首先将-2 的二进制形式{1111 1110} 按照正数的算法, 左移一位, 右边补零得到{11111100},如下图左边。 按照负数的形式, 讲符号位移动到最右边,右移 31 位, 得到下面右图。 再将两者取异或算法。 实现最终的压缩。
然后进行异或运算
存储方式
经过编码以后的数据,大大减少了字段值的占用字节数,然后基于 T-LV 的方式进行存储
tag 的取值为 field_number(字段数) << 3 | wire_type,296 被 varint 编码后的字节为 10101000 00000010 。
总结
Protocol Buffer 的性能好,主要体现在 序列化后的数据体积小 & 序列化速度快,最终使得传输效率高,其原因如下:
序列化速度快的原因:
a. 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)
b. 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成序列化后的数据量体积小(即数据压缩效果好)的原因:
c. 采用了独特的编码方式,如 Varint、 Zigzag 编码方式等等
d 采用 T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑
各个序列化技术的性能比较,参考网址https://github.com/eishay/jvm-serializers/wiki
序列化技术的选型
技术层面
1. 序列化空间开销,也就是序列化产生的结果大小,这个影响到传输的性能。
2. 序列化过程中消耗的时长,序列化消耗时间过长影响到业务的响应时间。
3. 序列化协议是否支持跨平台,跨语言。因为现在的架构更加灵活,如果存在异构系统通信需求,那么这个是必须要考虑的。
4. 可扩展性/兼容性,在实际业务开发中,系统往往需要随着需求的快速迭代来实现快速更新,这就要求我们采用的序列化协议基于良好 的可扩展性/兼容性,比如在现有的序列化数据结构中新增一个业务字段,不会影响到现有的服务。
5. 技术的流行程度,越流行的技术意味着使用的公司多,那么很多坑都已经淌过并且得到了解决,技术解决方案也相对成熟。
6. 学习难度和易用性。
选型建议
1. 对性能要求不高的场景,可以采用基于 XML 的 SOAP 协议。
2. 对性能和间接性有比较高要求的场景,那么 Hessian、Protobuf、Thrift、Avro 都可以。
3. 基于前后端分离,或者独立的对外的 api 服务,选用 JSON 是比较好的,对于调试、可读性都很不错。
4. Avro 设计理念偏于动态类型语言,那么这类的场景使用 Avro 是可以的。