1. Wprowadzenie
W Javie (i ogólnie w OOP) każdy obiekt ma tożsamość – to nie jest tylko zestaw wartości jego pól, lecz to, „kim jest” w pamięci. Dwa obiekty mogą być „równoważne” (na przykład a.equals(b) zwraca true), ale różnić się tożsamością (a != b). Tożsamość oznacza, że dwie zmienne wskazują na ten sam obiekt w pamięci (a == b).
Dlaczego to ważne dla serializacji?
Gdy serializujesz (zapisujesz) graf obiektów (na przykład drzewo, listę albo po prostu dwa obiekty, które wskazują na ten sam obiekt wewnętrzny), ważne jest zachowanie nie tylko wartości pól, lecz także struktury odwołań między obiektami.
Jeśli w Twoim grafie występują współdzielone odwołania (kilka obiektów wskazuje na ten sam obiekt wewnętrzny) lub nawet cykliczne odwołania (A → B → A), to po deserializacji te zależności muszą pozostać takie same.
Przykład: masz dwa obiekty A i B, oba wskazują na ten sam obiekt C. Po serializacji i deserializacji powinno być: a.c == b.c (ten sam obiekt w pamięci). Jeśli serializacja po prostu „kopiuje” obiekty, to po odtworzeniu powstaną dwa różne obiekty C – a to już inny graf.
2. Jak ObjectOutputStream rozwiązuje problem tożsamości
W Javie do binarnej serializacji używa się pary klas: ObjectOutputStream (do zapisu) i ObjectInputStream (do odczytu).
ObjectOutputStream to nie tylko „zapisz obiekt do pliku”. To sprytna klasa:
- Śledzi wszystkie obiekty, które zostały już zserializowane w ramach jednego strumienia zapisu (jednej sesji).
- Gdy napotyka odwołanie do obiektu, który został już zapisany, nie zapisuje go ponownie, lecz zapisuje specjalne „odwołanie zwrotne” (reference handle).
- Podczas deserializacji ObjectInputStream odtwarza strukturę tak, aby odwołania zwrotne wskazywały na ten sam obiekt w pamięci.
To działa nawet dla cyklicznych odwołań! Tzn. jeśli masz A → B → A, serializacja i deserializacja nie wejdą w nieskończoną pętlę i się nie „wywrócą”: odwołania zostaną odtworzone poprawnie.
Jak jest to zrealizowane?
Wewnątrz ObjectOutputStream używana jest specjalna tabela (identity map), w której przechowywane są już zserializowane obiekty. Gdy pojawia się nowy obiekt, dodaje się go do tabeli i zapisuje w całości. Gdy pojawia się obiekt już znajdujący się w tabeli – do strumienia trafia tylko „odwołanie zwrotne” (handle).
3. Demonstracja na przykładzie
Przykład 1: Graf z cyklicznymi odwołaniami (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; // Cykl!
// Serializacja
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
out.writeObject(a);
}
// Deserializacja
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!
}
}
Wynik:
a2.name = A
a2.next.name = B
a2.next.next == a2: true
Komentarz:
Cykl został zachowany! Po deserializacji a2.next.next znowu wskazuje na a2.
Przykład 2: Współdzielone odwołania (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;
// Serializacja
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
out.writeObject(new Wrapper[] {a, b});
}
// Deserializacja
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
}
}
Wynik:
a2.ref == b2.ref: true
a2.ref.name = C
Komentarz:
Po deserializacji oba odwołania ponownie wskazują na ten sam obiekt.
4. Związek z writeReplace i readResolve
W Javie można „wtrącić się” w proces (de)serializacji za pomocą specjalnych metod:
writeReplace() – wywoływana przed serializacją obiektu. Można zwrócić inny obiekt, który zostanie zserializowany zamiast oryginalnego.
readResolve() – wywoływana po deserializacji obiektu. Można zwrócić inny obiekt, który będzie użyty zamiast dopiero co utworzonego.
Wpływ na tożsamość:
- Jeśli używa się writeReplace() lub readResolve(), to właśnie obiekt zwrócony przez te metody trafi do tabeli (identity map).
- Może to zmienić tożsamość: jeśli readResolve() zwraca nowy/inny obiekt, współdzielone odwołania mogą wskazywać na różne egzemplarze.
- Zachowaj ostrożność: możesz zarówno „zepsuć” tożsamość, jak i celowo ją zapewnić (klasyczny przykład – Singleton przez readResolve(), gdzie wszystkie odwołania po deserializacji wskazują na ten sam singleton).
5. Praktyka: kod pokazujący zachowanie tożsamości
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);
// Serializacja
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
out.writeObject(new Holder[] {a, b});
}
// Deserializacja
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
}
}
Wynik:
a2.shared == b2.shared: true
a2.shared.value = C
Komentarz:
Tożsamość obiektu c została zachowana: po deserializacji oba odwołania wskazują na ten sam egzemplarz.
6. Podsumowanie i typowe błędy
Błąd nr 1: serializacja obiektów ze współdzielonymi odwołaniami osobno. Jeśli zapisujesz każdy obiekt osobną instancją ObjectOutputStream, odwołania między nimi nie zostaną zachowane – zdeserializowane egzemplarze będą różne, nawet jeśli w oryginale była to ta sama referencja.
Błąd nr 2: niepoprawne użycie writeReplace() i readResolve(). Te metody mogą podmienić obiekt w trakcie (de)serializacji, co zmieni jego tożsamość. Jeśli nie rozumiesz mechaniki, możesz otrzymać na wyjściu nieoczekiwane egzemplarze.
Błąd nr 3: nieoczekiwane skutki wspólnych, modyfikowalnych odwołań. Jeśli kilka obiektów wskazuje na jeden modyfikowalny obiekt wewnętrzny (na przykład listę), po deserializacji pozostanie tak samo. Zmiana w jednym miejscu wpłynie na wszystkie pozostałe.
Błąd nr 4: oczekiwanie „nowych” obiektów po serializacji. Serializacja nie tworzy „świeżej” struktury – odtwarza pierwotne odwołania, tak jak w oryginale. Może to być zaskakujące przy pracy z cache’ami lub obiektami wzorcowymi.
GO TO FULL VERSION