序列化:
序列化以及编解码属于七层网络中的表示层
序列化和反序列化的选型却是系统设计或重构一个重要的环节,在分布式、大数据量系统设计里面更为显著。恰当的序列化协议不仅可以提高系统的通用性、强健性、安全性、优化系统性能,而且会让系统更加易于调试、便于扩展
典型的序列化和反序列化过程往往需要如下组件:
分别从通用性、易用性、可扩展性、性能和数据类型与Java语法支持五方面给出对比测试。
下面提到的都是基于二进制的序列化协议,像 JSON 和 XML这种属于文本类序列化方式。虽然 JSON 和 XML可读性比较好,但是性能较差,一般不会选择。
市面上常见开源二进制序列化方式:
JDK Serializable、FST、Kryo、Protobuf、Thrift、Hession和Avro
阿里云已经对其进行过了测试,详见几种Java常用序列化框架的选型与对比
三 总结
1 通用性
下面是从通用性上对比各个序列化框架,可以看出Protobuf在通用上是最佳的,能够支持多种主流变成语言。
2 易用性
下面是从API使用的易用性上面来对比各个序列化框架,可以说除了JDK Serializer外的序列化框架都提供了不错API使用方式。
3 可扩展性
下面是各个序列化框架的可扩展性对比,可以看到Protobuf的可扩展性是最方便、自然的。其它序列化框架都需要一些配置、注解等操作。
4 性能
序列化大小对比
对比各个序列化框架序列化后的数据大小如下,可以看出kryo preregister(预先注册序列化类)和Avro序列化结果都很不错。所以,如果在序列化大小上有需求,可以选择Kryo或Avro。
序列化时间开销对比
下面是序列化与反序列化的时间开销,kryo preregister和fst preregister都能提供优异的性能,其中fst pre序列化时间就最佳,而kryo pre在序列化和反序列化时间开销上基本一致。所以,如果序列化时间是主要的考虑指标,可以选择Kryo或FST,都能提供不错的性能体验。
5 数据类型和语法结构支持
各序列化框架对Java数据类型支持的对比:
注:集合类型测试基本覆盖了所有对应的实现类。
List测试内容:ArrayList、LinkedList、Stack、CopyOnWriteArrayList、Vector。
Set测试内容:HashSet、LinkedHashSet、TreeSet、CopyOnWriteArraySet。
Map测试内容:HashMap、LinkedHashMap、TreeMap、WeakHashMap、ConcurrentHashMap、Hashtable。
Queue测试内容:PriorityQueue、ArrayBlockingQueue、LinkedBlockingQueue、ConcurrentLinkedQueue、SynchronousQueue、ArrayDeque、LinkedBlockingDeque和ConcurrentLinkedDeque。
下面根据测试总结了以上序列化框架所能支持的数据类型、语法。
注1:static内部类需要实现序列化接口。
注2:外部类需要实现序列化接口。
注3:需要在Lambda表达式前添加(IXxx & Serializable)。
由于Protobuf、Thrift是IDL定义类文件,然后使用各自的编译器生成Java代码。IDL没有提供定义staic内部类、非static内部类等语法,所以这些功能无法测试。
实现的两种序列化
从上面的比较结果来看kryo在序列化和反序列化的时间开销上占优,因此对于java栈来说如果追求极致的性能可以采用 kryo,但是kryo的通用性上并不佳,并不支持其他语言,没有定义语言描述接口IDL。同时扩展性也不佳。
如果需要通用性更好的 跨语言调用的可以采用protocol buffer。
@Slf4j
public class KryoSerializer implements Serializer {
/**
* Because Kryo is not thread safe. So, use ThreadLocal to store Kryo objects
*/
private final ThreadLocal<Kryo> kryoThreadLocal = ThreadLocal.withInitial(() -> {
Kryo kryo = new Kryo();
kryo.register(RpcResponse.class);
kryo.register(RpcRequest.class);
return kryo;
});
@Override
public byte[] serialize(Object obj) {
try (ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Output output = new Output(byteArrayOutputStream)) {
Kryo kryo = kryoThreadLocal.get();
// Object->byte:将对象序列化为byte数组
kryo.writeObject(output, obj);
kryoThreadLocal.remove();
return output.toBytes();
} catch (Exception e) {
throw new SerializeException("Serialization failed");
}
}
@Override
public <T> T deserialize(byte[] bytes, Class<T> clazz) {
try (ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
Input input = new Input(byteArrayInputStream)) {
Kryo kryo = kryoThreadLocal.get();
// byte->Object:从byte数组中反序列化出对对象
Object o = kryo.readObject(input, clazz);
kryoThreadLocal.remove();
return clazz.cast(o);
} catch (Exception e) {
throw new SerializeException("Deserialization failed");
}
}
}
ps: kyro不是线程安全的 所以使用 ThreadLocal进行包装
jdk原生序列化需要实现Serializable接口,才能被jdk自己的序列化机制序列化,jdk序列化的时候,会将这个类和他的所有超类都元数据,类描述,属性,属性值等等信息都序列化出来,这样就导致序列化后的大小比较大,速度也会比较慢,但是包含的内容最全面。可以完全反序列化。
Kryo是怎么节约时间和空间开销的?
1.序列化的时候,会将对象的信息,对象属性值的信息等进行序列化,而且没有将类field的描述信息进行序列化,这样就比jdk自己的序列化出来的小多了,而且速度肯定更快,但是包含的信息没有jdk的全面。
2.可变长编码
类似下图
kryo快的原因详见源码分析kryo对象序列化实现原理
简单来说:
1、先序列化类型(Class实例),然后根据类型返回相应的序列化器(上一篇详细介绍了各种类型的序列化器)。
2、再序列化该类型的值。
3、如果自定义类型,例如(cn.uce.demo.Student),则返回的值序列化器为DefaultSerializers$FieldSerializer,然后一个字段一个字段的序列化,当然其序列化类型也是,先类型再值的模式,递归进行,最终完成。
4、引入了对象图的概念来消除循环依懒的序列化,已序列化的对象,在循环引用时,只是用一个int类型来表示该对象值,类似一种缓存的概念。
原理:序列化的时候,也是将对象的信息,属性值信息等进行序列化,也会比jdk自己的序列化后的小很多,但是没有kryo的小,速度也挺快,类似下图。
1、Kryo序列化后比Hessian小很多。(kryo优于hessian)
2、由于Kryo没有将类field的描述信息序列化,所以Kryo需要以自己加载该类的filed。这意味着如果该类没有在kryo中注册,或者该类是第一次被kryo序列化时,kryo需要时间去加载该类(hessian优于kryo)
3、由于2的原因,如果该类已经被kryo加载过,那么kryo保存了其类的信息,就可以很快的将byte数组填入到类的field中,而hessian则需要解析序列化后的byte数组中的field信息,对于序列化过的类,kryo优于hessian。
4、hessian使用了固定长度存储int和long,而kryo则使用的变长,实际中,很大的数据不会经常出现。(kryo优于hessian)
5、hessian将序列化的字段长度写入来确定一段field的结束,而kryo对于String将其最后一位byte+x70用于标识结束(kryo优于hessian)
对于第二点 kryo也给出了解决方案:
那就是提供手动注册:
Kryo kryo = new Kryo();
kryo.register(SomeClass.class);
// ...
Output output = ...
SomeClass someObject = ...
kryo.writeObject(output, someObject);
这里,SomeClass 注册到了 Kryo,它将该类与一个 int 型的 ID 相关联。当 Kryo 写出 SomeClass 的一个实例时,它会写出这个 int ID。这比写出类名更有效。在反序列化期间,注册的类必须具有序列化期间相同的 ID 。上面展示的注册方法分配下一个可用的最小整数 ID,这意味着类被注册的顺序十分重要。注册时也可以明确指定特定 ID,这样的话注册顺序就不重要了:
Kryo kryo = new Kryo();
kryo.register(SomeClass.class, 10);
kryo.register(AnotherClass.class, 11);
kryo.register(YetAnotherClass.class, 12);
另一点是支持循环引用
kryo.setReferences(true);// 支持循环引用
还有需要注意的就是 Kryo 对循环引用的支持。References即引用,对A对象序列化时,默认情况下 Kryo会在每个成员对象第一次序列化时写入一个数字,该数字逻辑上就代表了对该成员对象的引用,如果后续有引用指向该成员对象,则直接序列化之前存入的数字即可,而不需要再次序列化对象本身。
而 “循环引用” 是指,假设有一个 JavaBean,假设是一个销售订单(SalesOrder),这个订单下面有很多子订单,比如 List ,而销售子订单中又有其中一个包括一个销售订单,那么这就构成了”循环引用”。Kryo 默认是支持循环引用的,当你确定不会有循环引用发生的时候,可以通过 kryo.setReferences(false); 关闭循环引用检测,从而提高一些性能。关闭后虽然序列化速度更快,但是遇到循环引用,就会报 “栈内存溢出” 错误。
所以,我并不认为关闭它是一件好的选择,大多数情况下,请保持kryo.setReferences(true)。
在学习dubbo的时候看到,dubbo默认的序列化方式是 hessian2序列化( hessian是一种跨语言的高效二进制序列化方式。但这里实际不是原生的hessian2序列化,而是阿里修改过的hessian lite,它是dubbo RPC默认启用的序列化方式),而再看dubbox的时候看到dubbox 引入Kryo和FST这两种高效Java序列化实现,来逐步取代hessian2。( 其中,Kryo是一种非常成熟的序列化实现,已经在Twitter、Groupon、Yahoo以及多个著名开源项目(如Hive、Storm)中广泛的使用。
ProtocalBuffer为了类的扩展性,会先将类的变异成指定的IDL
message Person {
string name = 1;
int32 id = 2;
}
由于Protobuf的易用性,它的哥哥 Protostuff 诞生了。
protostuff 基于Google protobuf,但是提供了更多的功能和更简易的用法。虽然更加易用,但是不代表 ProtoStuff 性能更差。
这里直接使用了java已有的工具类protostuff 不用去编写IDL
public class ProtostuffSerializer implements Serializer {
/**
* Avoid re applying buffer space every time serialization
*/
private static final LinkedBuffer BUFFER = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
@Override
public byte[] serialize(Object obj) {
Class<?> clazz = obj.getClass();
Schema schema = RuntimeSchema.getSchema(clazz);
byte[] bytes;
try {
bytes = ProtostuffIOUtil.toByteArray(obj, schema, BUFFER);
} finally {
BUFFER.clear();
}
return bytes;
}
@Override
public <T> T deserialize(byte[] bytes, Class<T> clazz) {
Schema<T> schema = RuntimeSchema.getSchema(clazz);
T obj = schema.newMessage();
ProtostuffIOUtil.mergeFrom(bytes, obj, schema);
return obj;
}
}
原理详见 Protocol Buffer原理解密