09 Network Communication Optimization Serialization Avoid Using Java's Serialization

09 Network communication optimization Serialization Avoid using Java’s serialization #

Hello, I’m Liu Chao.

Currently, most backend services are implemented based on microservice architecture. Services are divided according to business and achieve decoupling of services, but at the same time, it brings new problems. Communication between different businesses needs to be implemented through interfaces. To share a data object between two services, it needs to be converted into binary stream, transmitted through the network, sent to the receiving service, and then converted back into an object for service method invocation. We call this encoding and decoding process serialization and deserialization.

In the case of a large number of concurrent requests, slow serialization speed will lead to increased response time for requests, and the large volume of transmitted data after serialization will lead to reduced network throughput. Therefore, an excellent serialization framework can improve the overall performance of the system.

We know that Java provides the RMI framework to implement interface exposure and invocation between services. Java serialization is used for data object serialization in RMI. However, the mainstream microservice frameworks today hardly use Java serialization. SpringCloud uses JSON serialization, while Dubbo is compatible with Java serialization, but the default is to use Hessian serialization. Why is that?

Today, we will delve into Java serialization, and then compare it with the popular serialization framework Protobuf in the past two years to see how Protobuf achieves optimal serialization.

Java Serialization #

Before discussing the flaws, you need to understand what Java Serialization is and how it works.

Java provides a serialization mechanism that can serialize an object into a binary format (byte array) for writing to disk or outputting over a network. It can also deserialize byte arrays read from the network or disk into objects for use in a program.

img

Java Development Kit (JDK) provides two input/output stream objects, ObjectInputStream and ObjectOutputStream, which can only deserialize and serialize objects of classes that implement the Serializable interface.

The default serialization behavior of ObjectOutputStream only serializes non-transient instance variables of an object. It does not serialize transient instance variables or static variables.

In objects of classes that implement the Serializable interface, a serialVersionUID version number is generated. What is the purpose of this version number? It is used to verify if the serialized object has loaded the class for deserialization. If there are different versions of a class with the same class name, the object cannot be obtained during deserialization.

The serialization process is specifically implemented by the writeObject and readObject methods. Typically, these two methods are provided by default, but we can also override them in classes that implement the Serializable interface to customize our own serialization and deserialization mechanism.

In addition, the Java serialization class defines two overridden methods: writeReplace() and readResolve(). The former is used to replace the serialized object before serialization, and the latter is used to process the returned object after deserialization.

Defects in Java Serialization #

If you have used some RPC communication frameworks, you will find that these frameworks rarely use the serialization provided by the JDK. The fact that it is rarely used is often related to its drawbacks. Now let’s take a look at the defects of the default serialization in JDK.

1. Not Language-Agnostic #

System designs nowadays are becoming more diverse, with many systems using multiple languages to write applications. For example, some large games developed by our company use multiple languages, with C++ being used for game services, Java/Go for peripheral services, and Python for some monitoring applications.

However, Java serialization currently only works with frameworks implemented in the Java language, and most other languages do not use Java serialization frameworks or implement the Java serialization protocol. Therefore, if two application services written in different languages need to communicate with each other, it is not possible to serialize and deserialize objects between the two services.

2. Vulnerable to Attacks #

According to the Java Security Coding Guidelines on the Java official website, “Deserialization of untrusted data is fundamentally dangerous and should be avoided.” This indicates that Java serialization is not secure.

We know that objects are deserialized by calling the readObject() method on the ObjectInputStream, which is actually a magical constructor that can instantiate almost any object that implements the Serializable interface on the classpath.

This means that during the process of deserializing the byte stream, this method can execute code of any type, which is very dangerous.

For objects that require long deserialization, an attack can be launched without executing any code. An attacker can create a cyclic object chain and then transfer the serialized object to the program for deserialization. This situation will cause the hashCode method to be called an exponentially increasing number of times, leading to a stack overflow exception. The following example illustrates this well.

Set root = new HashSet();
Set s1 = root;
Set s2 = new HashSet();
for (int i = 0; i < 100; i++) {
   Set t1 = new HashSet();
   Set t2 = new HashSet();
   t1.add("foo"); // make t2 not equal to t1
   s1.add(t1);
   s1.add(t2);
   s2.add(t1);
   s2.add(t2);
   s1 = t1;
   s2 = t2;
}

In 2015, the security team FoxGlove Security’s breenmachine published a long blog post describing how the Java deserialization vulnerability can be exploited through Apache Commons Collections. This vulnerability swept through the latest versions of WebLogic, WebSphere, JBoss, Jenkins, and OpenNMS, and many Java web servers were affected.

Apache Commons Collections is actually a third-party base library that extends the Collection structure in the Java standard library, providing many powerful data structure types and implementing various collection utility classes.

The principle of the attack is that Apache Commons Collections allows chainable arbitrary class function reflection calls. Attackers upload the attack code to the server through a “port that implements the Java serialization protocol” and then execute it using TransformedMap in Apache Commons Collections.

So how was this vulnerability eventually resolved?

Many serialization protocols have defined a set of data structures for storing and retrieving objects. For example, JSON serialization, Protocol Buffers, etc. These protocols only support some basic types and array data types, which avoids the creation of uncertain instances during deserialization. Although their designs are simple, they are sufficient to meet the data transmission needs of most systems today.

We can also use a deserialization object whitelist to control deserialization objects. We can override the resolveClass method and validate the object name in this method. The code is shown below.

@Override
protected Class resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
    if (!desc.getName().equals(Bicycle.class.getName())) {
        throw new InvalidClassException(
            "Unauthorized deserialization attempt", desc.getName());
    }
    return super.resolveClass(desc);
}

3. Serialized Stream is Too Large #

The size of the serialized binary stream reflects the performance of serialization. The larger the size of the serialized binary array, the more storage space will be occupied, resulting in higher hardware storage costs. If we are doing network transmissions, it will also occupy more bandwidth and affect the system’s throughput.

In Java serialization, ObjectOutputStream is used to convert objects to binary encoding. Is there any difference in the size of the binary array generated by this serialization mechanism compared to that generated by ByteBuffer in NIO?

We can verify this through a simple example.

User user = new User();
user.setUserName("test");
user.setPassword("test");

ByteArrayOutputStream os = new ByteArrayOutputStream();
ObjectOutputStream out = new ObjectOutputStream(os);
out.writeObject(user);
byte[] testByte = os.toByteArray();
System.out.print("ObjectOutputStream byte encoding length: " + testByte.length + "\n");
ByteBuffer byteBuffer = ByteBuffer.allocate(2048);

byte[] userName = user.getUserName().getBytes();
byte[] password = user.getPassword().getBytes();
byteBuffer.putInt(userName.length);
byteBuffer.put(userName);
byteBuffer.putInt(password.length);
byteBuffer.put(password);

byteBuffer.flip();
byte[] bytes = new byte[byteBuffer.remaining()];
System.out.print("ByteBuffer byte encoding length: " + bytes.length + "\n");

// Results:
// ObjectOutputStream byte encoding length: 99
// ByteBuffer byte encoding length: 16

Here we can clearly see that the binary array size produced by Java serialization is several times larger than the one produced by ByteBuffer implementation. Therefore, the serialized stream in Java will be larger and ultimately affect the system's throughput.

### 4. Poor Serialization Performance

Serialization speed is also an important indicator of serialization performance. If serialization is slow, it will affect the efficiency of network communication and increase the system's response time. Let's again compare the performance of Java serialization and ByteBuffer encoding in NIO using the example above:

```java
User user = new User();
user.setUserName("test");
user.setPassword("test");

long startTime = System.currentTimeMillis();

for (int i = 0; i < 1000; i++) {
    ByteArrayOutputStream os = new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(os);
    out.writeObject(user);
    out.flush();
    out.close();
    byte[] testByte = os.toByteArray();
    os.close();
}

long endTime = System.currentTimeMillis();
System.out.print("ObjectOutputStream serialization time: " + (endTime - startTime) + "\n");
long startTime1 = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
    ByteBuffer byteBuffer = ByteBuffer.allocate(2048);

    byte[] userName = user.getUserName().getBytes();
    byte[] password = user.getPassword().getBytes();
    byteBuffer.putInt(userName.length);
    byteBuffer.put(userName);
    byteBuffer.putInt(password.length);
    byteBuffer.put(password);

    byteBuffer.flip();
    byte[] bytes = new byte[byteBuffer.remaining()];
}
long endTime1 = System.currentTimeMillis();
System.out.print("ByteBuffer serialization time: " + (endTime1 - startTime1) + "\n");

Results:

ObjectOutputStream serialization time: 29
ByteBuffer serialization time: 6

From the above example, we can clearly see that the encoding time in Java serialization is much longer than that in ByteBuffer.

Replace Java Serialization with Protobuf Serialization #

Currently, there are many excellent serialization frameworks in the industry, most of which avoid some of the shortcomings of Java default serialization. For example, recently popular frameworks such as FastJson, Kryo, Protobuf, and Hessian. We can completely find a replacement for Java serialization, and here I recommend using the Protobuf serialization framework.

Protobuf is a serialization framework developed by Google that supports multiple languages. In the performance comparison test reports of mainstream websites, Protobuf ranks high in terms of encoding and decoding time as well as binary stream compression size.

Protobuf is based on a file with a .proto extension, which describes the fields and field types. Different language-specific data structure files can be generated using this file. When serializing the data object, Protobuf generates Protocol Buffers format encoding based on the .proto file description.

Here, let me explain what a Protocol Buffers storage format is and how it works.

Protocol Buffers is a lightweight and efficient structured data storage format. It uses the T-L-V (Tag-Length-Value) data format to store data. T represents the positive sequence (tag) of the field. Protocol Buffers associates each field in the object with a positive sequence, and the generated code ensures this association. Integer values are used to replace field names during serialization, thereby significantly reducing transmission traffic. L represents the length of the value in bytes, usually only occupying one byte. V represents the encoded value of the field value. This format does not require separators or spaces and reduces redundant field names.

Protobuf defines its own encoding method, which can almost map to all basic data types in languages such as Java and Python. Different encoding methods correspond to different data types and can also use different storage formats. The following figure shows the encoding methods for different data types:

img

For storing Varint-encoded data, since the storage space occupied by the data is fixed, the length does not need to be stored (Length), so the actual storage format of Protocol Buffers is T-V, which reduces another byte of storage space.

The Varint encoding method defined by Protobuf is a variable-length encoding method. The last bit of each byte of a data type is a flag bit (msb), represented by 0 and 1. 0 indicates that the current byte is the last byte, and 1 indicates that there is another byte after this number.

For int32-type numbers, they generally require 4 bytes for representation. If Varint encoding is used, a small int32-type number can be represented using only 1 byte. For most integer-type data, they are generally less than 256, so this operation can effectively compress data.

We know that int32 represents positive and negative numbers, so the last bit is usually used to represent the positive or negative value. Now, with the Varint encoding method using the last bit as the flag bit, how can we represent positive and negative integers? If int32/int64 is used to represent negative numbers, multiple bytes are needed. In the Varint encoding type, by using Zigzag encoding, negative numbers are converted into unsigned numbers, and then sint32/sint64 is used to represent negative numbers. This greatly reduces the number of bytes after encoding.

Protobuf’s data storage format not only has good compression storage effects but also performs efficiently in terms of encoding and decoding. The encoding and decoding process of Protobuf, combined with the .proto file format and the unique encoding format of Protocol Buffers, only requires simple data operations and bit shifting to complete the encoding and decoding. It can be said that Protobuf has excellent overall performance.

Summary #

Whether it is network transmission or persistent disk data, we need to encode the data into bytecode. The data types or objects we usually use in programs are based on in-memory data, and we need to convert these data into binary byte streams through encoding; if we need to receive or use them again, we need to convert the binary byte streams back into in-memory data through decoding. We usually refer to these two processes as serialization and deserialization.

Java’s default serialization is implemented through the Serializable interface. As long as a class implements this interface and generates a default version number, we do not need to set it manually, and this class will automatically implement serialization and deserialization.

Although Java’s default serialization is convenient to implement, it has security vulnerabilities, does not support cross-language use, and has poor performance. Therefore, I strongly recommend you avoid using Java serialization.

Among mainstream serialization frameworks, FastJson, Protobuf, and Kryo are quite characteristic and have been recognized by the industry in terms of performance and security. We can choose a suitable serialization framework based on our own business to optimize the serialization performance of the system.

Thought question #

This is a class implemented using the singleton design pattern. If we implement the Serializable interface in this class in Java, will it still be a singleton? If you were asked to write a singleton that implements the Serializable interface in Java, how would you write it?

public class Singleton implements Serializable {
 
    private final static Singleton singleInstance = new Singleton();
 
    private Singleton(){}
 
    public static Singleton getInstance(){
       return singleInstance; 
    }
}

To answer the first question, no, the class will not still be a singleton if we implement the Serializable interface. The Serializable interface allows objects of a class to be converted into a byte stream and then reconstructed from the byte stream. When the object is deserialized, a new instance is created. So, in this case, if we serialize and then deserialize the Singleton object, we will end up with a new instance, breaking the singleton pattern.

To create a singleton that implements the Serializable interface, we can modify the above code as follows:

public class SerializableSingleton implements Serializable {
 
    private static final long serialVersionUID = 1L;
     
    private static class SingletonHelper{
        private static final SerializableSingleton instance = new SerializableSingleton();
    }
     
    private SerializableSingleton(){}
     
    public static SerializableSingleton getInstance(){
        return SingletonHelper.instance;
    }
     
    protected Object readResolve() {
        return getInstance();
    }
}

By using a static nested class to hold the instance, we ensure lazy initialization and thread safety. Additionally, by providing the readResolve method, we can control the object creation during deserialization and return the same instance. This ensures that the singleton pattern is maintained even after serialization and deserialization.