这篇文章主要给大家讲解序列化和反序列化。
序列化是网络通信中非常重要的一个机制,好的序列化方式能够直接影响数据传输的性能。
序列化
所谓的序列化,就是把一个对象,转化为某种特定的形式,然后以数据流的方式传输。
比如把一个对象直接转化为二进制数据流进行传输。当然这个对象可以转化为其他形式之后再转化为数据流。
比如XML、JSON等格式。它们通过另外一种数据格式表达了一个对象的状态,然后再把这些数据转化为二进制数据流进行网络传输。
反序列化
反序列化是序列化的逆向过程,把字节数组反序列化为对象,把字节序列恢复为对象的过程成为对象的反序列化
序列化的高阶认识
前面的代码中演示了,如何通过JDK提供了Java对象的序列化方式实现对象序列化传输,主要通过输出流java.io.ObjectOutputStream和对象输入流java.io.ObjectInputStream来实现。
java.io.ObjectOutputStream:表示对象输出流 , 它的writeObject(Object obj)方法可以对参数指定的obj对象进行序列化,把得到的字节序列写到一个目标输出流中。
java.io.ObjectInputStream:表示对象输入流 ,它的readObject()方法源输入流中读取字节序列,再把它们反序列化成为一个对象,并将其返回
需要注意的是,被序列化的对象需要实现java.io.Serializable接口
serialVersionUID的作用
在IDEA中通过如下设置可以生成serializeID,如图5-1所示
字面意思上是序列化的版本号,凡是实现Serializable接口的类都有一个表示序列化版本标识符的静态变量。
下面演示一下serialVersionUID的作用。首先需要创建一个普通的spring boot项目,然后按照下面的步骤来进行演示
创建User对象
public class User implements Serializable {
private static final long serialVersionUID = -8826770719841981391L;
private String name;
private int age;
}
编写Java序列化的代码
public class JavaSerializer {
public static void main(String[] args) {
User user=new User();
user.setAge(18);
user.setName("Mic");
serialToFile(user);
System.out.println("序列化成功,开始反序列化");
User nuser=deserialFromFile();
System.out.println(nuser);
}
private static void serialToFile(User user){
try {
ObjectOutputStream objectOutputStream=
new ObjectOutputStream(new FileOutputStream(new File("user")));
objectOutputStream.writeObject(user);
} catch (IOException e) {
e.printStackTrace();
}
}
private static T deserialFromFile(){
try {
ObjectInputStream objectInputStream=new ObjectInputStream(new FileInputStream(new File("user")));
return (T)objectInputStream.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}
}
UID验证演示步骤
- 先将user对象序列化到文件中
- 然后修改user对象,增加serialVersionUID字段
- 然后通过反序列化来把对象提取出来
- 演示预期结果:提示无法反序列化
结论
Java的序列化机制是通过判断类的serialVersionUID来验证版本一致性的。在进行反序列化时,JVM会把传来的字节流中的serialVersionUID与本地相应实体类的serialVersionUID进行比较,如果相同就认为是一致的,可以进行反序列化,否则就会出现序列化版本不一致的异常,即是InvalidCastException。
从结果可以看出,文件流中的class和classpath中的class,也就是修改过后的class,不兼容了,出于安全机制考虑,程序抛出了错误,并且拒绝载入。从错误结果来看,如果没有为指定的class配置serialVersionUID,那么java编译器会自动给这个class进行一个摘要算法,类似于指纹算法,只要这个文件有任何改动,得到的UID就会截然不同的,可以保证在这么多类中,这个编号是唯一的。所以,由于没有显指定 serialVersionUID,编译器又为我们生成了一个UID,当然和前面保存在文件中的那个不会一样了,于是就出现了2个序列化版本号不一致的错误。因此,只要我们自己指定了serialVersionUID,就可以在序列化后,去添加一个字段,或者方法,而不会影响到后期的还原,还原后的对象照样可以使用,而且还多了方法或者属性可以用。
tips: serialVersionUID有两种显示的生成方式:
一是默认的1L,比如:private static final long serialVersionUID = 1L;
二是根据类名、接口名、成员方法及属性等来生成一个64位的哈希字段
当实现java.io.Serializable接口的类没有显式地定义一个serialVersionUID变量时候,Java序列化机制会根据编译的Class自动生成一个serialVersionUID作序列化版本比较用,这种情况下,如果Class文件(类名,方法明等)没有发生变化(增加空格,换行,增加注释等等),就算再编译多次,serialVersionUID也不会变化的。
Transient关键字
Transient 关键字的作用是控制变量的序列化,在变量声明前加上该关键字,可以阻止该变量被序列化到文件中,在被反序列化后,transient 变量的值被设为初始值,如 int 型的是 0,对象型的是 null。
如果我们希望User类中的name字段不序列化,则按照以下方案进行修改。
修改User类
public class User implements Serializable {
private static final long serialVersionUID = -8826770719841981391L;
private transient String name;
private int age;
}
测试效果
public class JavaSerializer {
public static void main(String[] args) {
User user=new User();
user.setAge(18);
user.setName("Mic");
serialToFile(user);
System.out.println("序列化成功,开始反序列化");
User nuser=deserialFromFile();
System.out.println(nuser.getName()); //打印反序列化的结果,发现结果是NULL.
}
}
绕开transient机制
在User类中重写writeObject和readObject方法。
public class User implements Serializable {
private static final long serialVersionUID = -8826770719841981391L;
private transient String name;
private int age;
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(name);//增加写入name字段
}
private void readObject(ObjectInputStream in) throws Exception{
in.defaultReadObject();
name=(String)in.readObject();
}
}
这两个方法是在ObjectInputStream和ObjectOutputStream中,分别反序列化和序列化对象时反射调用目标对象中的这两个方法。
序列化的总结
- Java序列化只是针对对象的状态进行保存,至于对象中的方法,序列化不关心
- 当一个父类实现了序列化,那么子类会自动实现序列化,不需要显示实现序列化接口
- 当一个对象的实例变量引用了其他对象,序列化这个对象的时候会自动把引用的对象也进行序列化(实现深度克隆)
- 当某个字段被申明为transient后,默认的序列化机制会忽略这个字段
- 被申明为transient的字段,如果需要序列化,可以添加两个私有方法:writeObject和readObject
常见的序列化技术及优劣分析
随着分布式架构、微服务架构的普及。服务与服务之间的通信成了最基本的需求。这个时候,我们不仅需要考虑通信的性能,也需要考虑到语言多元化问题
所以,对于序列化来说,如何去提升序列化性能以及解决跨语言问题,就成了一个重点考虑的问题。
由于Java本身提供的序列化机制存在两个问题
- 序列化的数据比较大,传输效率低
- 其他语言无法识别和对接
以至于在后来的很长一段时间,基于XML格式编码的对象序列化机制成为了主流,一方面解决了多语言兼容问题,另一方面比二进制的序列化方式更容易理解。
以至于基于XML的SOAP协议及对应的WebService框架在很长一段时间内成为各个主流开发语言的必备的技术。
再到后来,基于JSON的简单文本格式编码的HTTP REST接口又基本上取代了复杂的Web Service接口,成为分布式架构中远程通信的首要选择。
但是JSON序列化存储占用的空间大、性能低等问题,同时移动客户端应用需要更高效的传输数据来提升用户体验。在这种情况下与语言无关并且高效的二进制编码协议就成为了大家追求的热点技术之一。
首先诞生的一个开源的二进制序列化框架-MessagePack。它比google的Protocol Buffers出现得还要早。
XML序列化框架介绍
XML序列化的好处在于可读性好,方便阅读和调试。但是序列化以后的字节码文件比较大,而且效率不高,适用于对性能不高,而且QPS较低的企业级内部系统之间的数据交换的场景,同时XML又具有语言无关性,所以还可以用于异构系统之间的数据交换和协议。比如我们熟知的Webservice,就是采用XML格式对数据进行序列化的。XML序列化/反序列化的实现方式有很多,熟知的方式有XStream和Java自带的XML序列化和反序列化两种。
引入jar包
com.thoughtworks.xstream
xstream
1.4.12
编写测试程序
public class XMLSerializer {
public static void main(String[] args) {
User user=new User();
user.setName("Mic");
user.setAge(18);
String xml=serialize(user);
System.out.println("序列化完成:"+xml);
User nuser=deserialize(xml);
System.out.println(nuser);
}
private static String serialize(User user){
return new XStream(new DomDriver()).toXML(user);
}
private static User deserialize(String xml){
return (User)new XStream(new DomDriver()).fromXML(xml);
}
}
JSON序列化框架
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,相对于XML来说,JSON的字节流更小,而且可读性也非常好。现在JSON数据格式在企业运用是最普遍的
JSON序列化常用的开源工具有很多
- Jackson (https://github.com/FasterXML/...)
- 阿里开源的FastJson (https://github.com/alibaba/fa...)
- Google的GSON (https://github.com/google/gson)
这几种json序列化工具中,Jackson与fastjson要比GSON的性能要好,但是Jackson、GSON的稳定性要比Fastjson好。而fastjson的优势在于提供的api非常容易使用
引入jar包
com.alibaba
fastjson
1.2.72
编写测试程序
public class JsonSerializer{
public static void main(String[] args) {
User user=new User();
user.setName("Mic");
user.setAge(18);
String xml=serializer(user);
System.out.println("序列化完成:"+xml);
User nuser=deserializer(xml);
System.out.println(nuser);
}
private static String serializer(User user){
return JSON.toJSONString(user);
}
private static User deserializer(String json){
return (User)JSON.parseObject(json,User.class);
}
}
Hessian序列化
Hessian是一个支持跨语言传输的二进制序列化协议,相对于Java默认的序列化机制来说,Hessian具有更好的性能和易用性,而且支持多种不同的语言
实际上Dubbo采用的就是Hessian序列化来实现,只不过Dubbo对Hessian进行了重构,性能更高
引入jar包
com.caucho
hessian
4.0.63
编写测试程序
public class HessianSerializer {
public static void main(String[] args) throws IOException {
User user=new User();
user.setName("Mic");
user.setAge(18);
byte[] bytes=serializer(user);
System.out.println("序列化完成");
User nuser=deserializer(bytes);
System.out.println(nuser);
}
private static byte[] serializer(User user) throws IOException {
ByteArrayOutputStream bos=new ByteArrayOutputStream(); //表示输出到内存的实现
HessianOutput ho=new HessianOutput(bos);
ho.writeObject(user);
return bos.toByteArray();
}
private static User deserializer(byte[] data) throws IOException {
ByteArrayInputStream bis=new ByteArrayInputStream(data);
HessianInput hi=new HessianInput(bis);
return (User)hi.readObject();
}
}
Avro序列化
Avro是一个数据序列化系统,设计用于支持大批量数据交换的应用。它的主要特点有:支持二进制序列化方式,可以便捷,快速地处理大量数据;动态语言友好,Avro提供的机制使动态语言可以方便地处理Avro数据。
Avro是apache下hadoop的子项目,拥有序列化、反序列化、RPC功能。序列化的效率比jdk更高,与Google的protobuffer相当,比facebook开源Thrift(后由apache管理了)更优秀。
因为avro采用schema,如果是序列化大量类型相同的对象,那么只需要保存一份类的结构信息+数据,大大减少网络通信或者数据存储量。
引入jar包
org.apache.avro
avro
1.8.2
org.apache.avro
avro-ipc
1.8.2
org.springframework.boot
spring-boot-maven-plugin
org.apache.avro
avro-maven-plugin
1.8.2
schemas
generate-sources
schema
${project.basedir}/src/main/avro
${project.basedir}/src/main/java
编写avsc文件
创建/src/main/avro目录,专门用来存储Avrode scheme定义文件。
{
"namespace":"com.gupao.example",
"type":"record",
"name":"Person",
"fields":[
{"name":"name","type":"string"},
{"name":"age","type":"int"},
{"name":"sex","type":"string"}
]
}
avsc文件中的语法定义如下:
- namespace:命名空间,在使用插件生成代码的时候,User类的包名就是它
- type:有 records, enums, arrays, maps, unions , fixed 取值,records是相当于普通的class
- name:类名,类的全名有namespace+name构成
- doc:注释
- aliases:取的别名,其他地方使用可以使用别名来引用
fields:属性
- name:属性名
- type:属性类型,可以是用["int","null"]或者["int",1]执行默认值
- default:也可以使用该字段指定默认值
- doc:注释
生成代码
执行maven install
,
会在main/java目录下生成Person类。
编写测试程序
public class AvroSerializer {
public static void main(String[] args) throws IOException {
Person person=Person.newBuilder().setName("Mic").setAge(18).setSex("男").build();
ByteBuffer byteBuffer=person.toByteBuffer(); //序列化
System.out.println("序列化大小:"+byteBuffer.array().length);
Person nperson=Person.fromByteBuffer(byteBuffer);
System.out.println("反序列化:"+nperson);
}
}
下面这种方式是基于文件的形式来实现序列化和反序列化
public class AvroSerializer {
public static void main(String[] args) throws IOException {
Person person=Person.newBuilder().setName("Mic").setAge(18).setSex("男").build();
/* ByteBuffer byteBuffer=person.toByteBuffer(); //序列化
System.out.println("序列化大小:"+byteBuffer.array().length);
Person nperson=Person.fromByteBuffer(byteBuffer);
System.out.println("反序列化:"+nperson);*/
DatumWriter personDatumWriter=new SpecificDatumWriter<>(Person.class);
DataFileWriter dataFileWriter=new DataFileWriter<>(personDatumWriter);
dataFileWriter.create(person.getSchema(),new File("person.avro"));
dataFileWriter.append(person);
dataFileWriter.close();
System.out.println("序列化成功.....");
DatumReader personDatumReader=new SpecificDatumReader<>(Person.class);
DataFileReader dataFileReader=new DataFileReader(new File("person.avro"),personDatumReader);
Person nper=dataFileReader.next();
System.out.println(nper);
}
}
kyro序列化框架
Kryo是一种非常成熟的序列化实现,已经在Hive、Storm)中使用得比较广泛,不过它不能跨语言. 目前dubbo已经在2.6版本支持kyro的序列化机制。它的性能要优于之前的hessian2
zookeeper中使用jute作为序列化
Protobuf序列化
Protobuf是Google的一种数据交换格式,它独立于语言、独立于平台。Google提供了多种语言来实现,比如Java、C、Go、Python,每一种实现都包含了相应语言的编译器和库文件,Protobuf是一个纯粹的表示层协议,可以和各种传输层协议一起使用。
Protobuf使用比较广泛,主要是空间开销小和性能比较好,非常适合用于公司内部对性能要求高的RPC调用。 另外由于解析性能比较高,序列化以后数据量相对较少,所以也可以应用在对象的持久化场景中
但是要使用Protobuf会相对来说麻烦些,因为他有自己的语法,有自己的编译器,如果需要用到的话必须要去投入成本在这个技术的学习中
protobuf有个缺点就是要传输的每一个类的结构都要生成对应的proto文件,如果某个类发生修改,还得重新生成该类对应的proto文件
使用protobuf开发的一般步骤是
- 配置开发环境,安装protocol compiler代码编译器
- 编写.proto文件,定义序列化对象的数据结构
- 基于编写的.proto文件,使用protocol compiler编译器生成对应的序列化/反序列化工具类
- 基于自动生成的代码,编写自己的序列化应用
安装protobuf编译工具
- https://github.com/google/pro... 找到 protoc-3.5.1-win32.zip
编写proto文件
syntax="proto2"; package com.gupao.example; option java_outer_classname="UserProtos"; message User { required string name=1; required int32 age=2; }
数据类型说明如下:
- string / bytes / bool / int32(4个字节)/int64/float/double
- enum 枚举类
- message 自定义类
修饰符
- required 表示必填字段
- optional 表示可选字段
- repeated 可重复,表示集合
- 1,2,3,4需要在当前范围内是唯一的,表示顺序
生成实例类,在cmd中运行如下命令
protoc.exe --java_out=./ ./User.proto
实现序列化
com.google.protobuf
protobuf-java
3.12.2
编写测试代码.
public class ProtobufSerializer {
public static void main(String[] args) throws InvalidProtocolBufferException {
UserProtos.User user=UserProtos.User.newBuilder().setName("Mic").setAge(18).build();
ByteString bytes=user.toByteString();
System.out.println(bytes.toByteArray().length);
UserProtos.User nUser=UserProtos.User.parseFrom(bytes);
System.out.println(nUser);
}
}
Protobuf序列化原理解析
我们可以把序列化以后的数据打印出来看看结果
public static void main(String[] args) {
UserProtos.User user=UserProtos.User.newBuilder().
setAge(300).setName("Mic").build();
byte[] bytes=user.toByteArray();
for(byte bt:bytes){
System.out.print(bt+" ");
}
}
10 3 77 105 99 16 -84 2
我们可以看到,序列化出来的数字基本看不懂,但是序列化以后的数据确实很小,那我们接下来带大家去了解一下底层的原理
正常来说,要达到最小的序列化结果,一定会用到压缩的技术,而protobuf里面用到了两种压缩算法,一种是varint,另一种是zigzag
varint
先说第一种,我们先来看【Mic】是怎么被压缩的
“Mic”这个字符,需要根据ASCII对照表转化为数字。
M =77、i=105、c=99
所以结果为 77 105 99
大家肯定有个疑问,这里的结果为什么直接就是ASCII编码的值呢?怎么没有做压缩呢?有没有同学能够回答出来
原因是,varint是对字节码做压缩,但是如果这个数字的二进制只需要一个字节表示的时候,其实最终编码出来的结果是不会变化的。 如果出现需要大于一个字节的方式来表示,则需要进行压缩。
比如,我们设置的age=300, 这里需要2个字节来存储。那看一下它是如何被压缩的。
300如何被压缩
这两个字节字节分别的结果是:-84 、2
-84怎么计算来的呢? 我们知道在二进制中表示负数的方法,高位设置为1, 并且是对应数字的二进制取反以后再计算补码表示(补码是反码+1)
所以如果要反过来计算
- 【补码】10101100 -1 得到 10101011
- 【反码】01010100 得到的结果为84. 由于高位是1,表示负数所以结果为-84
存储格式
protobuf采用T-L-V作为存储方式
tag的计算方式是 field_number(当前字段的编号) << 3 | wire_type
比如Mic的字段编号是1 ,类型wire_type的值为 2 所以 : 1 <<3 | 2 =10
age=300的字段编号是2,类型wire_type的值是0, 所以 : 2<<3|0 =16
所以按照T-L-V的格式,第一个字段为name,所以它的数据为 {10} {3} {77 105 99},第二个字段为age ,{16} {2} {-82 2}
5.5.3 负数的存储方式
在计算机中,负数会被表示为很大的整数,因为计算机定义负数符号位为数字的最高位,所以如果采用varint编码表示一个负数,那么一定需要5个比特位。所以在protobuf中通过sint32/sint64类型来表示负数,负数的处理形式是先采用zigzag编码(把符号数转化为无符号数),在采用varint编码。
sint32:(n << 1) ^ (n >> 31)
sint64:(n << 1) ^ (n >> 63)
比如存储一个(-300)的值。
修改proto原始文件
message User { required string name=1; required int32 age=2; required sint32 status=3; //增加一个sint的字段 }
设置一个值
UserProtos.User user=UserProtos.User.newBuilder().setAge(300).setName("Mic").setStatus(-300).build();
- 此时的输出结果:
10 3 77 105 99 16 -84 2 24 -41 4
我们发现,针对于负数类型,压缩出来的数据是不一样的,这里采用的编码方式是zigzag的编码,再采用varint进行编码压缩。
比如存储一个(-300)的值
-300
原码:0001 0010 1100
取反:1110 1101 0011
加1 :1110 1101 0100
n<<1: 整体左移一位,右边补0 -> 1101 1010 1000
n>>31: 整体右移31位,左边补1 -> 1111 1111 1111
n<<1 ^ n >>31
1101 1010 1000 ^ 1111 1111 1111 = 0010 0101 0111
十进制: 0010 0101 0111 = 599
这样做的目的,是消除高位的1,从而形成一个可以被压缩的数据。针对599再采用varint进行编码。
varint算法: 从右往做,选取7位,高位补1/0(取决于字节数)
得到两个字节
1101 0111 0000 0100
-41 、 4
5.5.4 总结
Protocol Buffer的性能好,主要体现在 序列化后的数据体积小 & 序列化速度快,最终使得传输效率高,其原因如下:
序列化速度快的原因:
a. 编码 / 解码 方式简单(只需要简单的数学运算 = 位移等等)
b. 采用 Protocol Buffer 自身的框架代码 和 编译器 共同完成
序列化后的数据量体积小(即数据压缩效果好)的原因:
a. 采用了独特的编码方式,如Varint、Zigzag编码方式等等
b. 采用T - L - V 的数据存储方式:减少了分隔符的使用 & 数据存储得紧凑
序列化技术选型
技术层面
- 序列化空间开销,也就是序列化产生的结果大小,这个影响到传输的性能
- 序列化过程中消耗的时长,序列化消耗时间过长影响到业务的响应时间
- 序列化协议是否支持跨平台,跨语言。因为现在的架构更加灵活,如果存在异构系统通信需求,那么这个是必须要考虑的
- 可扩展性/兼容性,在实际业务开发中,系统往往需要随着需求的快速迭代来实现快速更新,这就要求我们采用的序列化协议基于良好的可扩展性/兼容性,比如在现有的序列化数据结构中新增一个业务字段,不会影响到现有的服务
- 技术的流行程度,越流行的技术意味着使用的公司多,那么很多坑都已经淌过并且得到了解决,技术解决方案也相对成熟
- 学习难度和易用性
选型建议
- 对性能要求不高的场景,可以采用基于XML的SOAP协议
- 对性能和间接性有比较高要求的场景,那么Hessian、Protobuf、Thrift、Avro都可以。
- 基于前后端分离,或者独立的对外的api服务,选用JSON是比较好的,对于调试、可读性都很不错
- Avro设计理念偏于动态类型语言,那么这类的场景使用Avro是可以的
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自Mic带你学架构
!
如果本篇文章对您有帮助,还请帮忙点个关注和赞,您的坚持是我不断创作的动力。欢迎关注同名微信公众号获取更多技术干货!