1. Вступ
У Java (і загалом в ООП) кожен об’єкт має ідентичність — це не просто набір значень його полів, а саме «хто він такий» у пам’яті. Два об’єкти можуть бути «еквівалентними» (наприклад, a.equals(b) повертає true), але відрізнятися за ідентичністю (a != b). Ідентичність — це коли дві змінні посилаються на один і той самий об’єкт у пам’яті (a == b).
Чому це важливо для серіалізації?
Коли ви серіалізуєте (зберігаєте) граф об’єктів (наприклад, дерево, список або просто два об’єкти, які посилаються на один і той самий вкладений об’єкт), важливо зберегти не лише значення полів, а й структуру посилань між об’єктами.
Якщо у вашому графі є повторні посилання (кілька об’єктів посилаються на один і той самий вкладений об’єкт) або навіть циклічні посилання (A → B → A), то після десеріалізації ці зв’язки мають лишитися такими самими.
Приклад: у вас є два об’єкти A і B, обидва посилаються на один і той самий об’єкт C. Після серіалізації та десеріалізації має бути: a.c == b.c (один і той самий об’єкт у пам’яті). Якщо серіалізація просто «копіює» об’єкти, то після відновлення вийдуть два різні об’єкти C — і це вже інший граф.
2. Як ObjectOutputStream розв’язує проблему ідентичності
У Java для бінарної серіалізації використовується пара класів: ObjectOutputStream (для запису) і ObjectInputStream (для читання).
ObjectOutputStream — це не просто «записати об’єкт у файл». Він «розумний»:
- Відстежує всі об’єкти, які вже були серіалізовані в межах одного потоку запису (однієї сесії).
- Якщо зустрічається посилання на об’єкт, який уже серіалізовано, він не пише його заново, а записує спеціальне «повторне посилання» (reference handle).
- Під час десеріалізації ObjectInputStream відновлює структуру так, щоб повторні посилання вказували на один і той самий об’єкт у пам’яті.
Це працює навіть для циклічних посилань! Тобто, якщо у вас є A → B → A, серіалізація та десеріалізація не увійдуть у нескінченний цикл і не «заваляться»: посилання буде відновлено коректно.
Як це реалізовано?
Усередині ObjectOutputStream використовується спеціальна таблиця (identity map), де зберігаються вже серіалізовані об’єкти. Коли трапляється новий об’єкт, він додається в таблицю і серіалізується повністю. Якщо зустрічається об’єкт із таблиці — у потік записується лише «повторне посилання» (handle).
3. Демонстрація на прикладі
Приклад 1: Граф із циклічними посиланнями (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; // Цикл!
// Серіалізація
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("cyclic.dat"))) {
out.writeObject(a);
}
// Десеріалізація
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!
}
}
Виведення:
a2.name = A
a2.next.name = B
a2.next.next == a2: true
Коментар:
Цикл збережено! Після десеріалізації a2.next.next знову вказує на a2.
Приклад 2: Повторні посилання (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;
// Серіалізація
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("shared.dat"))) {
out.writeObject(new Wrapper[] {a, b});
}
// Десеріалізація
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
}
}
Виведення:
a2.ref == b2.ref: true
a2.ref.name = C
Коментар:
Після десеріалізації обидва посилання знову вказують на один і той самий об’єкт.
4. Зв’язок із writeReplace та readResolve
У Java можна «втрутитися» в процес (де)серіалізації за допомогою спеціальних методів:
writeReplace() — викликається перед серіалізацією об’єкта. Можна повернути інший об’єкт, який буде серіалізовано замість вихідного.
readResolve() — викликається після десеріалізації об’єкта. Можна повернути інший об’єкт, який буде використано замість щойно створеного.
Вплив на ідентичність:
- Якщо використовується writeReplace() або readResolve(), то саме об’єкт, повернений цими методами, потрапить у таблицю посилань.
- Це може змінити ідентичність: якщо readResolve() повертає новий/інший об’єкт, повторні посилання можуть вказувати на різні екземпляри.
- Будьте обережні: ви можете як «зламати» ідентичність, так і навмисно забезпечити її (класичний приклад — синглтон через readResolve(), де всі посилання після десеріалізації вказують на один і той самий синглтон).
5. Практика: код, що демонструє збереження ідентичності
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);
// Серіалізація
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("identity.dat"))) {
out.writeObject(new Holder[] {a, b});
}
// Десеріалізація
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
}
}
Виведення:
a2.shared == b2.shared: true
a2.shared.value = C
Коментар:
Ідентичність об’єкта c збережено: після десеріалізації обидва посилання вказують на один і той самий екземпляр.
6. Підсумки та типові помилки
Помилка № 1: серіалізація об’єктів із повторними посиланнями окремо. Якщо ви записуєте кожен об’єкт окремим екземпляром ObjectOutputStream, посилання між ними не збережуться — десеріалізовані екземпляри будуть різними, навіть якщо в оригіналі це було одне й те саме посилання.
Помилка № 2: некоректне використання writeReplace() та readResolve(). Ці методи можуть підмінити об’єкт під час (де)серіалізації, що змінить його ідентичність. Якщо не розуміти механіку, можна отримати неочікувані екземпляри на виході.
Помилка № 3: неочікувані ефекти від спільних змінюваних посилань. Якщо кілька об’єктів посилаються на один змінюваний вкладений об’єкт (наприклад, список), після десеріалізації це залишиться так само. Зміна в одному місці вплине на всі інші.
Помилка № 4: очікування «нових» об’єктів після серіалізації. Серіалізація не створює «свіжу» структуру — вона відновлює вихідні посилання, як в оригіналі. Це може бути неочікуваним під час роботи з кешами або шаблонними об’єктами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ