Serialization is the process of converting an object into a byte stream, and deserialization is reconstructing the object back from the stream.
For immutable classes, special care is needed because:
- Fields are declared
final. - We rely on deep copy and defensive copies to ensure immutability.
- The Java deserialization mechanism bypasses the constructor — meaning your deep copy logic in the constructor won’t automatically run during deserialization.
✅ Problem Scenario
Imagine you have an immutable class with a mutable field (Date).
import java.io.Serializable;
import java.util.Date;
public final class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private final String name;
private final Date joiningDate; // mutable field
public Employee(String name, Date joiningDate) {
this.name = name;
this.joiningDate = new Date(joiningDate.getTime()); // defensive copy
}
public String getName() {
return name;
}
public Date getJoiningDate() {
return new Date(joiningDate.getTime()); // defensive copy
}
}
👉 During normal construction, joiningDate is safely deep-copied.
👉 But during deserialization, Java restores fields directly from the stream, bypassing the constructor — so no defensive copy is made.
This means the deserialized object might hold a direct reference to the original mutable object stored in the stream, breaking immutability.
✅ Solution: Custom readObject Method
To maintain immutability, override the readObject method and re-apply defensive copying:
private void readObject(java.io.ObjectInputStream in)
throws java.io.IOException, ClassNotFoundException {
in.defaultReadObject();
// Defensive copy after deserialization
if (joiningDate != null) {
// Reflection sets final field, so we must ensure immutability manually
java.lang.reflect.Field field;
try {
field = Employee.class.getDeclaredField("joiningDate");
field.setAccessible(true);
field.set(this, new Date(joiningDate.getTime())); // deep copy
} catch (Exception e) {
throw new java.io.InvalidObjectException("Failed to maintain immutability");
}
}
}
✅ Now even after deserialization, the joiningDate field is safely deep-copied.
✅ Alternative: readResolve Method
Another technique is using readResolve, which allows you to replace the deserialized object with a properly constructed one:
private Object readResolve() {
// Recreate immutable object using constructor
return new Employee(this.name, this.joiningDate);
}
✅ This ensures your constructor logic (including deep copy) is applied.
⚠️ Things to Be Careful About
- Final Fields and Reflection
- Java deserialization sets final fields via reflection, so ensure your custom logic (
readObjectorreadResolve) maintains immutability.
- Java deserialization sets final fields via reflection, so ensure your custom logic (
- Serialization Proxy Pattern (Best Practice)
- Instead of allowing direct serialization of your immutable class, use a proxy object that handles serialization safely.
private Object writeReplace() {
return new SerializationProxy(this);
}
private Object readResolve() {
throw new java.io.InvalidObjectException("Proxy required");
}
private static class SerializationProxy implements Serializable {
private final String name;
private final Date joiningDate;
SerializationProxy(Employee e) {
this.name = e.name;
this.joiningDate = e.joiningDate;
}
private Object readResolve() {
return new Employee(name, joiningDate); // constructor ensures immutability
}
}
✅ This is the safest approach for immutable classes.
🔑 Summary
- Issue: Deserialization bypasses constructor, so deep copy logic isn’t applied.
- Solution:
- Use
readObjectto manually enforce immutability. - Or use
readResolveto reconstruct via constructor. - Best practice: adopt Serialization Proxy Pattern for maximum safety.
- Use
