1. What are cyclic references?
A cyclic reference is a situation where an object (or a collection) directly or indirectly contains a reference to itself. In collections this happens more often than it seems, especially if you build complex data structures or work with graphs.
Real‑world examples
- Two objects reference each other:
For example, you have a User class that holds a reference to Profile, and Profile holds a back reference to User. - A collection contains itself:
The simplest and “funniest” example:
List<Object> list = new ArrayList<>();
list.add(list); // Oops! list contains itself
- Object graph:
Interrelated objects, for example, tree nodes where each node can reference its parent and its children.
Visualization
graph LR A[User] -- profile --> B[Profile] B -- user --> A
Or for a collection:
graph TD L[List] -- add(self) --> L
Why can this be a problem?
If a serializer cannot detect cycles, it may “go infinite,” attempting to serialize nested objects again and again until it overflows the stack (StackOverflowError). The good news: Java’s standard serialization knows about such tricks and can handle them!
2. How does Java’s standard serialization handle cycles?
When you serialize an object via ObjectOutputStream, Java automatically tracks which objects have already been serialized in that stream. If the serializer encounters an object again, it does not serialize it anew but writes a special reference to the already serialized object. This makes it possible to correctly serialize even very complex structures with cycles.
Example: a collection containing itself
Let’s try to serialize a collection that contains itself. This is not a joke—such code compiles and even works:
import java.io.*;
import java.util.*;
public class CyclicListDemo {
public static void main(String[] args) throws Exception {
List<Object> list = new ArrayList<>();
list.add("Hello, cyclic world!");
list.add(list); // Add itself
// Serialization
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic_list.ser"))) {
out.writeObject(list);
}
// Deserialization
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic_list.ser"))) {
List<?> deserialized = (List<?>) in.readObject();
System.out.println(deserialized.get(0)); // "Hello, cyclic world!"
System.out.println(deserialized.get(1) == deserialized); // true!
}
}
}
Result:
— The first element is a regular string.
— The second element is... the collection itself! The check deserialized.get(1) == deserialized will return true.
Java did not loop forever or crash; it correctly restored the reference structure.
How does it work under the hood?
ObjectOutputStream maintains an internal “registry” of serialized objects. If an object has already been serialized, a special reference (handle) to it is written to the stream rather than its contents. During deserialization, ObjectInputStream restores the same relationships.
3. Problems and limitations
- Accidentally serialized a huge graph.
If your data structure is very large and contains many cross-references, serialization can take a long time and produce a huge file. - Changing class structure.
If you serialized an object and then changed its class (for example, added or removed a field), deserialization may throw an InvalidClassException. This is especially true if fields that participate in a cycle are changed. - Issues with custom serialization.
If you implement writeObject and readObject manually, you must handle cycles correctly yourself. If you forget to call the default methods (defaultWriteObject/defaultReadObject), the serializer will not be able to track cycles. - Serialization to other formats (for example, JSON).
Java’s standard serialization (ObjectOutputStream) handles cycles, but if you serialize objects to JSON (for example, via Jackson or Gson), cycles can lead to a StackOverflowError or exceptions. Such libraries do not support cycles by default—you need explicit configuration.
4. Dealing with cyclic references
In Java’s standard serialization
It all works “out of the box”! You do not need to do anything special—Java will detect cycles and preserve the reference structure.
Manually: serialization to other formats
- Use identifiers instead of references.
Instead of storing references to other objects, store their unique identifiers. After deserialization, restore relationships using these IDs. - Special annotations or settings.
In Jackson, you can use annotations @JsonIdentityInfo or the pair @JsonBackReference/@JsonManagedReference to control cycle serialization. - Remove cycles before serialization.
Temporarily null out the fields that create a cycle, or exclude them using transient or annotations.
Example: serializing a graph with cycles
Consider an example with a more complex structure—a graph of users, where each user can be another user’s friend.
import java.io.*;
import java.util.*;
class User implements Serializable {
String name;
List<User> friends = new ArrayList<>();
User(String name) { this.name = name; }
public String toString() {
return name + " (" + friends.size() + " friends)";
}
}
public class CyclicGraphDemo {
public static void main(String[] args) throws Exception {
User alice = new User("Alice");
User bob = new User("Bob");
User charlie = new User("Charlie");
// Create friends with cycles
alice.friends.add(bob);
bob.friends.add(charlie);
charlie.friends.add(alice); // cycle!
// Serialization
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
out.writeObject(alice);
}
// Deserialization
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("users.ser"))) {
User restoredAlice = (User) in.readObject();
System.out.println(restoredAlice);
System.out.println(restoredAlice.friends.get(0));
System.out.println(restoredAlice.friends.get(0).friends.get(0));
System.out.println(restoredAlice.friends.get(0).friends.get(0).friends.get(0) == restoredAlice); // true!
}
}
}
Result:
— The structure with a cycle is restored: after three hops through friends we end up back at Alice.
— Java did not get confused or loop forever.
5. Common mistakes when working with cyclic references
Error #1: Serializing to JSON without cycle support. If you decide to serialize an object with cycles via Jackson or Gson without configuration, you will most likely get a StackOverflowError. For example, if you have a Node class where each node references its parent and its children, serializing such a tree to JSON will lead to infinite nesting.
Error #2: Changing class structure. If you change a class’s structure after serialization (for example, add a field), deserializing an old file may result in an incompatibility error. This is especially critical for complex graphs with cycles.
Error #3: Hand-rolled serialization without handling cycles. If you implement writeObject/readObject manually and do not call defaultWriteObject, Java will not be able to track cycles, and serialization will either loop or the reference structure will break during deserialization.
Error #4: Accidentally adding a collection to itself. Sometimes inexperienced developers accidentally add a collection to itself (for example, when copying elements), not realizing they have created a cycle. As a result, serialization will work, but the program’s logic may become strange and unpredictable.
GO TO FULL VERSION