The Principle of JDK Serialization for Deep Analysis of Long Worded Text and the Ultimate Performance Implementation of Fury's High Compatibility

Fury is a high-performance multilingual native serialization framework based on JIT dynamic compilation. It supports Java/Python/Golang/C++/JavaScript and other languages, provides fully automatic multilingual/cross language serialization of objects, and has a performance of 20 to 200 times higher than other frameworks.

Preface

For Java object serialization, due to the poor serialization performance of JDK, the industry has introduced Hessian/Kryo and other frameworks to speed up serialization. These frameworks can serialize most Java objects, but if the objects implement JDK custom serialization methods such as writeObject/readObject/writeReplace/readResolve, these frameworks can't do anything. Since users may execute arbitrary logic in these methods, in order to ensure the correctness of serialization, these methods need to be executed in a manner consistent with JDK serialization. At this time, users can only choose the serialization framework provided with JDK and endure extremely slow performance.

Customized JDK serialization of data objects in the business system is a common thing. For example, the following is the flame graph of a complex scenario serialization tested with Fury, and a considerable part of the cost is spent on JDK serialization (Fury's earlier versions will forward it to JDK for serialization when encountering customized JDK serialization types).

In order to improve the serialization performance and ensure that any scenario does not fall back, Fury has fully implemented the entire JDK serialization protocol since version 0.9.2, which is compatible with all JDK custom serialization behaviors, so as to avoid using JDK serialization in any scenario and ensure efficient serialization performance.

This article will first analyze the principle of JDK serialization, and then expand the shortcomings of hessian/kryo and other frameworks based on the principle of JDK serialization, and then introduce the efficient and compatible implementation of Fury, and finally give the data for performance comparison.

Analysis of JDK Serialization Principle

The JDK serialization framework uses ObjectOutputStream and ObjectInputStream for serialization and deserialization. The framework allows users to customize serialization behavior through methods such as Externalization/writeObject/readObject/readObjectNoData/writeReplace/readResolve. When the object to be serialized does not contain these methods, ObjectOutputStream will call the internal defaultWriteObject to serialize all the fields and type information of the type hierarchy. During deserialization, ObjectInputStream will be used to read the information about each type and the corresponding field value of the type hierarchy and fill the entire object. If you include custom serialization methods, you need to go to a separate execution process.

Serialization overall process

When an object defines a writeReplace method, serialization will first call the method, and then use the object reference returned by the method to replace the reference recorded before the reference table. If the return object type remains unchanged, that is, there is still a writeReplace method for the return type. At this time, the method will be ignored and the normal writeObject/writeExternal process will start. If the return type changes, the writeReplace method is called circularly to repeat the above process.

When the returned object no longer contains the writeReplace method, the process of field data serialization begins. If the object implements the Externalizable interface, call writeExternal for serialization. Otherwise, serialize each type and all field data belonging to the current type in turn, starting from the first parent class of the object hierarchy that defines Serializable.

When a writeObject method is defined for a type in the object hierarchy, the writeObject method defined by the type will be called for serialization of the field corresponding to the type. The writeObject method can call the defaultWriteObject of ObjectOutputStream internally to serialize the default field, or completely handwritten serialization logic.

If the fields of different JDK versions are inconsistent and need to be compatible, you need to call the putFields method to obtain the PutField object, which is used to set the field data that only exists in some JDK versions but does not exist in the current JDK version, and then call writeFields to complete the writing of field data.

Deserialize the overall process

Deserialization will first read the object type, and then query the type's parameterless constructor to create the object. If there is no parameterless constructor, it will traverse the type hierarchy structure up through ReflectionFactory # newConstructorForSerialization (java. lang. Class) until the parameterless constructor of the first non Serializable parent class is obtained (this process will be cached to avoid repeated searches).

Then create the object according to the constructor, and put the object into the reference table to avoid the circular reference that cannot find the object.

Next, deserialize each type and corresponding field data in turn from the first Serializable parent class, and fill them into the object created by the constructor. If a deserialized type does not exist, it means that the object hierarchy has changed, and a new parent class has been added to the deserialized object. If the readObjectNoData method is defined for this type, the method will be called to initialize the field state, otherwise these fields will be in the default state.

If no readObject is defined for the parent type, the defaultReadObject will be called to read the value of each non transient non static field in turn and fill it into the object. If the readObject method is defined, this method is called to complete the deserialization of data of this type.

The readObject method can call defaultReadObject to complete the deserialization of the default field value, and then execute other custom logic, or completely handwritten deserialization logic.

If the fields of different JDK versions are inconsistent and need to be compatible, you need to call the readFields method to obtain the GetField object. This object may contain field data that is not available in the current class version. In this case, you can directly ignore it. Other fields can be queried from GetField and set to the object. Note that only one of defaultReadObject and readFields can be called.

In some cases, the deserialization of the parent class field depends on the state of the child class field after deserialization. Because the parent class field is deserialized first, the state of the child class after deserialization cannot be obtained at this time. Therefore, the JDK provides a registerValidation callback to execute after the whole object is deserialized. At this time, additional operations can be performed to restore the object state.

After the object is serialized, check whether the readResolve method is defined for the type of the object. If the method is defined, call the method to return a substitute object. If the return type changes, call the readResolve method circularly to repeat the above process.

After the readResolve is executed, the entire object is deserialized.

Problems in Hessian/Kryo and other frameworks

Hessian's problems

Hessian currently supports the writeReplace/readResolve custom method. When the writeReplace method is defined for an object, it will be serialized through com.caucho.hissian.io.WriteReplaceSerializer. The serializer can meet the requirements of some scenarios, but when the writeReplace method returns a new object of the same type, the hessian will have a stack overflow.

Hessian currently does not support the writeObject/readObject method. When the object to be serialized defines these methods, Hessian will directly ignore them. In the actual scenario, many objects define these two methods, and most JDK types also define these two methods, which causes Hessian to have inconsistent state errors when serializing these types.

In these types, data fields are generally marked as transient, so ignoring these two methods to directly serialize all non transient fields will result in data loss. For example, the main data fields of LinkedBlockingQueue are all transient, and special processing is performed in writeObject.

At the same time, since the custom logic in these two methods is not implemented, the object state of the final deserialization will be incorrect. For example, Java

For common types, it may be possible to serialize them through the built-in serializer, but this cannot enumerate all known and unknown types. Once serialization errors occur, such as multi-threaded Lock status errors, it will be extremely difficult to troubleshoot.

At the same time, Hessian does not support duplicate name fields of parent and child classes, which may also become a usage restriction under certain conditions.

Therefore, in the RPC framework, users will directly select JDK serialization for many scenarios, and these scenarios can now be switched to FURY for acceleration.

Problems in Kryo

In order to ensure the correctness of serialization, Kryo calls the ObjectOutputStream and ObjectInputStream of the JDK for serialization when it encounters an object with writeObject/readObject/readObjectNoData/writeReplace/readResolve defined. There are three problems with this method:

*The serialization performance of JDK is poor, leading to a significant degradation of kryo serialization performance

*JDK serialization results are very large, causing kryo serialized data to expand

*The object sub graph forwarded to JDK for serialization will not share the same reference table with Kryo share. If the sub graph shares/circularly references other objects, repeated serialization/recursion stack overflow will occur

Kryo does not support duplicate name fields of parent and child classes, which may also become a usage restriction under certain conditions.

Problems in other frameworks

Jsonb does not support any JDK custom serialization methods. Deserialization will report an error

Fst does not support type compatibility and cannot be used in service scenarios

Fury compatible implementation principle

Early serialization process

The serialization process of previous versions of Fury followed Kryo type. When encountering objects of writeObject/readObject/readObjectNoData/writeReplace/readResolve, the ObjectOutputStream and ObjectInputStream of JDK were called for serialization.

New serialization process

In Fury 0.9.2, we provide a set of implementation of 100% compatible JDK custom serialization based on JIT dynamic compilation, which improves the performance by an order of magnitude.

The overall implementation process simulates the JDK serialization process, but the implementation uses Fury's built-in JIT serializer to speed up and reduce the size of serialization results. At the same time, for each Serializable class in the object hierarchy, only the class name is serialized, not the metadata of the class, so as to reduce the overhead.

The overall implementation is in the two serializers, io.fury.serializers.ReplaceResolveSerializer and io.fury.serializers.ObjectStreamSerializer, which are respectively responsible for writeReplace/readResolve custom serialization and writeObject/readObject/readObjectNoData custom serialization.

ReplaceResolveSerializer

ReplaceResolveSerializer completely implements the same replace/resolve behavior of the JDK. Even though the writeReplace method returns objects of the same type and different references, it can be serialized normally without the same stack overflow problem as the Hessian. At the same time, when the returned object type is different from the original object type, fury can avoid writing the class name of the original object and reduce the size of the serialized result.

If the object defines the writeObject/readObject/readObjectNoData/writeReplace/readResolve method at the same time, fury will distribute it to ReplaceResolveSerializer to process the reference replace/resolve, and then submit the processed object to ObjectStreamSerializer for JDK custom serialization process.

ObjectStreamSerializer

ObjectStreamSerializer implements a complete set of JDK writeObject/readObject/readObjectNoData/registerValidation behaviors to ensure the consistency of behaviors with JDK, and serialization will not report errors under any circumstances. Because the user calls the interface of JDK ObjectOutputStream/ObjectInputStream/PutField/GetField in writeObject/readObject/readObjectNoData/registerValidation, Fury also implements a set of subclasses of ObjectOutputStream/ObjectInputStream/PutField/GetField to ensure that the actual serialization logic can be forwarded to Fury.

To ensure the compatibility of types and the compatibility of defaultWriteObject/defaultReadObject with putFields/readFields, Fury's CompatibleSerializer is used for field data serialization. Deserialization can also be achieved when the types of the read-write end are inconsistent. To ensure high performance, when JIT mode is enabled, JITCompatibleSerializer will be created through io.fury.serializers. CodegenSerializer # loadCompatibleCodegenSerializer for serialization.

The overall implementation is divided into the serializer initialization part and the execution part.

Serializer initialization section

Get the parameterless constructor or the first non Serializable parent class parameterless constructor. In order to avoid the reflective access permission problem of JDK17 and above, the constructor extracted from ObjectStreamClass. lookup (type) will be directly obtained through Unsafe in JDK17 and above.

Serializer Execution Section

Serialize Execution Section

Write the number of all Serializable classes

Traverse the object class hierarchy and serialize the field data of each type in turn. Serializing each type of data is divided into the following parts:

If the writeObject method is not defined for the type of the current object, call slotsSerializer (JITCompatibleSerializer) directly to serialize all fields of the current type.

If the writeObject method is defined for the type of the previous object, the context of the previous serialization will be cached, and then the writeObject method will be called to pass in the FuryObjectOutputStream implemented by Fury.

In FuryObjectOutputStream, special processing is also performed for putFields/writeFields/defaultWriteObject. PutFields/writeFields will convert the object to the array form recognized by CompatibleSerializer, and defaultWriteObject will directly call slotsSerializer (JITCompatibleSerializer) to serialize all fields of the current type.

Deserialization execution part:

Create an object instance based on the constructor.

Write the object instance to the reference table.

Read the number of all Serializable Classes in the object hierarchy.

Read the classes from the data in turn and compare them with the class of the current type hierarchy. If it is inconsistent, it means that the current type hierarchy has changed and a new parent class has been introduced. If readObjectNoData is defined for this type, call this method for initialization, and then traverse the type hierarchy upward until the same type is found.

Deserialize all field values of this type and set them on the object fields.

If the readObject method is not defined for the object, the slotsSerializer (JITCompatibleSerializer) is directly called for deserialization.

If the readObject method is defined, the readObject method of the object is called and the FuryObjectInputStream implemented by Fury is passed in.

In the FuryObjectInputStream, special processing is also performed for readFields/defaultReadObject. ReadFields will use the Compatible Serializer to convert the object to the recognizable GetField form, and the defaultReadObject will directly call the slotsSerializer (JITCompatibleSerializer) to deserialize all fields of the current type.

If the user registers an ObjectInputValidation callback through registerValidation during readObject, the callback will be executed in order of priority before returning the object.

So far, deserialization is complete. The core code is roughly as follows:

Performance comparison

After the complete implementation of JDK custom serialization, Fury will no longer call into JDK serialization in any scenario. Under the same data test, Fury has a performance improvement of 10 times over Kryo and 3 times over Hessian and other frameworks without any configuration (most of the data are strings and hashmaps, and string serialization and hashmap iteration/rebalance balance the advantages of JIT).

image.pngimage.png

The following is the flame diagram of Fury serialization. It is also clear that there is no JDK serialized stack in it:

conclusion

It can be seen that since version 0.9.2, fury has significant performance advantages over JDK/Kryo/Hessian in any scenario. It is also the only framework in the industry that can maintain 100% compatibility and correctness with JDK serialization. At present, due to the correctness of the mission, many businesses will directly use JDK serialization to endure its slow performance. We hope that through the serialization capability provided by Fury, we can completely say goodbye to JDK serialization, free the business from this pain, and provide higher productivity.

Related Articles

Explore More Special Offers

  1. Short Message Service(SMS) & Mail Service

    50,000 email package starts as low as USD 1.99, 120 short messages start at only USD 1.00

phone Contact Us