- StringBuffer in Java: A Complete Guide
- StringBuilder in Java: A Complete Guide
- Formatting Messages In Java
- Formatting Numbers In Java
- Locale Class In Java
- ResourceBundle Class In Java
- AtomicInteger Class In Java
- BigInteger Class In Java
- Wrapper Classes In Java
- BigDecimal Class In Java
- OffsetDateTime Class in Java
- Period Class in Java
- Duration Class in Java
- ZonedDateTime Class in Java
- LocalDateTime Class in Java
- LocalTime Class in Java
- LocalDate Class in Java
- Text Block in Java: Learn to Handle Multiline Text With Ease
- String Class In Java: Essential Techniques for Text Manipulation
- Java IO API: What You Need To Know
- Serialize an Object Graph: Understanding Java Serialization
Introduction
Java object serialization often appears deceptively simple: implement Serializable, write an object to a stream, and read it back later. In reality, serialization is governed by a precise and sometimes unforgiving rule: Java serializes an object graph, not a single object.
Many production bugs related to serialization stem from a misunderstanding of this rule. Unexpected data sizes, NotSerializableException, broken invariants, or compatibility issues across versions are usually symptoms of an incorrectly modeled object graph.
This article explains how Java serialization works from a graph perspective, clarifying what gets serialized, what does not, and why. For a broader overview of Java I/O concepts, see Java IO API.
“Serialization does not save objects; it walks the object graph.”
1. Serialization and Deserialization
When working with Java objects, it is important to understand that objects normally live only in memory.
Once the program stops, those objects disappear.
Serialization and deserialization are mechanisms that allow Java objects to leave memory and come back later.
1.1 Serialization
Serialization is the process of converting a Java object into a sequence of bytes.
These bytes can then be:
- written to a file,
- sent over the network,
- or stored for later use.
From a beginner’s perspective, you can think of serialization as “saving an object”.
try (ObjectOutputStream out =
new ObjectOutputStream(new FileOutputStream("user.ser"))) {
User user = new User("Alice", 30);
out.writeObject(user);
}
What happens here:
- The User object exists in memory,
- writeObject() transforms it into bytes,
- Java writes the bytes to a file (user.ser).
Serialization copies the state; it does not remove or transform the original object.
1.2 Deserialization
Deserialization performs the inverse operation: it reconstructs a Java object from its serialized form.
try (ObjectInputStream in =
new ObjectInputStream(new FileInputStream("user.ser"))) {
User restoredUser = (User) in.readObject();
}
What happens here:
- Java reads the bytes from the file
- readObject() recreates the User object
- The object becomes usable again in memory
The deserialized object is a new instance, whose state matches the serialized one.
2. Serialize an Object Graph: How Java Traverses Objects?
An object graph is the set of objects reachable from a given root object through references.
class Order implements Serializable {
Customer customer;
}
class Customer implements Serializable {
Address address;
}
When you serialize an Order instance:
out.writeObject(order);
Java will:
- Serialize
Order - Follow the
customerreference - Serialize
Customer - Follow the
addressreference - Serialize
Address
This traversal continues until the entire reachable graph is processed.
“Reachability, not intention, determines what is serialized.”
3. The Serializable Marker Interface
Serializable is a marker interface: it has no methods. Its presence signals to the JVM that instances of the class may be serialized.
class User implements Serializable {
String name;
}
However, implementing Serializable does not serialize only that class.
Rules:
- Every reachable object must be serializable,
- Otherwise, serialization fails with
NotSerializableException.
This requirement propagates transitively across the object graph.
Consider the following objects:
class Session implements Serializable {
User user;
Socket socket; // not serializable
}
class User implements Serializable {
String name;
//...
}
class Socket{
String address;
//...
}
Attempting to serialize Session will fail:
out.writeObject(session);
Output:
java.io.NotSerializableException: Socket
Why? Because:
- Java reached socket
- socket is not serializable
- Traversal cannot continue
4. Transient and Static Fields
Java provides explicit escape hatches to control graph traversal.
4.1 transient Fields
A transient field:
- is ignored during serialization,
- breaks the traversal at that point,
- is restored with a default value during deserialization.
class Account implements Serializable {
String id;
private transient String secret;
}
After deserialization:
secret == null; // default value
Use transient for:
- sensitive data,
- caches
- derived values,
- non-serializable dependencies.
4.2 static Fields
Static fields belong to the class, not to instances. They are never serialized.
class Config implements Serializable {
static String ENV = "prod";
String value;
}
Only value is serialized.
“Serialization captures state, not class-level configuration.”
5. Cycles and Shared References
Object graphs may contain:
- cycles (A → B → A),
- shared references (A → B, C → B).
Both situations are common in real-world object models and must be handled correctly during serialization.
5.1. Cyclic references
class Node implements Serializable {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a; // cycle
If Java naively followed references, this structure would cause infinite recursion.
Instead, the serialization mechanism tracks already-serialized objects and writes references to them when encountered again.
After deserialization, object identity is preserved:
a.next.next == a // true after deserialization
5.2. Shared references
class Customer implements Serializable {
String name;
Address address;
//...
}
class Address implements Serializable {
String city;
//...
}
Address sharedAddress = new Address();// shared reference
sharedAddress.city = "Paris";
Customer c1 = new Customer("Alice", shared);
Customer c2 = new Customer("Bob", shared);
Here, both Customer instances reference the same Address object.
Java serialization preserves this relationship. After deserialization:
c1.address == c2.address // true
6. serialVersionUID and Versioning
Each serializable class has a serialVersionUID, which identifies its serialization version.
class Person implements Serializable {
private static final long serialVersionUID = 1L;
String name;
}
If not explicitly declared, the JVM computes one based on:
Warning: “Implicit
serialVersionUIDs are time bombs.”
Always declare it explicitly to control compatibility across versions and avoid InvalidClassException.
7. Custom Serialization Hooks
By default, Java serialization is fully automatic: the JVM writes and restores all non-transient, non-static fields by walking the object graph. In most cases, this default behavior is sufficient, and you should not modify it.
However, Java provides two optional serialization hooks that allow a class to intervene in this process when necessary:
writeObject(ObjectOutputStream out)readObject(ObjectInputStream in)
If these methods are present with the correct signature, the serialization mechanism will call them instead of the default implementation.
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
}
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
in.defaultReadObject();
}
In the above example, the calls to defaultWriteObject() and defaultReadObject() explicitly delegate back to the standard behavior.
These hooks exist for specific, advanced use cases, such as:
- validating object invariants after deserialization (constructors are not called),
- reconstructing derived or transient fields,
- transforming or protecting sensitive data,
- maintaining compatibility when a class evolves over time.:
Used carefully, they allow a serialized class to remain correct, secure, and compatible across versions.
A working example: rebuilding a transient field
Consider a class with a derived field that should not be serialized, but must be restored after deserialization.
class User implements Serializable {
private static final long serialVersionUID = 1L;
private String firstName;
private String lastName;
// Derived value: should not be serialized
private transient String displayName;
User(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
this.displayName = firstName + " " + lastName;
}
private void writeObject(ObjectOutputStream out) throws IOException {
// Perform default serialization of non-transient fields
out.defaultWriteObject();
}
private void readObject(ObjectInputStream in)
throws IOException, ClassNotFoundException {
// Restore non-transient fields
in.defaultReadObject();
// Rebuild derived state
this.displayName = firstName + " " + lastName;
}
@Override
public String toString() {
return displayName;
}
}
What happens during serialization
- Java calls writeObject() automatically
- defaultWriteObject() serializes firstName and lastName
- Java ignores displayName because it is transient
What happens during deserialization
- Java calls readObject() automatically
- defaultReadObject() restores firstName and lastName
- Java recomputes displayName to restore object consistency
Without this hook, displayName would be null after deserialization.
8. Common Serialization Pitfalls
Frequent mistakes include:
- serializing large object graphs unintentionally,
- forgetting
transienton non-serializable fields, - breaking compatibility across versions,
- assuming serialization is secure.
“Serialization preserves structure, not intent.”
class Service implements Serializable {
Logger logger; // should be transient
//...
}
Conclusion
Java serialization operates on object graphs, not isolated objects. Understanding reachability, transience, and versioning is essential for safe and predictable use.
While serialization is powerful, you must approach it with care and restraint. A correct mental model prevents subtle bugs and long-term maintenance issues.
You can find the complete code of this article here on GitHub.
