目录
2.4.1 Kryo在即时响应模式下要对Input/Output进行池化管理
2.4.4 实例化构建策略的选择(同时支持缺失无参构造函数,及无参构造函数中有额外初始化逻辑)
2.5.2 默认不支持HashMap及ArrayList等集合类的处理策略
简介
序列化就是将对象转化为字节序列的过程,反序列化则是将字节序列转化为对象的过程。序列化框架广泛的应用在分布式系统通信、存储等相关场景中。例如在网络通信中,不同的计算机进行相互通信主要的方式就是将数据流从一台机器传输给另外一台计算机,当客户端将需要请求的数据封装好了之后就需要序列化为二进制格式再转换为流进行传输,当服务端接收到流之后再将数据解析为二进制格式的内容,再按照约定好的协议进行反序列化解析。最常见的场景就是rpc远程调用的时候,对发送数据和接收数据时候的处理。常见的序列化和反序列化方式很多,目前在java体系中就有许多比较成熟的序列化框架,例如基于二进制的序列化框架就有java内置提供序列化机制, hessian, kryo, protobuffer, protostuff等,基于明文json格式的有jackson及fastjson等,如何选择是重点也是难点,对于不同的场景往往会有不同的答案,不限场景的最优序列化框架是不存在的,否则也就不会出现这么多形形色色的序列化框架了。
常见的序列化框架性能测试对比,往往对象选取比较简单,且偏向于批量的处理而非即时响应。因此我们选取的应用场景是在分布式强一致性系统(CP系统)中的场景,在这种场景下,集群中的机器之间的消息特点是高并发,大量的小消息,且需要单条消息过来便即时响应(非批量处理),且请求与响应的消息类型比较复杂繁多,综合来看对序列化框架的性能的对比是比较全面的。基于明文json/xml格式的序列化框架由于其序列化后码流偏大,且性能偏低,更多的适合于不同系统之间的服务调用场景,并不适合于同构分布式系统之间的通信场景,因此我们主要考察基于二进制的序列化框架。
本文限定在java体系中选择序列化框架,因此对于多语言支持方面就不予以考虑,主要对比限定在衡量序列化框架性能的指标上,包括:
- 压缩后的字节码大小
- 序列化及反序列化的速度(单线程、多线程)
- 资源占用
另外还包括支持的功能及使用的难易程度,例如:
- 是否支持多线程并发
- 对于一些集合类是否支持
- 对于缺失无参构造函数的对象是否支持
- 对于向前兼容性的支持
接下来,我们先简单介绍一个java体系下各个序列化框架,然后选取其中最典型的几个构建为序列化器(及其各自的优化注意事项),接着说明一下我们构建的消息实体类型及测试应用场景,并对比各序列化框架在其中的性能表现。
1 Java体系下常见的序列化框架简介
1.1 Java内置序列化框架
jdk自身便带有序列化的功能,Java序列化API允许我们将一个对象转换为流,并通过网络发送,或将其存入文件或数据库以便未来使用,反序列化则是将对象流转换为实际程序中使用的Java对象的过程。通过ObjectOutputStream和ObjectInputStream来实现,序列化类需要实现Serializable接口。
其优点为:java内置,使用简单,在一定程度上多线程可以取得接近线性的吞吐量提升;
缺点也很明显:序列化性能欠佳,序列化后的码流过大。
1.2 Hessian
Hessian是一款支持多种语言进行序列化操作的框架技术,同时在进行序列化之后产生的码流也较小,处理数据的性能方面远超于java内置的jdk序列化方式。
Hessian序列化比Java序列化高效很多,而且生成的字节流也要短很多。但相对来说没有Java序列化可靠,而且也不如Java序列化支持的全面。
1.3 Kryo
Kryo是一种非常成熟的序列化实现,已经在Twitter、Groupon、 Yahoo以及多个著名开源项目(如Hive、Storm)中广泛的使用,它的性能在各个方面都比hessian2要优秀很多。另外,Kryo可以把对象信息直接写到序列化数据里,反序列化的时候可以精确地找到原始类信息,不会出错,这意味着在写readxxx方法时,无需传入Class或Type类信息。
Kryo使用了变长存储特性并借助于字节码生成机制,使其拥有较高的运行速度和较小的体积。其对int和long类型都采用了可变长存储的机制,以int为例,一般需要4个字节去存储,而对kryo来说,可以通过1-5个变长字节去存储,从而避免高位都是0的浪费。最多需要5个字节存储是因为,在变长存储int过程中,一个字节的8位用来存储有效数字的只有7位,最高位用于标记是否还需读取下一个字节,1表示需要,0表示不需要。在对string的存储中也有变长存储的应用,string序列化的整体结构为length+内容,那么length也会使用变长int写入字符的长度。
1.4 Protobuffer/Protostuff
google protobuffer是一个灵活的、高效的用于序列化数据的协议。相比较XML和JSON格式,protobuffer更小、更快、更便捷。protobuffer是跨语言的,并且自带了一个编译器(protoc),只需要用它进行编译,可以编译成Java、python、C++、C#、Go等代码,然后就可以直接使用,不需要再写其他代码,自带有解析的代码。protobuffer需要预先编写.proto IDL文件,再通过protobuffer提供的编译器生成对应于各种语言的代码。
对于Java体系来说,由于Java具有反射和动态代码生成的能力,上述protobuffer的预编译过程不是必需的,可以在代码执行时实现。protostuff就是实现此功能的框架,基于protobuffer,实现了无须预编译就能对JavaBean进行序列化/反序列化能力。protostuff是一个基于protobuffer实现的序列化方法,它较于protobuffer最明显的好处是,在几乎不损耗性能的情况下做到了不用我们写.proto文件来实现序列化。
2 各序列化器的构建及优化方案
接下来我们选取了Java内置序列化、Hessian、Kryo和Protostuff几款序列化实现来构建序列化器并在选定的应用场景中对比各自支持的功能及性能(详情可参见代码https://github.com/hantangwangd/serializerForRaft)
2.1 序列化器接口定义
首先定义序列化器的接口如下:
public interface MySerializer {
<T extends Serializable> byte[] encode(T obj) throws Exception;
<T extends Serializable> T decode(byte[] buf, Class<T> cls) throws Exception;
}
2.2 Java内置序列化框架
Java内置序列化框架使用非常简单,不需要注意什么,原生就支持多线程,无需也无法对用到的stream做池化管理(虽然性能很一般),其构造序列化器代码如下:
public class JavaSerializer implements MySerializer {
@Override
public <T extends Serializable> byte[] encode(T obj) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
ObjectOutputStream oo = new ObjectOutputStream(out);
try {
oo.writeObject(obj);
oo.flush();
byte[] buffer = out.toByteArray();
return buffer;
} finally {
oo.close();
}
}
@Override
public <T extends Serializable> T decode(byte[] buf, Class<T> cls) throws Exception {
ObjectInputStream ii = new ObjectInputStream(new ByteArrayInputStream(buf));
T res = (T)ii.readObject();
ii.close();
return res;
}
}
2.3 Hessian
Hessian使用同样比较简单,原生支持多线程,需要maven中引入相应类库
<dependency>
<groupId>com.caucho</groupId>
<artifactId>hessian</artifactId>
<version>4.0.51</version>
</dependency>
其构造序列化器代码如下:
public class HessianSerializer implements MySerializer {
@Override
public <T extends Serializable> byte[] encode(T obj) throws Exception {
if (obj == null){
throw new NullPointerException();
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
HessianOutput ho = new HessianOutput(out);
try{
ho.writeObject(obj);
ho.flush();
return out.toByteArray();
} catch(Exception ex){
throw new RuntimeException("HessianSerializeUtil序列化发生异常!" + ex);
} finally {
ho.close();
}
}
@SuppressWarnings("unchecked")
@Override
public <T extends Serializable> T decode(byte[] buf, Class<T> cls) throws Exception {
if (buf == null){
throw new NullPointerException();
}
ByteArrayInputStream bis = new ByteArrayInputStream(buf);
HessianInput hi = new HessianInput(bis);
try{
return (T)hi.readObject();
} catch(Exception ex){
throw new RuntimeException("HessianSerializeUtil反序列化发生异常!" + ex);
} finally {
hi.close();
}
}
}
2.4 Kryo
Kryo包括了写完整类和预注册类只写ID两种使用模式,在构造器中使用了两个不同的构造函数来构造,首先引入其依赖的类库:
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>4.0.2</version>
</dependency>
接着其实现代码如下:
public class KryoSerializer implements MySerializer {
public static final int DEFAULT_BUFFER_SIZE = 4096;
KryoPool pool;
private final KryoOutputPool kryoOutputPool = new KryoOutputPool();
private final KryoInputPool kryoInputPool = new KryoInputPool();
boolean preRegisterClass = false;
public KryoSerializer() {
pool = new KryoPool.Builder(() -> {
final Kryo kryo = new Kryo();
kryo.setInstantiatorStrategy(new Kryo.DefaultInstantiatorStrategy(
new StdInstantiatorStrategy()));
kryo.setReferences(false); // 关闭循环引用检查
return kryo;
}).softReferences().build();
}
public KryoSerializer(boolean preRegisterClass) {
this.preRegisterClass = preRegisterClass;
pool = new KryoPool.Builder(() -> {
final Kryo kryo = new Kryo();
kryo.setInstantiatorStrategy(new Kryo.DefaultInstantiatorStrategy(
new StdInstantiatorStrategy()));
kryo.setReferences(false); //关闭循环引用检查
if (preRegisterClass) {
KryoRegisterUtils.register(kryo);
}
return kryo;
}).softReferences().build();
}
@Override
public <T extends Serializable> byte[] encode(T obj) throws Exception {
return kryoOutputPool.run(output -> {
return pool.run(kryo -> {
kryo.writeClassAndObject(output, obj);
output.flush();
return output.getByteArrayOutputStream().toByteArray();
});
}, DEFAULT_BUFFER_SIZE);
}
@Override
public <T extends Serializable> T decode(byte[] buf, Class<T> cls) throws Exception {
return kryoInputPool.run(input -> {
input.setInputStream(new ByteArrayInputStream(buf));
return pool.run(kryo -> {
@SuppressWarnings("unchecked")
T obj = (T) kryo.readClassAndObject(input);
return obj;
});
}, DEFAULT_BUFFER_SIZE);
}
}
2.4.1 Kryo在即时响应模式下要对Input/Output进行池化管理
如果不做池化管理,由于其Input/Output创建及回收的开销,导致其只擅长于批量序列化及反序列化,对于大量的单条消息就要响应的场景并不擅长。将Input/Output池化管理及不做池化的性能差距会非常大(感兴趣的同学可以自己试验一下),构建的资源池代码如下:
首先定义资源池的抽象类,其中使用模板方法模式定义了公共执行逻辑,并将具体的构建与回收相应资源定义为两个抽象方法,供具体子类来实现:
public abstract class SerializerIOPool<T> {
private final Queue<SoftReference<T>> queue = new ConcurrentLinkedQueue<>();
private T borrow(final int bufferSize) {
T element;
SoftReference<T> reference;
while ((reference = queue.poll()) != null) {
if ((element = reference.get()) != null) {
return element;
}
}
return create(bufferSize);
}
protected abstract T create(final int bufferSize);
protected abstract boolean recycle(final T element);
<R extends Serializable> R run(final Function<T, R> function, final int bufferSize) {
final T element = borrow(bufferSize);
try {
return function.apply(element);
} finally {
if (recycle(element)) {
queue.offer(new SoftReference<>(element));
}
}
}
}
KryoInputPool输入资源池继承了KryoIOPool抽象资源池,用于管理kryo的Input实例,其中实现了Input的具体创建与回收方法:
public class KryoInputPool extends SerializerIOPool<Input> {
static final int MAX_POOLED_BUFFER_SIZE = 512 * 1024;
@Override
protected Input create(int bufferSize) {
return new Input(bufferSize);
}
@Override
protected boolean recycle(Input input) {
if (input.getBuffer().length < MAX_POOLED_BUFFER_SIZE) {
input.setInputStream(null);
return true;
}
return false; // discard
}
}
KryoOutputPool输出资源池继承了KryoIOPool抽象资源池,用于管理kryo的Output实例,其中实现了Output的具体创建与回收方法:
public class KryoOutputPool extends SerializerIOPool<ByteArrayOutput> {
private static final int MAX_BUFFER_SIZE = 768 * 1024;
static final int MAX_POOLED_BUFFER_SIZE = 512 * 1024;
@Override
protected ByteArrayOutput create(int bufferSize) {
return new ByteArrayOutput(bufferSize, MAX_BUFFER_SIZE, new BufferAwareByteArrayOutputStream(bufferSize));
}
@Override
protected boolean recycle(ByteArrayOutput output) {
if (output.getByteArrayOutputStream().getBufferSize() < MAX_POOLED_BUFFER_SIZE) {
output.getByteArrayOutputStream().reset();
output.clear();
return true;
}
return false; // discard
}
}
2.4.2 Kryo多线程并发支持
Kryo本身并不是线程安全的,因此要想支持多线程并发,要么自己使用ThreadLocal或者代码控制每个线程持有一个独立的kryo实例,要么使用Kryo提供的KryoPool(如上述代码所示)
2.4.3 明确指定类注册id以支持集群模式
当Kryo写一个对象的实例的时候,默认需要将类的完全限定名称写入。将类名一同写入序列化数据中是比较低效的,所以Kryo支持通过类注册进行优化:
kryo.register(OpenSessionRequest.class);
kryo.register(ReadConsistency.class);
kryo.register(AppendRequest.class);
kryo.register(ConfigurationEntry.class);
注册会给每一个class一个int类型的Id相关联,这显然比类名称高效,但同时要求反序列化的时候的Id必须与序列化过程中一致。这意味着注册的顺序非常重要。但是由于现实原因,同样的代码,同样的Class在不同的机器上注册编号仍然不能保证一致,所以多机器部署时候反序列化可能会出现问题。所以Kryo默认会禁止类注册,当然如果想要打开这个属性,可以通过kryo.setRegistrationRequired(true)或显示注册某一个类来打开。
由于我们是在集群中使用,因此不能接受不同机器上由于注册顺序不一致导致的反序列化失败,但是若采用将类的完全限定名写入,又会影响到序列化后的码流大小及序列化/反序列化性能,因此,我们可以采用明确指定注册类型的ID的方式,确保在集群上的任何一处序列化与反序列时类的注册ID的一致性(如上述代码所示):
int id = BEGIN_USER_CUSTOM_ID;
kryo.register(OpenSessionRequest.class, id++);
kryo.register(ReadConsistency.class, id++);
kryo.register(AppendRequest.class, id++);
kryo.register(ConfigurationEntry.class, id++);
2.4.4 实例化构建策略的选择(同时支持缺失无参构造函数,及无参构造函数中有额外初始化逻辑)
DefaultInstantiatorStrategy策略会使用默认的无参构造函数来实例化对象,因此对于没有默认无参构造函数的对象,使用DefaultInstantiatorStrategy时就会抛出异常;而StdInstantiatorStrategy在是依据JVM version信息及JVM vendor信息创建对象的,可以不调用对象的任何构造方法创建对象。但是在碰到如下TestBuffer这样的对象时,就会出问题:
public TestBuffer() {
this.buffer = new byte[DEFAULT_SIZE];
}
由于没有调用构造器,那么这里this.buffer就不会初始化,因此在后续操作中就会出现问题。要想调和这两种问题,可以像前面代码中写的一样,显示指定实例化器,首先使用默认无参构造策略DefaultInstantiatorStrategy,若创建对象失败再采用StdInstantiatorStrategy:
kryo.setInstantiatorStrategy(new Kryo.DefaultInstantiatorStrategy(
new StdInstantiatorStrategy()));
2.4.5 向前兼容性支持
Kryo默认并不支持向前兼容,但提供了CompatibleFieldSerializer来支持向前兼容,通过在对应实体类型上侵入式的打标注,指定使用CompatibleFieldSerializer以支持其字段的变化:
@DefaultSerializer(CompatibleFieldSerializer.class)
或者直接设置kryo的属性来开启:
kryo.setDefaultSerializer(CompatibleFieldSerializer::new)
此时在kryo.writeClassAndObject时候写入的信息如下:
class name|field length|field1 name|field2 name|field1 value| filed2 value
而在读入kryo.readClassAndObject时,会先读入field names,然后匹配当前反序列化类的field和顺序再构造结果。
2.5 Protostuff
Protostuff的使用稍微复杂些,需要自己管理(缓存)对应实体类型的schema,并编码支持集合类型等等。使用Protostuff需要首先引入如下类库:
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>4.0.2</version>
</dependency>
<dependency>
<groupId>my.test</groupId>
<artifactId>memorymeasure</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.29</version>
</dependency>
<dependency>
<groupId>com.caucho</groupId>
<artifactId>hessian</artifactId>
<version>4.0.51</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-api</artifactId>
<version>1.6.0</version>
</dependency>
<dependency>
<groupId>io.protostuff</groupId>
<artifactId>protostuff-collectionschema</artifactId>
<version>1.6.0</version>
</dependency>
<!-- Objenesis -->
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.1</version>
</dependency>
然后构建序列化器的代码如下:
public class ProtostuffSerializer implements MySerializer {
private static final Set<Class<?>> WRAPPER_SET = new HashSet<>();
/**
* 序列化/反序列化包装类 Class 对象
*/
private static final Class<SerializeDeserializeWrapper> WRAPPER_CLASS = SerializeDeserializeWrapper.class;
/**
* 序列化/反序列化包装类 Schema 对象
*/
private static final Schema<SerializeDeserializeWrapper> WRAPPER_SCHEMA = RuntimeSchema.createFrom(WRAPPER_CLASS);
/**
* 缓存对象及对象schema信息集合
*/
private static final Map<Class<?>, Schema<?>> CACHE_SCHEMA = new HashMap<>(64);
// private static final ClassInstanceEnhancer instanceCreator = new ClassInstanceEnhancer();
private static final Objenesis instanceCreator = new ObjenesisStd(true);
/**
* 构建LinkedBuffer资源管理池
* */
private static final ProtostuffLinkedBufferPool linkedBufferPool = new ProtostuffLinkedBufferPool();
/**
* 预定义一些Protostuff无法直接序列化/反序列化的对象
*/
static {
WRAPPER_SET.add(List.class);
WRAPPER_SET.add(ArrayList.class);
WRAPPER_SET.add(CopyOnWriteArrayList.class);
WRAPPER_SET.add(LinkedList.class);
WRAPPER_SET.add(Stack.class);
WRAPPER_SET.add(Vector.class);
WRAPPER_SET.add(Map.class);
WRAPPER_SET.add(HashMap.class);
WRAPPER_SET.add(TreeMap.class);
WRAPPER_SET.add(Hashtable.class);
WRAPPER_SET.add(SortedMap.class);
WRAPPER_SET.add(Map.class);
WRAPPER_SET.add(Object.class);
}
/**
* 注册需要使用包装类进行序列化/反序列化的 Class 对象
*
* @param clazz 需要包装的类型 Class 对象
*/
public static void registerWrapperClass(Class<?> clazz) {
WRAPPER_SET.add(clazz);
}
/**
* 获取序列化对象类型的schema
*
* @param cls 序列化对象的class
* @param <T> 序列化对象的类型
* @return 序列化对象类型的schema
*/
@SuppressWarnings({"unchecked"})
private static <T> Schema<T> getSchema(Class<T> cls) {
Schema schema = CACHE_SCHEMA.get(cls);
if (schema == null) {
synchronized (CACHE_SCHEMA) {
schema = CACHE_SCHEMA.computeIfAbsent(cls, k -> RuntimeSchema.createFrom(k));
}
}
return schema;
}
@SuppressWarnings("unchecked")
@Override
public <T extends Serializable> byte[] encode(T obj) throws Exception {
return encodeWithPool(obj);
}
@Override
public <T extends Serializable> T decode(byte[] buf, Class<T> cls) throws Exception {
try {
if (!WRAPPER_SET.contains(cls)) {
T message = instanceCreator.newInstance(cls);
Schema<T> schema = getSchema(cls);
ProtostuffIOUtil.mergeFrom(buf, message, schema);
return message;
} else {
SerializeDeserializeWrapper<T> wrapper = new SerializeDeserializeWrapper<>();
ProtostuffIOUtil.mergeFrom(buf, wrapper, WRAPPER_SCHEMA);
return wrapper.getData();
}
} catch (Exception e) {
throw new IllegalStateException("反序列化对象异常 [" + cls.getName() + "]", e);
}
}
private <T extends Serializable> byte[] encodeWithPool(T obj) throws Exception {
return linkedBufferPool.run(buffer -> {
Class<T> clazz = (Class<T>) obj.getClass();
try {
Object serializeObject = obj;
Schema schema = WRAPPER_SCHEMA;
if (!WRAPPER_SET.contains(clazz)) {
schema = getSchema(clazz);
} else {
serializeObject = SerializeDeserializeWrapper.builder(obj);
}
return ProtostuffIOUtil.toByteArray(serializeObject, schema, buffer);
} catch (Exception e) {
throw new IllegalStateException("序列化对象异常 [" + obj + "]", e);
}
}, LinkedBuffer.DEFAULT_BUFFER_SIZE);
}
private <T extends Serializable> byte[] encodeWithoutPool(T obj) throws Exception {
LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
Class<T> clazz = (Class<T>) obj.getClass();
try {
Object serializeObject = obj;
Schema schema = WRAPPER_SCHEMA;
if (!WRAPPER_SET.contains(clazz)) {
schema = getSchema(clazz);
} else {
serializeObject = SerializeDeserializeWrapper.builder(obj);
}
return ProtostuffIOUtil.toByteArray(serializeObject, schema, buffer);
} catch (Exception e) {
throw new IllegalStateException("序列化对象异常 [" + obj + "]", e);
} finally {
buffer.clear();
}
}
}
2.5.1 支持缺失无参构造函数的对象
Protostuff自身并不处理对象的实例化,而是直接根据实例化后的对象进行反序列化并填充,因此我们自己需要编写支持无无参构造函数的对象的实例化,借助于sun.reflect.ReflectionFactory实现一个增强对象构造器,当缺失无参构造函数时使用ReflectionFactory创造一个构造器Constructor来完成对象实例化,代码如下:
public class ClassInstanceEnhancer {
@SuppressWarnings("restriction")
private final ReflectionFactory REFLECTION_FACTORY = ReflectionFactory.getReflectionFactory();
private final ConcurrentHashMap<Class<?>, Constructor<?>> _constructors =
new ConcurrentHashMap<Class<?>, Constructor<?>>();
public <T> T newInstance(Class<T> type) {
try {
Constructor<?> constructor = _constructors.get(type);
if (constructor != null) {
return (T)newInstanceFrom(constructor);
} else {
return type.newInstance();
}
} catch (Exception e) {
return (T) newInstanceFromReflectionFactory(type);
}
}
private Object newInstanceFrom(Constructor<?> constructor) {
try {
return constructor.newInstance();
} catch (final Exception e) {
throw new RuntimeException(e);
}
}
@SuppressWarnings("unchecked")
private <T> T newInstanceFromReflectionFactory(Class<T> type) {
Constructor<?> constructor = _constructors.get(type);
if (constructor == null) {
synchronized (_constructors) {
constructor = newConstructorForSerialization(type);
_constructors.put(type, constructor);
}
}
return (T) newInstanceFrom(constructor);
}
@SuppressWarnings("restriction")
private <T> Constructor<?> newConstructorForSerialization(
Class<T> type) {
try {
Constructor<?> constructor = REFLECTION_FACTORY
.newConstructorForSerialization(type,
Object.class.getDeclaredConstructor());
constructor.setAccessible(true);
return constructor;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
或者,也可以使用第三方类库objenesis,在pom.xml中引入依赖:
<!-- Objenesis -->
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.1</version>
</dependency>
然后初始化一个Objenesis对象:
private static Objenesis objenesis = new ObjenesisStd(true);
并在代码中调用类似于如下方法:
AppendRequest ar = objenesis.newInstance(AppendRequest.class);
创建对应的实例,其会选择最合适的实例化方式来实例化指定的类型。经测试,该类库的性能要大大优于我们上面自行构建的ClassInstanceEnhancer的。
2.5.2 默认不支持HashMap及ArrayList等集合类的处理策略
Protostuff默认并不直接支持HashMap及ArrayList等集合类,但是其支持对象属性中的集合类型。因此当想要序列化及反序列化这些集合类型时,需要借助于一个封装类Wrapper,将其封装为该Wrapper类型的属性进行序列化/反序列化,定义的封装类代码如下:
public class SerializeDeserializeWrapper<T> {
private T data;
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public static <T> SerializeDeserializeWrapper<T> builder(T data) {
SerializeDeserializeWrapper<T> wrapper = new SerializeDeserializeWrapper<>();
wrapper.setData(data);
return wrapper;
}
}
并在初始化时添加默认不支持的类型
/**
* 预定义一些Protostuff无法直接序列化/反序列化的对象
*/
static {
WRAPPER_SET.add(List.class);
WRAPPER_SET.add(ArrayList.class);
WRAPPER_SET.add(CopyOnWriteArrayList.class);
WRAPPER_SET.add(LinkedList.class);
WRAPPER_SET.add(Stack.class);
WRAPPER_SET.add(Vector.class);
WRAPPER_SET.add(Map.class);
WRAPPER_SET.add(HashMap.class);
WRAPPER_SET.add(TreeMap.class);
WRAPPER_SET.add(Hashtable.class);
WRAPPER_SET.add(SortedMap.class);
WRAPPER_SET.add(Map.class);
WRAPPER_SET.add(Object.class);
}
当发现要序列化的类型属于这些类型时,就使用SerializeDeserializeWrapper将其封装并序列化;而在反序列化时,将对应的byte数组反序列化为SerializeDeserializeWrapper对象,并通过getData()得到实际的对象返回。
2.5.3 LinkedBuffer池化管理
经验证Protostuff使用的LinkedBuffer在不做池化处理时,自己也能work的很好:当资源(内存)充足时(例如默认-Xmx4g -Xms1g时)候性能基本上没有什么区别。但是在限定了内存资源后,(例如-Xmx512 -Xms512,甚至更有夸张些-Xmx256 -Xms256),观察其执行期间的gc次数及开销,还是可以发现使用了池化管理之后内存应用情况要好些(表现为gc次数更少)。与之相对应的,自然是序列化/反序列化性能表现会好些。
资源池的抽象类与kryo使用的相同,都是SerializerIOPool,代码参见前面。然后具体的ProtostuffLinkedBufferPool实现类代码如下:
public class ProtostuffLinkedBufferPool extends SerializerIOPool<LinkedBuffer> {
@Override
protected LinkedBuffer create(int bufferSize) {
return LinkedBuffer.allocate(bufferSize > 0 ? bufferSize : LinkedBuffer.DEFAULT_BUFFER_SIZE);
}
@Override
protected boolean recycle(LinkedBuffer buffer) {
buffer.clear();
return true;
}
}
其中实现了LinkedBuffer的创建及回收的具体逻辑。在使用中,如上述ProtostuffSerializer中所示,初始化一个资源池:
/**
* 构建LinkedBuffer资源管理池
* */
private static final ProtostuffLinkedBufferPool linkedBufferPool = new ProtostuffLinkedBufferPool();
并在资源池中运行具体的序列化逻辑:
return linkedBufferPool.run(buffer -> {
Class<T> clazz = (Class<T>) obj.getClass();
try {
Object serializeObject = obj;
Schema schema = WRAPPER_SCHEMA;
if (!WRAPPER_SET.contains(clazz)) {
schema = getSchema(clazz);
} else {
serializeObject = SerializeDeserializeWrapper.builder(obj);
}
return ProtostuffIOUtil.toByteArray(serializeObject, schema, buffer);
} catch (Exception e) {
throw new IllegalStateException("序列化对象异常 [" + obj + "]", e);
}
}, LinkedBuffer.DEFAULT_BUFFER_SIZE);
经测试,在对序列化过程使用的LinkedBuffer应用了资源池管理之后,能在一定程度上改善内存的使用情况。
3 测试应用场景说明
由于大部分的关于序列化框架的测试都是使用的较简单的大量相同结构的数据,因此随着选取数据结构的不同各序列化框架的性能表现也无法真实反应。在此将一个生产环境中真实的强一致分布式CP系统的整个消息体系抽离出来作为基础数据,来整体测试一下各个序列化框架的综合性能,看一看在真实场景种类繁多的的消息体系下各个序列化框架的实际表现如何。既然是分布式CP系统,其消息特点是:大部分小消息和小部分的大消息;即时响应而非批量处理(也就是说每收到一条消息都会执行反序列化->处理->序列化再响应),对响应时间及并发吞吐量都有较高的要求,除了序列化/反序列化性能外,由于网络带宽限制同样需要考虑序列化后的码流大小。
3.1 消息类型数据结构
数据结构如下图所示(详情可参见代码https://github.com/hantangwangd/serializerForRaft)
整个系统的消息可分为Request和Response两部分,以AppendRequest为例,其代码截取部分如下:
public class AppendRequest extends AbstractRaftRequest {
/**
*
*/
private static final long serialVersionUID = 1231231231L;
private final long term;
private final String leader;
private final long prevLogIndex;
private final long prevLogTerm;
private final List<RaftLogEntry> entries;
private final long commitIndex;
public AppendRequest(long term, String leader, long prevLogIndex, long prevLogTerm, List<RaftLogEntry> entries, long commitIndex) {
this.term = term;
this.leader = leader;
this.prevLogIndex = prevLogIndex;
this.prevLogTerm = prevLogTerm;
this.entries = entries;
this.commitIndex = commitIndex;
}
......
}
其中的RaftLogEntry是用于持久化到本地操作日志系统中的操作记录(可以理解为WAL),用于状态机应用操作、确保一致性及可靠性恢复等,其具体类型如下图所示:
以其中的KeepAliveEntry为例,其代码截取部分如下:
public abstract class RaftLogEntry implements Serializable {
/**
*
*/
private static final long serialVersionUID = 1231231232L;
protected final long term;
public RaftLogEntry(long term) {
this.term = term;
}
......
}
public class TimestampedEntry extends RaftLogEntry {
protected final long timestamp;
public TimestampedEntry(long term, long timestamp) {
super(term);
this.timestamp = timestamp;
}
......
}
public class KeepAliveEntry extends TimestampedEntry {
private final long[] sessionIds;
private final long[] commandSequences;
private final long[] eventIndexes;
public KeepAliveEntry(long term, long timestamp, long[] sessionIds, long[] commandSequences, long[] eventIndexes) {
super(term, timestamp);
this.sessionIds = sessionIds;
this.commandSequences = commandSequences;
this.eventIndexes = eventIndexes;
}
......
}
此处需要说明的是,对于分布式消息而言,一个比较好的代码味道(smell)是将消息的属性设置为final不可变,从代码上强调分布式系统中的消息的不可变性(Java作为一款“自带注释”的编程语言,其优势也部分体现于此)。但是如此一来,就会导致其无参构造函数没有存在的意义,因此就造成了缺失无参构造函数的情况。(此处需要特别注意,因为有些序列化框架是基于无参构造函数来工作的,此时要么修改消息的定义,加上无参构造函数; 要么修改/加强对应的序列化框架,使其支持缺失无参构造函数的情况)
3.2 测试场景说明及测试结果
为了评估各序列化框架在实际应用场景中的功能及性能综合表现如何,分别选取了以下的测试场景进行测试。
3.2.1 可用性及序列化后二进制码流大小
针对上述定义的所有请求/响应消息及其关联实体,使用memory-measurer测量构建出的消息的内存大小,并对照序列化之后的二进制流大小。关于memory-measurer的使用请参照
https://github.com/DimitrisAndreou/memory-measurer
在maven中引入memory-measurer相关的包(也可以自己构建),并在运行程序时添加jvm参数-javaagent:path/to/object-explorer.jar,然后就可以如下面的工具类中一样使用MemoryMeasurer来测试内存中对象的大小了:
public class MemoryMeasureTool {
public static long measure(Object obj) {
return MemoryMeasurer.measureBytes(obj);
}
}
测试序列化前后的大小,并将序列化后的二进制码流执行反序列化后判断是否与原始消息相等(注意重写每一个消息对象的equals方法)。其测试结果如下表所示:
内存数据(字节) | 序列化后二进制流(字节) | |
JavaSerializer | 4908800 | 5916500 |
HessianSerializer | 4908800 | 5156600 |
KryoSerializer | 4908800 | 4295023 |
Kryo_preRegister | 4908800 | 3952654 |
ProtostuffSerializer | 4908800 | 4111007 |
由上可见,预注册类信息的kryo拥有最佳的序列化后的二进制码流压缩比,略微优过protostuff。而protostuff好过未预先注册类信息的kryo。而这三者都远远优于java和hessian,更有甚者,java和hessian序列化之后的二进制码流的size是大于在内存中数据结构的size的。
3.2.2 各种特性的支持
测试其是否支持缺少无参构造函数的对象、是否支持集合类型、在多个并发线程中反复执行所有请求/响应消息新创建实例的序列化及反序列化,并将反序列化出来的对象与原始对象比较,确认其相等、另外将各序列化框架序列化后的二进制码流写入文件中,然后修改部分实体的字段(增加、删除、修改顺序),随后将二进制码流读入并在新的类定义下尝试反序列化,查看是否支持向前兼容,测试结果如下表所示:
JavaSerializer | HessianSerializer | Kryo | ProtostuffSerializer | |
多线程的支持 | 支持 | 支持 | 借助KrypPool支持 | 支持 |
集合类的支持 | 支持 | 支持 | 支持 | 编码支持 |
缺少无参构造 函数对象 | 支持 | 支持 | 支持 | 编码支持 |
向前兼容 | 支持 | 支持 | CompatibleFieldSerializer支持 | 部分支持(可以有条件的添加删除字段,但是已有字段的类型和顺序不能更改) |
3.2.3 性能测试
在序列化性能测试中,我们引入了java的微基准测试神器JMH,JMH(Java Microbenchmark Harness)是用于代码微基准测试的工具套件,主要是基于方法层面的基准测试,精度可以达到纳秒级。该工具是由 Oracle 内部实现 JIT 的大牛们编写的,他们应该比任何人都了解 JIT 以及 JVM 对于基准测试的影响,能够尽大程度的去除不合理的影响及不合理的JIT优化,并尽大可能的达到合理的优化。JMH是JDK9自带的,如果是JDK9之前的版本需要加入如下依赖(目前 JMH 的最新版本为 1.23
):
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.19</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.19</version>
</dependency>
具体使用此处不再赘述,可以直接参考项目代码:https://github.com/hantangwangd/serializerForRaft
3.2.3.1 单线程性能测试
在单线程情况下执行序列化及反序列化性能的测试,其测试结果如下:
序列化性能 | 反序列化性能 | |
JavaSerializer | 17600 | 3100 |
HessianSerializer | 8100 | 3450 |
KryoSerializer | 71500 | 55000 |
Kryo_preRegister | 86900 | 76000 |
ProtostuffSerializer | 84900 | 61500 |
注意:此处的单位是“次/秒”,“1次”是指将前述所有定义的请求与响应消息统统执行一遍序列化或反序列化,因此1次对应的实际数据对象为数十个。
单线程情况下,protostuff与预先注册类信息的kryo表现都非常优秀,在序列化性能上旗鼓相当,在反序列化性能上kryo占优。未预先注册类信息的kryo比上面两者有10%-20%的性能损耗,但是仍然远远强过hession及java内置序列化框架(一个数量级的差距)。
3.2.3.2 多线程持有单个序列化器情况下的性能
多线程的支持性上面已经测试了,此处测试其序列化的性能。在8核*2线程的cpu环境中分别测试在1线程,2线程,4线程,8线程,10线程,15线程及30线程时的吞吐率,结果如下所示:
JavaSerializer | HessianSerializer | KryoSerializer | Kryo_preRegister | ProtostuffSerializer | |
1线程 | 17600/3100 | 8100/3450 | 71500/55000 | 86900/76000 | 84900/61500 |
2线程 | 33200/6200 | 15500/6600 | 107300/85900 | 125400/113700 | 168100/117800 |
4线程 | 56900/11400 | 28100/11800 | 147300/129200 | 164300/148000 | 291200/218900 |
8线程 | 62600/19700 | 32000/18900 | 193600/170300 | 200900/196500 | 323400/350400 |
10线程 | 61400/20000 | 30900/18000 | 209500/178900 | 233000/232100 | 308900/353800 |
15线程 | 57900/21000 | 29400/19500 | 239000/212400 | 258900/234800 | 283000/335500 |
30线程 | 56200/19100 | 25300/19000 | 212000/189000 | 255000/220000 | 267000/316000 |
注意:此处的单位同样是“次/秒”;另外,每个表格中的数据表示“序列化性能/反序列化性能”,由表格可见普遍而言序列化的性能都高过反序列化性能。
对于多线程共用同一个序列化器的情况,kryo明显是受制于某些需要同步的属性,因此无法表现出很好的并发性,protostuff随着线程数增加(一定范围内)表现出较好的线性增加。java和hessian都表现出了不错的线性增长,但是由于其基数太低,还是非常慢。
另外,各个序列化器都会随着线程数的增加,在某一个点达到最大的吞吐量(该点未必就一定是本机器内核支持的并发线程数),之后由于线程调度及资源竞争等情况吞吐量反而会下降。对于本测试场景来说,基本上各序列化框架性价比最高的点基本上出现在8线程左右时,再往上增加线程,只会徒劳的消耗cpu,但是吞吐量提升的幅度非常小了,并且由于load增加还会造成响应时间的延长。
3.2.3.3 多线程多序列化器情况下的性能
对于有可能使用需要某些同步行为的全局属性的序列化框架实现,当多线程共享一个序列化器时,可能实际上并无法达到并发或完全并发的效果,此处需要再测试一下:在多个线程中各自使用自己的序列化器而非共享一个,分别测试在1线程,2线程,4线程,8线程,10线程,15线程及30线程时的吞吐率(同样8核*2线程的cpu环境),测试结果如下:
JavaSerializer | HessianSerializer | KryoSerializer | Kryo_preRegister | ProtostuffSerializer | |
1线程 | 17600/3100 | 8100/3450 | 71500/55000 | 86900/76000 | 84900/61500 |
2线程 | 32700/6100 | 15800/6700 | 139100/106500 | 166800/142280 | 161000/113600 |
4线程 | 57200/11500 | 27800/11500 | 250600/191400 | 298300/254800 | 290000/215800 |
8线程 | 62700/19700 | 31900/18900 | 361000/302800 | 384700/382500 | 323600/356300 |
10线程 | 61600/20200 | 30900/19500 | 364700/310000 | 383500/374900 | 324200/362900 |
15线程 | 57900/21100 | 29400/19400 | 366300/301600 | 396800/349800 | 281300/341900 |
30线程 | 54300/19550 | 25900/19670 | 330000/272000 | 378500/334600 | 271000/331500 |
在多线程各自持有单独的序列化器的情况下,kryo貌似不再受制于需要同步的全局属性,比其在多线程单序列化器中的表现有大幅度提升,表现出了较好的线性增长,综合性能稳稳压过protostuff一头。而protostuff与多线程持有单个序列化器的表现相差不大。
同上面一样,各个序列化器都会随着线程数的增加,在某一个点达到最大的吞吐量(该点未必就一定是本机器内核支持的并发线程数),之后由于线程调度及资源竞争等情况吞吐量反而会下降。此处同上面一样,基本上各序列化框架性价比最高的点均出现在8线程左右时,再往上增加线程,只会徒劳的消耗cpu,但是吞吐量提升的幅度非常小了,并且由于load增加还会造成响应时间的延长。
综上所述,性能表现较好的Protostuff与Kryo在合适的线程数下可以达到单机千万次每秒的序列化/反序列化性能,为实现Server端单服务器提供百万级tps提供了理论上的必要条件。
3.2.4 资源使用情况
由于序列化/反序列化是典型的cpu密集型,在没有内存泄露等低级问题的情况下(这些久经沙场的框架都不会有的),实际上对内存的不可释放的实际占用量都是非常小的,因此可以认为无论将jvm进程的内存设置到多小(256m以上),从功能上来讲都是可以work的,不会出现内存溢出的情况,其区别就在于执行过程中使用的临时变量导致的gc开销。而这个gc开销其实是还是影响到了实际序列化/反序列化工作的cpu的使用率的,也就是会影响到序列化/反序列化的性能。
另外不同框架中cpu的使用率高低对比,并不代表其性能高低对比(例如java的cpu使用率高过protostuff,但是protostuff的性能完爆java超过一个数量级),同一个框架自己对cpu使用率的对比才有意义。作为cpu计算密集型,在没有其他干扰的情况下(例如内存过小导致的gc开销,或者并发数超过cpu支持的线程数导致调度开销,或其他非预期的同步代码导致的并发度上不去),序列化框架大体上对cpu的使用率都接近100%。
如下展示了在内存充足(-Xmx4g -Xms1g)情况下,与内存不太充足(-Xmx512m -Xms512m)情况下各个序列化框架的cpu使用率及gc开销:
1. 8cpu*2线程数,16线程并发,内存资源充足(-Xmx4g -Xms1g)
minor gc count | minor gc time | major gc count | major gc time | cpu time | user time | committed memory | |
protostuff(buffer) | 164 | 154ms | 0 | 0 | 1425.01% | 1391.86% | 1495m |
protostuff | 173 | 162ms | 0 | 0 | 1475.06% | 1449.72% | 1559m |
kryo_preregister | 271 | 235ms | 0 | 0 | 1491.65% | 1475.48% | 1200m |
kryo | 320 | 286ms | 0 | 0 | 1480.4% | 1467.03% | 1132.5m |
hessian | 2066 | 1868ms | 0 | 0 | 1480.02% | 1475.73% | 1702m |
java | 2033 | 1786ms | 0 | 0 | 1520.02% | 1517.22% | 1311.5m |
2. 8cpu*2线程数,16线程并发,内存资源不太充足(-Xmx512m -Xms512m)
minor gc count | minor gc time | major gc count | major gc time | cpu time | user time | committed memory | |
protostuff(buffer) | 1010 | 789ms | 0 | 0 | 1254.68% | 1246.25% | 511.5m |
protostuff | 1151 | 865ms | 0 | 0 | 1259.35% | 1250.7% | 511.5m |
kryo_preregister | 941 | 761ms | 0 | 0 | 1343.38% | 1338.8% | 511.5m |
kryo | 1102 | 835ms | 0 | 0 | 1347.84% | 1344.29% | 511.5m |
hessian | 11191 | 7631ms | 0 | 0 | 1287.56% | 1284% | 511.5m |
java | 7137 | 4578ms | 0 | 0 | 1368.73% | 1366.17% | 511.5m |
其中committed momory代表了执行完序列化任务之后jvm进程占据了多少内存,该值会随着执行过程动态的增加会减少,并不代表当前实际使用了多少内存,只能大体的反应在执行过程中为了应对内存的增加而提前申请了多少内存,且由于其并非只增不减,也不能确切的反应实际执行过程中预申请的最大内存值。只可以当做一个大概的参考。
由上面的两个表格可以看到,对序列化/反序列化过程使用的Input/Output都做了池化管理的kryo对于内存的使用和控制是最好的;而对序列化过程使用的LinkedBuffer做了池化管理的protostuff比未做池化管理的protostuff对内存的使用和控制要好一些;而序列化过程中InputStream/OutputStream都无法进行池化管理的java与hessian对于内存的使用和控制非常差,其实际表现为当内存不是很充足时产生了大量了gc开销,因此较大的影响到序列化/反序列化的性能。
4 结语
结合上述的测试结果,各序列化框架在各个不同的场景下会有不同的表现,并不存在一个在各种情况下都表现为最好的方案,因此如何选择是需要具体问题具体分析的。java内置序列化框架有着最多的特性支持和最好的易用性,且其线性表现也非常的好,但是其性能基数非常一般;Hessian在java内置序列化框架上做了点折中,其反序列化性能及序列化后的码流大小略优于java,但是支持的特性及稳定性上略差于java,总体而言性能属于同一级别(较差)的存在。Kryo有最好的二进制码流压缩比,且具备非常不俗的性能,但是其在支持非批量即时响应场景时需要自己做优化,且其对于多线程的支持无论从功能还是性能上都需要写代码时专门注意;Protostuff基于Protobuffer,有着相当强悍的性能,天然的适用于非批量即时响应场景,且其本身对于多线程的支持也非常的好,但是其对于不具备无参构造函数的对象,及集合类等的支持需要自己编码实现,另外其序列化之后的码流还是要比预注册类信息的Kryo大。在某些场景下,例如对序列化后字节码大小非常敏感时表现Protostuff不如Kryo;但是在多线程使用同一个全局性的序列化器(例如单例模式)时,protostuff就表现出了无与伦比的性能优势。