Blog-Archiv

Montag, 30. Januar 2023

Java serialVersionUID Test App

In nearly every Java class that implements Serializable you find the serialVersionUID. Why do developers define that field? Is it needed? What happens if you change its value? What happens on serialization if you add or remove a field or method in the enclosing class?

This Blog tries to give some answers to these questions. It also refers to one of my past Blogs.

Recommendation

Excerpt from Serializable JavaDoc:

It is strongly recommended that all serializable classes explicitly declare serialVersionUID values, since the default serialVersionUID computation is highly sensitive to class details that may vary depending on compiler implementations, and can thus result in unexpected InvalidClassExceptions during deserialization.
....
serialVersionUID fields are not useful as inherited members.

If you do not define the serialVersionUID field with a value on a Serializable class, the JVM will compute it automatically at runtime, and check the compatibility of classes on object serialization by comparing these values.

That doesn't mean that every new compilation of a class will cause a new serialVersionUID value. But adding or removing non-static fields or methods will cause that (statics do not get serialized - except the explicit serialVersionUID).

Example Code

Here is example code of a serializable class. It is about the static field on line 5:

import java.io.Serializable;

public class TransferExample implements Serializable
{
    private static final long serialVersionUID = 1;
    
    private String name;
    private int number;
    
    public TransferExample(String name, int number) {
        this.name = name;
        this.number = number;
    }

    @Override
    public String toString() {
        return super.toString()+": "+name+" "+number;
    }
}

I will use an object of this class to try out what happens if you read a serialized object into a class that has been modified since serialization. The scenario is a server A that has a different TransferExample class version than server B and tries to send a TransferExample object to server B.

If both class versions have a hardcoded serialVersionUID of same value, the data-exchange will always succeed, even if the sending and receiving classes are different. This is the reason why the Serializable JavaDoc (see above) recommends to always define a serialVersionUID.

In another scenario, when a serialized object got persisted into a database or the file-system, reading it out into a modified class version will also succeed (with hardcoded serialVersionUID), but data will be lost in case the receiving class has less properties than the class of the persisted object had. Not a problem? At least you have to know about that data loss ...

Test Code

In reality, there are many class-modification cases to consider: removing a property, adding a property, removing the serialVersionUID, adding it, changing it, all these possibilities combined should be tested to get an imagination what could happen at runtime.

For brevity I will just try out removing a property with or without having defined a serialVersionUID.

Following class provides in-memory serialization. It contains a method to turn an Object into a byte[] array, and another method to turn a byte[] array back into an Object. I will use that to write bytes to a file, and read bytes from a file.

import java.io.*;

public final class Serializer
{
    public static byte[] serialize(Object object) throws IOException {
        final ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
        final ObjectOutputStream objectStream = new ObjectOutputStream(byteStream);
        objectStream.writeObject(object);    // is now in outputStream
        objectStream.close();
        return byteStream.toByteArray();
    }   

    public static Object deserialize(byte[] bytes) throws IOException, ClassNotFoundException {
        final InputStream inputStream = new ByteArrayInputStream(bytes);
        return new ObjectInputStream(inputStream).readObject();
    }   

    private Serializer() {}
}

The scenario of a server A sending to server B is not so short and easy to implement. So I will replace server B by a file. The test-app will serialize an object into a file, then read it it from that file and output it (1st launch). In case the file exists, it will not write the object but only read it (2nd launch). This is the situation where we can modify the class before, and then see what happens on serialization of objects having incompatible classes.

Thus the follwing test-app should be run once to write an example into a serialization-file, afterwards the class should be modified in some way, then the test-app should be run again to see what it does when reading from the already existing serialization-file. When the 2nd launch succeeds, the test-app will automatically remove the file to make place for another experiment. Here is its code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import java.io.*;

public class SerialVersionUidTest
{
    private final static String FILE_NAME = "TransferExample.ser";
    
    public static void main(String[] args) throws Exception {
        final Object toSerialize = new TransferExample("Oberon", 1);
        new SerialVersionUidTest(toSerialize);
    }

    public SerialVersionUidTest(Object toSerialize) throws IOException, ClassNotFoundException {
        final File file = new File(FILE_NAME);
        final boolean fileExists = file.exists();
        
        if (fileExists == false) {
            System.err.println("Creating nonexisting file "+file.getAbsolutePath());
            writeObjectToFile(file, toSerialize);
        }
        
        final Object deserialized = readObjectFromFile(file);
        System.out.println("Deserialized object is: "+deserialized);
        
        if (fileExists == true) {
            file.delete();    // make ready for next launch
            System.err.println("Deleted file "+file.getAbsolutePath());
        }
    }
    
    private void writeObjectToFile(File file, Object object) throws IOException {
        System.out.println("Writing to "+file+" ....");
        
        byte[] serializedBytes = Serializer.serialize(object);
        try (OutputStream outputStream = new FileOutputStream(file)) {
            outputStream.write(serializedBytes);
        }   // auto-close stream
    }

    private Object readObjectFromFile(File file) throws IOException, ClassNotFoundException {
        System.out.println("Reading from file "+file+" ....");
        
        try (InputStream inputStream = new FileInputStream(file)) {
            byte[] deserializedBytes = inputStream.readAllBytes();
            return Serializer.deserialize(deserializedBytes);
        }   // auto-close stream
    }
}

Test Executions

Running this as Java class yields on first launch:

Creating nonexisting file /tmp/TransferExample.ser
Writing to TransferExample.ser ....
Reading from file TransferExample.ser ....
Deserialized object is: TransferExample@3930015a: Oberon 1

Now I comment-out the number property:

public class TransferExample implements Serializable
{
    private static final long serialVersionUID = 1;
    
    private String name;
    //private int number;
    
    public TransferExample(String name, int number) {
        this.name = name;
    //    this.number = number;
    }

    @Override
    public String toString() {
        return super.toString()+": "+name; //+" "+number;
    }
}

As you can see I removed the number field and thus forced a new compilation of the class. Running the test now (2nd launch) yields:

Reading from file TransferExample.ser ....
Deserialized object is: TransferExample@67117f44: Oberon
Deleted file /tmp/TransferExample.ser

The serialization worked (because of the explicit serialVersionUID), but the number value got lost. You must know if this is a problem for your application. In case of data-exchange between servers it won't be a problem in most cases, the receiving server will get a zero value for number (JVM initial value for int data types). No exception will happen, although the classes are incompatible.


Now let's try this out without the serialVersionUID definition. Here is the altered TransferExample:

public class TransferExample implements Serializable
{
    //private static final long serialVersionUID = 1;
    
    private String name;
    private int number;
    
    public TransferExample(String name, int number) {
        this.name = name;
        this.number = number;
    }

    @Override
    public String toString() {
        return super.toString()+": "+name+" "+number;
    }
}

This time I commented out the serialVersionUID field. On 1st launch, the test-app outputs the following:

Creating nonexisting file /tmp/TransferExample.ser
Writing to TransferExample.ser ....
Reading from file TransferExample.ser ....
Deserialized object is: TransferExample@6ea6d14e: Oberon 1

Now let's remove the number property again, like done before (see above):

public class TransferExample implements Serializable
{
    //private static final long serialVersionUID = 1;
    
    private String name;
    //private int number;
    
    ....
    

Don't forget to compile the changed source code.
The next (2nd) launch yields the following:

Reading from file TransferExample.ser ....
Exception in thread "main" java.io.InvalidClassException: TransferExample; 
		local class incompatible: 
		stream classdesc serialVersionUID = 2910957717821065909,
		local class serialVersionUID = -4472615520725387357
	at java.base/java.io.ObjectStreamClass.initNonProxy(ObjectStreamClass.java:689)
	at java.base/java.io.ObjectInputStream.readNonProxyDesc(ObjectInputStream.java:1903)
	at java.base/java.io.ObjectInputStream.readClassDesc(ObjectInputStream.java:1772)
	at java.base/java.io.ObjectInputStream.readOrdinaryObject(ObjectInputStream.java:2060)
	at java.base/java.io.ObjectInputStream.readObject0(ObjectInputStream.java:1594)
	at java.base/java.io.ObjectInputStream.readObject(ObjectInputStream.java:430)
	at Serializer.deserialize(Serializer.java:17)
	at SerialVersionUidTest.readObjectFromFile(SerialVersionUidTest.java:46)
	at SerialVersionUidTest.<init>(SerialVersionUidTest.java:23)
	at SerialVersionUidTest.main(SerialVersionUidTest.java:11)

This time the JVM itself computed serialVersionUID values. Because I removed a property and thus created another version of the TransferExample class, its computed value is different from that of the first version.

On deserialization, the JVM always compares the serialVersionUID values of both sides and throws an InvalidClassException if they are different.

Conclusion

Serialization of objects is used in two contexts: (1) persistence, and (2) data-exchange between JVM-instances at runtime. Case (2) mostly won't be a problem due to the tolerant behavior when a serialVersionUID was defined, so hardcoding it is a good solution. This also applies to case (1) if lost property values do not matter, maybe because these data are not used any more.




Keine Kommentare: