11.4 Java Object Serialization and Record Serialization
Key Takeaways
- Object serialization converts object graphs into byte streams via ObjectOutputStream and deserializes them via ObjectInputStream, requiring participating classes to implement the Serializable marker interface.
- Fields marked transient and static are excluded from serialization; upon standard class deserialization, transient fields are initialized to primitive default values or null without executing inline initializers.
- Deserialization of ordinary classes bypasses the serializable class's constructors and only invokes the no-argument constructor of the first non-serializable superclass, requiring an accessible no-arg constructor to avoid InvalidClassException.
- Custom serialization hooks (writeObject, readObject, readResolve, writeReplace) allow tailored data representation and singleton identity preservation in standard classes.
- Record serialization in Java SE 21 bypasses custom writeObject/readObject methods and unconditionally reconstructs record instances through their canonical constructor, guaranteeing that all validation rules and invariants are strictly enforced.
Java Object Serialization and Record Serialization
Serialization is the mechanism of transforming an in-memory Java object graph into a sequential stream of binary bytes for persistent storage on disk or transmission across network boundaries. Deserialization performs the reverse transformation, reconstructing a live, active Java object graph on the JVM heap from the serialized byte stream.
On the Oracle Certified Professional: Java SE 21 Developer (1Z0-830) exam, serialization is a heavily tested architectural topic. Candidates must master the java.io.Serializable marker interface, serialVersionUID evolution rules, transient and static field behavior, constructor invocation semantics during standard class deserialization, custom serialization hooks (writeObject, readObject, readResolve, writeReplace), the Externalizable interface, and the modern serialization protocol for Java records.
1. The Serialization Protocol & Serializable
To enable serialization for a class, it must implement the java.io.Serializable interface. Serializable is a marker interface (it declares no methods or constants), signaling to the JVM that instances of the class are safe to serialize.
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private double salary;
private transient String secretPin; // Excluded from serialization
public Employee(String name, double salary, String pin) {
this.name = name;
this.salary = salary;
this.secretPin = pin;
}
public String getName() { return name; }
public double getSalary() { return salary; }
public String getSecretPin() { return secretPin; }
}
Serializing and Deserializing Objects
Object serialization is executed via java.io.ObjectOutputStream and java.io.ObjectInputStream:
// Writing an object graph to a binary file:
try (ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("employee.ser"))) {
Employee emp = new Employee("Alice Johnson", 95000.0, "9876");
oos.writeObject(emp);
}
// Reading and reconstructing the object graph from the binary file:
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream("employee.ser"))) {
Employee emp = (Employee) ois.readObject();
System.out.println("Name: " + emp.getName()); // "Alice Johnson"
System.out.println("Salary: " + emp.getSalary()); // 95000.0
// Transient field receives primitive/reference default value:
System.out.println("PIN: " + emp.getSecretPin()); // null
}
The NotSerializableException
[!WARNING] If an object implementing
Serializablecontains a reference to an object that does not implementSerializable, invokingObjectOutputStream.writeObject()throwsjava.io.NotSerializableExceptionat runtime! To prevent this exception, non-serializable fields must be explicitly marked with thetransientkeyword or must hold anullreference at the time of serialization.
2. Field Modifiers: transient and static
staticFields: Belong to the class definition rather than any individual object instance. Static fields are never serialized. When an object is deserialized, static fields reflect whatever value is currently loaded in the target JVM's class definition.transientFields: Explicitly excluded from the serialized stream. They are used for sensitive credentials (passwords, cryptographic keys), temporary cache buffers, or non-serializable system resources (such as database connections or thread handles).
The Transient Default Initialization Trap
When an ordinary Serializable class is deserialized:
transientreference types are initialized tonull.transientnumeric primitives (byte,short,int,long,float,double) are initialized to0(0.0).transient booleanfields are initialized tofalse.transient charfields are initialized to'\u0000'.
[!IMPORTANT] Inline Field Initializers Do NOT Run: During standard class deserialization, inline field initializers (e.g.,
private transient int maxAttempts = 5;) and instance initializer blocks are NOT executed. The transient field will have the default value0, not5!
3. Class Evolution and serialVersionUID
serialVersionUID is a 64-bit hash (long) used as a version identifier for a Serializable class:
private static final long serialVersionUID = 1L;
Why Declare serialVersionUID Explicitly?
If a Serializable class does not declare an explicit serialVersionUID, the Java compiler automatically computes a default UID based on the class name, implemented interfaces, fields, and method signatures. Any subsequent change to the source code (such as adding a private helper method or reordering fields) changes the generated UID hash.
InvalidClassException
During deserialization, ObjectInputStream compares the serialVersionUID stored in the serialized byte stream with the serialVersionUID of the class currently loaded in the JVM. If the two identifiers do not match, deserialization immediately aborts by throwing java.io.InvalidClassException.
4. Object Deserialization & Constructor Invocation Rules
One of the most frequently tested topics on the 1Z0-830 exam is the exact sequence of constructor invocations during deserialization of standard classes:
The 4 Golden Deserialization Rules for Standard Classes
- Constructors of
Serializableclasses are NEVER executed during deserialization! The JVM allocates raw uninitialized heap memory for the object and injects field values directly from the stream using internal reflection. - The JVM traverses up the class inheritance hierarchy to locate the first NON-SERIALIZABLE superclass.
- The JVM invokes the NO-ARGUMENT constructor of that first non-serializable superclass.
- If that first non-serializable superclass does not declare an accessible no-argument constructor (for example, if it only defines parameterized constructors), the JVM throws
java.io.InvalidClassExceptionat runtime during deserialization!
// Non-serializable superclass MUST have an accessible no-arg constructor:
public class Person {
private String species;
public Person() { // Required for subclass deserialization!
this.species = "Homo sapiens";
System.out.println("Person no-arg constructor executed");
}
public Person(String species) {
this.species = species;
}
}
public class Manager extends Person implements Serializable {
private static final long serialVersionUID = 1L;
private String department;
public Manager(String dept) {
super("Human");
this.department = dept;
System.out.println("Manager constructor executed");
}
}
// When deserializing a serialized Manager object:
// 1. Person() no-arg constructor IS executed!
// 2. Manager(String) constructor IS NOT executed!
5. Custom Serialization Hooks for Standard Classes
Standard classes can customize their serialization and deserialization formats by declaring special private methods with exact signatures:
public class SecureAccount implements Serializable {
private static final long serialVersionUID = 1L;
private String accountNumber;
private transient String password;
// Custom serialization method
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // Serializes all non-transient fields normally
// Write custom encrypted/masked representation of transient field:
String encryptedPass = encrypt(this.password);
out.writeUTF(encryptedPass);
}
// Custom deserialization method
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject(); // Deserializes all non-transient fields normally
// Read and restore custom encrypted transient field:
String encryptedPass = in.readUTF();
this.password = decrypt(encryptedPass);
}
// Invoked if class hierarchy has changed and stream has no data for this class
private void readObjectNoData() throws ObjectStreamException {
this.password = "DEFAULT_PASSWORD";
}
// Designates an alternate object to be serialized in place of this object
private Object writeReplace() throws ObjectStreamException {
return new AccountProxy(this.accountNumber);
}
// Replaces the deserialized object with an existing instance (e.g. Singleton Pattern)
private Object readResolve() throws ObjectStreamException {
return AccountRegistry.getCanonicalInstance(this.accountNumber);
}
private String encrypt(String raw) { return raw != null ? "ENC:" + raw : ""; }
private String decrypt(String enc) { return enc.startsWith("ENC:") ? enc.substring(4) : enc; }
}
The Role of readResolve() in the Singleton Pattern
When a singleton instance is serialized and subsequently deserialized via readObject(), the JVM standard deserializer creates a new, distinct instance on the heap, violating the core invariant of the Singleton design pattern. Implementing readResolve() allows the class to discard the newly deserialized instance and return the existing singleton reference instead:
public class DatabasePool implements Serializable {
private static final long serialVersionUID = 1L;
private static final DatabasePool INSTANCE = new DatabasePool();
private DatabasePool() {}
public static DatabasePool getInstance() { return INSTANCE; }
// Preserves Singleton Identity during Deserialization:
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}
6. The java.io.Externalizable Interface
Externalizable is a sub-interface of Serializable that provides complete, manual control over the binary format:
public interface Externalizable extends Serializable {
void writeExternal(ObjectOutput out) throws IOException;
void readExternal(ObjectInput in) throws IOException, ClassNotFoundException;
}
Critical Exam Rules for Externalizable
- Requires Public No-Arg Constructor: Unlike standard
Serializableclasses (where constructors are bypassed), anExternalizableclass MUST declare apublicno-argument constructor. - Constructor Invocation: During deserialization, the JVM first invokes the public no-argument constructor of the externalizable class itself, and then calls
readExternal(in)on that newly created instance. - No Automatic Field Storage: The JVM does not automatically write any field data. All fields (even non-transient ones) must be explicitly written in
writeExternal()and read inreadExternal()in identical order.
7. Modern Record Serialization in Java SE 21
Java records introduce a completely redesigned, secure serialization protocol that fundamentally differs from standard class serialization.
public record CustomerOrder(String orderId, double amount, int itemCount) implements Serializable {
// Compact constructor enforces validation invariants:
public CustomerOrder {
Objects.requireNonNull(orderId, "orderId cannot be null");
if (amount < 0.0) {
throw new IllegalArgumentException("Order amount cannot be negative");
}
if (itemCount <= 0) {
throw new IllegalArgumentException("Item count must be positive");
}
}
}
The 5 Core Rules of Record Serialization
- Always Invokes the Canonical Constructor: Unlike standard classes (which bypass constructors), record deserialization ALWAYS invokes the canonical constructor, passing the deserialized stream components as arguments.
- Strict Invariant and Validation Enforcement: Because the canonical constructor is guaranteed to execute, any validation checks, sanitization logic, or defensive copies defined in compact/canonical constructors are strictly enforced during deserialization. Tampered or malicious serialized byte streams cannot bypass record integrity constraints!
- Custom Methods are Completely Ignored: Declaring
writeObject(),readObject(),readObjectNoData(), orserialPersistentFieldsinside a record has zero effect. The JVM serialization framework completely ignores them. serialVersionUIDDefaults to0L: TheserialVersionUIDrequirement is waived for records. The default UID is0L, and changes in component order or adding/removing record components do not triggerInvalidClassExceptionbased on UID mismatch.readResolveandwriteReplaceSupported: Records supportwriteReplace()andreadResolve()(for instance, to substitute a proxy or canonical cached instance).
8. Serialization Architecture Comparison Matrix
| Feature | Standard Class (Serializable) | Externalizable Class | Record (Record implements Serializable) |
|---|---|---|---|
| Deserialization Mechanism | Field injection via reflection | readExternal() callback | Canonical Constructor Invocation |
| Constructor Executed | First non-serializable superclass no-arg | Class's own public no-arg constructor | Record's Canonical Constructor |
| Validation Enforcement | Bypassed by default | Bypassed (inside readExternal) | Strictly Enforced by Constructor |
writeObject / readObject | Supported | Ignored (uses writeExternal) | Completely Ignored |
readResolve / writeReplace | Supported | Supported | Supported |
serialVersionUID Check | Enforced (UID mismatch throws exception) | Enforced | Waived (Defaults to 0L) |
transient Keyword | Excludes field | N/A (all serialization is manual) | Not permitted on record components |
A class Account is defined as follows:
If an instance of public class Account implements Serializable {
private static final long serialVersionUID = 1L;
private String accountNumber = "ACT-100";
private transient double balance = 500.0;
private transient String securityToken = "TOKEN-XYZ";
}
Account is serialized to disk and subsequently deserialized into a new JVM instance, what will be the values of accountNumber, balance, and securityToken in the deserialized object?
Given the following class hierarchy:
What happens when an existing serialized class Vehicle {
public Vehicle(String type) {
System.out.print("V1 ");
}
}
class Car extends Vehicle implements Serializable {
private static final long serialVersionUID = 1L;
public Car() {
super("Sedan");
System.out.print("C1 ");
}
}
Car object is deserialized via ObjectInputStream.readObject()?
A developer creates a Java 21 record for storing user credentials:
If a serialized stream containing valid record component data (public record UserCredential(String username, int pinCode) implements Serializable {
public UserCredential {
if (pinCode < 1000 || pinCode > 9999) {
throw new IllegalArgumentException("PIN must be 4 digits");
}
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
throw new UnsupportedOperationException("Custom reading not allowed");
}
}
username = "admin", pinCode = 4321) is deserialized via ois.readObject(), what is the outcome?
In standard Java object serialization, what is the primary purpose of defining a private Object readResolve() method inside a singleton class?