1. ¿Qué son las referencias cíclicas?
Una referencia cíclica es una situación en la que un objeto (o una colección) contiene directa o indirectamente una referencia a sí mismo. En las colecciones esto ocurre más a menudo de lo que parece, especialmente si construyes estructuras de datos complejas o trabajas con grafos.
Ejemplos de la vida real
- Dos objetos se referencian mutuamente:
Por ejemplo, tienes una clase User que tiene una referencia a Profile, y Profile — una referencia de vuelta a User. - Una colección se contiene a sí misma:
El ejemplo más simple y «divertido»:
List<Object> list = new ArrayList<>();
list.add(list); // ¡Ups! la lista se contiene a sí misma
- Grafo de objetos:
Objetos interrelacionados, por ejemplo, nodos de un árbol, donde cada uno puede tener una referencia al padre y a los hijos.
Visualización
graph LR A[User] -- profile --> B[Profile] B -- user --> A
O para una colección:
graph TD L[List] -- add(self) --> L
¿Por qué puede ser un problema?
Si el serializador no sabe detectar ciclos, puede «irse al infinito» intentando serializar los objetos anidados una y otra vez hasta desbordar la pila (StackOverflowError). Buena noticia: la serialización estándar de Java conoce estos trucos y sabe cómo evitarlos.
2. ¿Cómo funciona la serialización estándar de Java con ciclos?
Cuando serializas un objeto mediante ObjectOutputStream, Java rastrea automáticamente qué objetos ya han sido serializados en ese flujo. Si el serializador se encuentra de nuevo con un objeto, no lo serializa otra vez, sino que escribe una referencia especial al objeto ya serializado. Esto permite serializar correctamente incluso estructuras muy complejas con ciclos.
Ejemplo: una colección que se contiene a sí misma
Probemos a serializar una colección que se contiene a sí misma. No es broma: ese código compila e incluso funciona:
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); // Añadimos la propia lista
// Serialización
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic_list.ser"))) {
out.writeObject(list);
}
// Deserialización
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!
}
}
}
Resultado:
— El primer elemento es una cadena normal.
— El segundo elemento es... ¡la propia colección! La comprobación deserialized.get(1) == deserialized devolverá true.
Java no entró en un bucle ni se cayó; reconstruyó correctamente la estructura de referencias.
¿Cómo funciona por dentro?
ObjectOutputStream mantiene un «registro» interno de los objetos serializados. Si un objeto ya fue serializado, en el flujo se escribe una referencia especial (handle) a él, y no su contenido. Al deserializar, ObjectInputStream restaura esas mismas relaciones.
3. Problemas y limitaciones
- Serializaste accidentalmente un grafo enorme.
Si tu estructura de datos es muy grande y contiene muchas referencias cruzadas, la serialización puede tardar mucho y generar un archivo enorme. - Cambio de la estructura de las clases.
Si serializaste un objeto y luego cambiaste su clase (por ejemplo, añadiste o eliminaste un campo), durante la deserialización puede aparecer InvalidClassException. Especialmente si cambian campos que participan en el ciclo. - Problemas con la serialización personalizada.
Si implementas los métodos writeObject y readObject manualmente, deberás gestionar correctamente los ciclos por tu cuenta. Si olvidas llamar a los métodos por defecto (defaultWriteObject/defaultReadObject), el serializador no podrá rastrear los ciclos. - Serialización a otros formatos (por ejemplo, JSON).
La serialización estándar de Java (ObjectOutputStream) maneja los ciclos, pero si serializas objetos a JSON (por ejemplo, con Jackson o Gson), los ciclos pueden provocar StackOverflowError o excepciones. Estas bibliotecas no saben trabajar con ciclos por defecto: requieren una configuración explícita.
4. Tratamiento de referencias cíclicas
En la serialización estándar de Java
¡Todo funciona de serie! No necesitas hacer nada especial: Java detectará los ciclos y conservará la estructura de referencias.
Manual: serialización a otros formatos
- Usar identificadores en lugar de referencias.
En vez de almacenar referencias a otros objetos, almacena sus identificadores únicos. Tras la deserialización, restaura las relaciones a partir de esos id. - Anotaciones o ajustes específicos.
En Jackson puedes usar las anotaciones @JsonIdentityInfo o la pareja @JsonBackReference/@JsonManagedReference para controlar la serialización de ciclos. - Eliminar los ciclos antes de serializar.
Poner temporalmente a null los campos que crean el ciclo, excluirlos con transient o mediante anotaciones.
Ejemplo: serialización de un grafo con ciclos
Veamos un ejemplo con una estructura más compleja: un grafo de usuarios, donde cada usuario puede ser amigo de otro usuario.
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");
// Creamos amistades con ciclos
alice.friends.add(bob);
bob.friends.add(charlie);
charlie.friends.add(alice); // ¡ciclo!
// Serialización
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("users.ser"))) {
out.writeObject(alice);
}
// Deserialización
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!
}
}
}
Resultado:
— Se reconstruye la estructura con ciclo: tras tres pasos por los amigos volvemos a Alice.
— Java no se confundió ni entró en un bucle.
5. Errores típicos al trabajar con referencias cíclicas
Error n.º 1: Serialización a JSON sin soporte de ciclos. Si decides serializar un objeto con ciclos mediante Jackson o Gson sin configurarlo, lo más probable es que obtengas un StackOverflowError. Por ejemplo, si tienes una clase Node donde cada nodo se refiere al padre y a los hijos, la serialización de dicho árbol a JSON conducirá a un anidamiento infinito.
Error n.º 2: Alterar la estructura de las clases. Si después de serializar cambias la estructura de la clase (por ejemplo, añades un campo), al deserializar el archivo antiguo puede surgir un error de incompatibilidad. Esto es especialmente crítico para grafos complejos con ciclos.
Error n.º 3: Serialización casera sin tener en cuenta los ciclos. Si implementas writeObject/readObject manualmente y no llamas a defaultWriteObject, Java no podrá rastrear los ciclos y la serialización se quedará en bucle o la estructura de referencias se romperá al deserializar.
Error n.º 4: Inclusión accidental de una colección en sí misma. A veces, desarrolladores inexpertos añaden accidentalmente una colección a sí misma (por ejemplo, al copiar elementos) sin darse cuenta de que han creado un ciclo. Como resultado, la serialización funcionará, pero la lógica del programa puede volverse extraña e impredecible.
GO TO FULL VERSION