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() возвращает новый/другой объект, повторные ссылки могут указывать на разные экземпляры.
- Будьте осторожны: вы можете как «сломать» идентичность, так и намеренно обеспечить её (классический пример — Singleton через 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: ожидание «новых» объектов после сериализации. Сериализация не создаёт «свежую» структуру — она восстанавливает исходные ссылки, как в оригинале. Это может быть неожиданным при работе с кэшами или шаблонными объектами.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ