Sip of Java - Record Serialization

Learn about Records Serialization…

Records, introduced in Java 16 (JEP 395), address several key issues related to serialization. A source of frequent headaches in the Java ecosystem.

Transparent Data Carrier

Records are designed to be transparent carriers of data. This is achieved by placing several constraints on Records including:

  • Cannot be extended and superclass always java.lang.Record
  • Cannot be abstract and is implicitly final
  • Cannot declare instance fields or contain instance initializers

Consequently this makes the serialization and deserialization process for Records much simpler and entirely defined by the public definition of the Record class:

  • Only a Record’s components can be serialized: record Range(int high, int low)
  • Records serialized as if defaultWriteObject was invoked
  • Records deserialized by calling public canonical constructor
  • writeObject, readObject, readObjectNoData, writeExternal, readExternal, and serialVersionUID methods and fields are ignored

This stands in stark contrast to the serialization and deserialization process for classes described by Stuart Marks here.

Honoring Encapsulation During Deserialization

When deserializing a class, Java does not call a class’ constructor. This can allow “impossible objects” to be created, objects that wouldn’t be possible to create through normal programmatic paths, like in the example below with Range:

public class Range implements Serializable{
	int low;
	int high;
	public Range(int low, int high) {
		if(low > high) {
			throw new IllegalArgumentException(
			String.format("high: %d must be greater than low: %d", low, high));
		}
		this.low = low;
		this.high = high;
	}
}

Range’s constructor checks that the field low must be less than the field high. However when deserializing data that violates that requirment like in the example below:

Range [low=10, high=1]

The deserialization process is successful and a new Range instance is created with the following values:

Range [low=10, high=1]

If the same scenario were run, but Range was constructed as a Record like below:

public record Range(int low, int high) 
implements Serializable {
	public Range(int low, int high) {
		if(low > high) {
			throw new IllegalArgumentException(
			String.format("high: %d must be greater than low: %d", high, low));
		}
		this.low = low;
		this.high = high;
	}
}

Instead an InvalidObjectException would be thrown during deserialiazation as a result of an exception (IllegalArgumentException) being thrown from Range’s constructor:

Exception in thread "main" java.io.InvalidObjectException: high: 1 must be greater than low: 10

Better Model Versioning Support

As all developers know, change is a constant in our field and that applies to how we model domain concepts. Here again Records provide better support for model versioning than standard classes.

Bi-Directional Compatibility

Fields are frequently added, changed, and removed from a model. Records provide better support for when this happens.

New Field

If a new field is added to a Record class, for example adding the field mid to Range like in the example below:

public record Range(int lo, int hi, int mid) implements Serializable {}

When deserializing of a version of Range that does not contain mid like below:

Range [low=1, high=10]

The JVM will automatically inject a default value for the type or primitive, in this case 0, into the canonical constructor:

Range [low=1, high=10, mid=0]

Removed and Unrecognized Fields

If a field is changed* or removed, only the values in the stream that match to a Records components will be passed.

So deserializing an instance of Range that contains a value for mid:

Range [low=10, high=10, mid=5]

Into a version of Range that does not have a mid field:

public record Range(int low, int high) implements Serializable {}

The value for mid will be ignored during deserialization and an instance of Range that contains these values will be created:

Range [low=1, high=10]

Further Reading

Happy Coding!