1. Introduzione
In Java (e in generale nella OOP) ogni oggetto ha un’identità — non è solo l’insieme dei valori dei suoi campi, ma proprio «chi è» in memoria. Due oggetti possono essere «equivalenti» (per esempio, a.equals(b) restituisce true), ma essere diversi per identità (a != b). L’identità è quando due variabili puntano allo stesso oggetto in memoria (a == b).
Perché è importante per la serializzazione?
Quando si serializza (si salva) un grafo di oggetti (per esempio, un albero, una lista o semplicemente due oggetti che fanno riferimento allo stesso oggetto annidato), è importante conservare non solo i valori dei campi, ma anche la struttura dei riferimenti tra gli oggetti.
Se nel vostro grafo ci sono riferimenti ripetuti (più oggetti fanno riferimento allo stesso oggetto annidato) o persino riferimenti ciclici (A → B → A), dopo la deserializzazione questi legami devono rimanere gli stessi.
Esempio: avete due oggetti A e B, entrambi fanno riferimento allo stesso oggetto C. Dopo serializzazione e deserializzazione deve valere: a.c == b.c (lo stesso oggetto in memoria). Se la serializzazione «copiasse» semplicemente gli oggetti, dopo il ripristino si otterrebbero due oggetti C diversi — e sarebbe un grafo differente.
2. Come ObjectOutputStream risolve il problema dell’identità
In Java per la serializzazione binaria si usa una coppia di classi: ObjectOutputStream (per la scrittura) e ObjectInputStream (per la lettura).
ObjectOutputStream non è semplicemente «scrivere un oggetto su file». È intelligente:
- Tiene traccia di tutti gli oggetti già serializzati nell’ambito dello stesso flusso di scrittura (una sessione).
- Se compare un riferimento a un oggetto già serializzato, non lo scrive di nuovo, ma registra un apposito «riferimento ripetuto» (reference handle).
- In deserializzazione ObjectInputStream ripristina la struttura in modo che i riferimenti ripetuti puntino allo stesso oggetto in memoria.
Questo funziona anche per i riferimenti ciclici! Cioè, se avete A → B → A, serializzazione e deserializzazione non andranno in loop e non falliranno: i riferimenti verranno ripristinati correttamente.
Come è implementato?
All’interno di ObjectOutputStream viene usata una tabella speciale (identity map) in cui sono memorizzati gli oggetti già serializzati. Quando incontra un nuovo oggetto, lo aggiunge alla tabella e lo serializza completamente. Quando incontra un oggetto presente nella tabella — nel flusso viene scritto soltanto un «riferimento ripetuto» (handle).
3. Dimostrazione con esempi
Esempio 1: Grafo con riferimenti ciclici (A → B, B → A)
import java.io.*;
class Node implements Serializable {
String name;
Node next;
Node(String name) {
this.name = name;
}
}
public class CyclicSerializationDemo {
public static void main(String[] args) throws Exception {
Node a = new Node("A");
Node b = new Node("B");
a.next = b;
b.next = a; // Ciclo!
// Serializzazione
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
out.writeObject(a);
}
// Deserializzazione
Node a2;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("cyclic.dat"))) {
a2 = (Node) in.readObject();
}
System.out.println("a2.name = " + a2.name); // A
System.out.println("a2.next.name = " + a2.next.name); // B
System.out.println("a2.next.next == a2: " + (a2.next.next == a2)); // true!
}
}
Output:
a2.name = A
a2.next.name = B
a2.next.next == a2: true
Commento:
Il ciclo è stato preservato! Dopo la deserializzazione a2.next.next punta di nuovo a a2.
Esempio 2: Riferimenti ripetuti (A → C, B → C)
import java.io.*;
class Wrapper implements Serializable {
String name;
Object ref;
Wrapper(String name) { this.name = name; }
}
public class SharedReferenceDemo {
public static void main(String[] args) throws Exception {
Wrapper a = new Wrapper("A");
Wrapper b = new Wrapper("B");
Wrapper c = new Wrapper("C");
a.ref = c;
b.ref = c;
// Serializzazione
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
out.writeObject(new Wrapper[] {a, b});
}
// Deserializzazione
Wrapper[] arr;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("shared.dat"))) {
arr = (Wrapper[]) in.readObject();
}
Wrapper a2 = arr[0];
Wrapper b2 = arr[1];
System.out.println("a2.ref == b2.ref: " + (a2.ref == b2.ref)); // true!
System.out.println("a2.ref.name = " + ((Wrapper)a2.ref).name); // C
}
}
Output:
a2.ref == b2.ref: true
a2.ref.name = C
Commento:
Dopo la deserializzazione entrambi i riferimenti puntano di nuovo allo stesso oggetto.
4. Relazione con writeReplace e readResolve
In Java ci si può «intromettere» nel processo di (de)serializzazione tramite metodi speciali:
writeReplace() — viene chiamato prima della serializzazione dell’oggetto. Si può restituire un altro oggetto che verrà serializzato al posto dell’originale.
readResolve() — viene chiamato dopo la deserializzazione dell’oggetto. Si può restituire un altro oggetto che verrà usato al posto di quello appena creato.
Impatto sull’identità:
- Se si usa writeReplace() o readResolve(), l’oggetto restituito da questi metodi è quello che finirà nella tabella dei riferimenti.
- Questo può cambiare l’identità: se readResolve() restituisce un oggetto nuovo/diverso, i riferimenti ripetuti potrebbero puntare a istanze diverse.
- Fate attenzione: potete sia «rompere» l’identità sia garantirla intenzionalmente (l’esempio classico è il Singleton tramite readResolve(), dove tutti i riferimenti dopo la deserializzazione puntano alla stessa istanza singleton).
5. Pratica: codice che mostra il mantenimento dell’identità
import java.io.*;
class Shared implements Serializable {
String value;
Shared(String value) { this.value = value; }
}
class Holder implements Serializable {
String name;
Shared shared;
Holder(String name, Shared shared) {
this.name = name;
this.shared = shared;
}
}
public class IdentityDemo {
public static void main(String[] args) throws Exception {
Shared c = new Shared("C");
Holder a = new Holder("A", c);
Holder b = new Holder("B", c);
// Serializzazione
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
out.writeObject(new Holder[] {a, b});
}
// Deserializzazione
Holder[] arr;
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("identity.dat"))) {
arr = (Holder[]) in.readObject();
}
Holder a2 = arr[0];
Holder b2 = arr[1];
System.out.println("a2.shared == b2.shared: " + (a2.shared == b2.shared)); // true!
System.out.println("a2.shared.value = " + a2.shared.value); // C
}
}
Output:
a2.shared == b2.shared: true
a2.shared.value = C
Commento:
L’identità dell’oggetto c è stata preservata: dopo la deserializzazione entrambi i riferimenti puntano alla stessa istanza.
6. Conclusioni ed errori tipici
Errore n. 1: serializzare separatamente oggetti con riferimenti ripetuti. Se scrivete ogni oggetto con la propria istanza di ObjectOutputStream, i riferimenti tra di loro non verranno conservati — le istanze deserializzate saranno diverse, anche se in origine si trattava dello stesso riferimento.
Errore n. 2: uso scorretto di writeReplace() e readResolve(). Questi metodi possono sostituire l’oggetto durante la (de)serializzazione, modificandone l’identità. Senza comprendere la meccanica, si possono ottenere istanze inattese in uscita.
Errore n. 3: effetti imprevisti da riferimenti condivisi mutabili. Se più oggetti fanno riferimento a un unico oggetto annidato mutabile (per esempio, una lista), dopo la deserializzazione rimarrà così. Una modifica in un punto influenzerà tutti gli altri.
Errore n. 4: aspettarsi «nuovi» oggetti dopo la serializzazione. La serializzazione non crea una struttura «fresca» — ripristina i riferimenti originari, come nell’originale. Questo può sorprendere quando si lavora con cache o oggetti modello.
GO TO FULL VERSION