1. Introduction
En Java (et, plus largement, en POO), chaque objet possède une identité — ce n'est pas seulement l'ensemble des valeurs de ses champs, mais bien « qui il est » en mémoire. Deux objets peuvent être « équivalents » (par exemple, a.equals(b) renvoie true), tout en étant différents par leur identité (a != b). L'identité, c'est lorsque deux variables pointent vers le même objet en mémoire (a == b).
Pourquoi est-ce important pour la sérialisation ?
Lorsque vous sérialisez (enregistrez) un graphe d'objets (par exemple, un arbre, une liste ou simplement deux objets qui référencent le même objet imbriqué), il est important de préserver non seulement les valeurs des champs, mais aussi la structure des références entre les objets.
Si votre graphe comporte des références partagées (plusieurs objets référencent le même objet imbriqué) ou même des références cycliques (A → B → A), alors après désérialisation ces liens doivent rester identiques.
Exemple : vous avez deux objets A et B, tous deux référencent le même objet C. Après sérialisation et désérialisation, il doit être vrai que : a.c == b.c (le même objet en mémoire). Si la sérialisation se contente de « copier » les objets, alors on obtiendra deux objets C distincts — et le graphe sera différent.
2. Comment ObjectOutputStream résout le problème de l'identité
En Java, la sérialisation binaire utilise une paire de classes : ObjectOutputStream (pour l'écriture) et ObjectInputStream (pour la lecture).
ObjectOutputStream, ce n'est pas simplement « écrire un objet dans un fichier ». Il est intelligent :
- Suit tous les objets déjà sérialisés dans le cadre d'un même flux d'écriture (une session).
- Si une référence vers un objet déjà sérialisé est rencontrée, il ne le réécrit pas, mais enregistre une « référence partagée » (reference handle).
- Lors de la désérialisation, ObjectInputStream restaure la structure de sorte que les références partagées pointent vers le même objet en mémoire.
Cela fonctionne même pour les références cycliques ! Autrement dit, si vous avez A → B → A, la sérialisation et la désérialisation ne boucleront pas et ne « tomberont pas en panne » : les références seront restaurées correctement.
Comment est-ce implémenté ?
À l'intérieur d'ObjectOutputStream, une table spéciale (identity map) est utilisée pour stocker les objets déjà sérialisés. Lorsqu'un nouvel objet est rencontré, il est ajouté à la table et entièrement sérialisé. Lorsqu'un objet présent dans la table est rencontré, seul un « handle » de référence est écrit dans le flux.
3. Démonstration par l'exemple
Exemple 1 : Graphe avec références cycliques (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; // Cycle !
// Sérialisation
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
out.writeObject(a);
}
// Désérialisation
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!
}
}
Sortie :
a2.name = A
a2.next.name = B
a2.next.next == a2: true
Commentaire :
Le cycle a été conservé ! Après désérialisation, a2.next.next pointe de nouveau vers a2.
Exemple 2 : Références partagées (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;
// Sérialisation
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
out.writeObject(new Wrapper[] {a, b});
}
// Désérialisation
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
}
}
Sortie :
a2.ref == b2.ref: true
a2.ref.name = C
Commentaire :
Après désérialisation, les deux références pointent de nouveau vers le même objet.
4. Lien avec writeReplace et readResolve
En Java, on peut « intervenir » dans le processus de (dé)sérialisation grâce à des méthodes spéciales :
writeReplace() — appelée avant la sérialisation de l'objet. On peut renvoyer un autre objet, qui sera sérialisé à la place de l'original.
readResolve() — appelée après la désérialisation de l'objet. On peut renvoyer un autre objet, qui sera utilisé à la place de celui qui vient d'être créé.
Impact sur l'identité :
- Si writeReplace() ou readResolve() est utilisé, c'est l'objet renvoyé par ces méthodes qui sera placé dans la table des références.
- Cela peut modifier l'identité : si readResolve() renvoie un objet nouveau/différent, les références partagées peuvent pointer vers des instances différentes.
- Soyez prudent : vous pouvez aussi bien « casser » l'identité que la garantir intentionnellement (exemple classique — singleton via readResolve(), où toutes les références après désérialisation pointent vers la même instance singleton).
5. Pratique : code démontrant la conservation de l'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);
// Sérialisation
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
out.writeObject(new Holder[] {a, b});
}
// Désérialisation
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
}
}
Sortie :
a2.shared == b2.shared: true
a2.shared.value = C
Commentaire :
L'identité de l'objet c est conservée : après désérialisation, les deux références pointent vers la même instance.
6. Conclusions et erreurs typiques
Erreur n°1 : sérialiser séparément des objets ayant des références partagées. Si vous écrivez chaque objet avec son propre ObjectOutputStream, les liens entre eux ne seront pas conservés — les instances désérialisées seront différentes, même si à l'origine c'était la même référence.
Erreur n°2 : utilisation incorrecte de writeReplace() et readResolve(). Ces méthodes peuvent remplacer l'objet pendant la (dé)sérialisation, ce qui modifiera son identité. Sans comprendre la mécanique, vous risquez d'obtenir des instances inattendues en sortie.
Erreur n°3 : effets inattendus des références partagées mutables. Si plusieurs objets référencent un même objet imbriqué mutable (par exemple, une liste), cela restera vrai après désérialisation. Une modification en un endroit affectera tous les autres.
Erreur n°4 : s'attendre à des « nouveaux » objets après la sérialisation. La sérialisation ne crée pas une structure « neuve » — elle restaure les références d'origine, comme dans la source. Cela peut surprendre lorsqu'on travaille avec des caches ou des objets modèles.
GO TO FULL VERSION