Сериализация и десериализация объектов в Java
Поле объявили transient, объект записали в файл, прочитали обратно — и вызов bird.getRing().getSize() падает с NullPointerException. Причина в том, что при десериализации transient-поле не восстанавливается, а конструктор объекта не вызывается, поэтому ссылка остаётся null. Разберёмся, как устроен этот механизм.
Сериализация — это процесс преобразования состояния объекта в последовательность байтов, которую можно сохранить в файл, базу данных или передать по сети. Десериализация — обратный процесс: восстановление объекта из этих байтов. В Java объект можно сериализовать стандартными средствами, если его класс реализует интерфейс java.io.Serializable.
1. Что такое сериализация и десериализация
Сериализация данных — это перевод объекта из памяти программы в формат, пригодный для хранения или передачи (поток байтов). Десериализация — восстановление из этого потока объекта с тем же состоянием. Сериализовать объект — значит записать его в поток байтов; десериализовать — прочитать байты и получить объект обратно.
Зачем это нужно на практике:
- сохранить состояние программы между запусками (например, корзину покупателя или настройки);
- передать объект в другой процесс или на другую машину (Java RMI, распределённые кеши, HTTP-сессии в кластере);
- сделать глубокую копию объекта (записать и сразу прочитать).
В Java за встроенную сериализацию отвечает Java Serialization API из пакета java.io: интерфейсы ObjectOutput и ObjectInput и реализующие их классы ObjectOutputStream и ObjectInputStream.
2. Как сериализовать объект в Java
Чтобы сериализовать объект в Java, его класс должен реализовать интерфейс Serializable, а сам объект нужно передать в метод writeObject() класса ObjectOutputStream. Для десериализации используется ObjectInputStream.readObject() с приведением результата к нужному типу.
Интерфейс java.io.Serializable не содержит ни одного метода — это интерфейс-маркер. Он лишь сообщает JVM, что объекты класса разрешено сериализовать. При попытке сериализовать объект, класс которого не реализует Serializable, будет брошено java.io.NotSerializableException.
import java.io.Serializable;
public class Cat implements Serializable {
private String name;
public Cat(String name) {
this.name = name;
}
public String getName() {
return name;
}
} Порядок действий при сериализации:
- Создать выходной поток
OutputStream(например,FileOutputStream). - Передать его в конструктор
ObjectOutputStream. - Вызвать
writeObject(obj)— объект будет сериализован и записан в поток.
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeCat {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
serialize();
Cat cat = deserialize();
System.out.println("Восстановлен кот: " + cat.getName());
}
private static void serialize() {
Cat cat = new Cat("Барсик");
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
os.writeObject(cat);
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
private static Cat deserialize() {
Cat cat = null;
try (FileInputStream fis = new FileInputStream(FILE_NAME);
ObjectInputStream ois = new ObjectInputStream(fis)) {
cat = (Cat) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
return cat;
}
}
// Вывод: Восстановлен кот: Барсик Восстанавливать объект можно в другой программе и на другой машине — главное, чтобы там в classpath был тот же класс с совместимым serialVersionUID. Иначе readObject() бросит ClassNotFoundException или InvalidClassException.
3. Как работает десериализация
При десериализации JVM выделяет память под объект и заполняет его поля значениями из потока, не вызывая конструктор сериализуемого класса. Поэтому логика инициализации из конструктора (проверки, значения по умолчанию, открытие ресурсов) при чтении объекта не выполняется.
Что происходит с разными видами полей:
| Поле | Пишется в поток? | Значение после десериализации |
|---|---|---|
| Обычное поле (примитив или Serializable-объект) | Да | Значение из потока |
transient | Нет | Значение по умолчанию: null, 0, false |
static | Нет | Текущее значение статического поля в этой JVM (принадлежит классу, а не объекту) |
| Поле несериализуемого родительского класса | Нет | То, что присвоит конструктор без параметров родителя (см. наследование) |
Ссылка на объект класса без Serializable, без transient | — | Сериализация не выполнится: NotSerializableException при writeObject() |
Модификаторы доступа на сериализацию не влияют: private-поля сохраняются так же, как и public. Поле с инициализатором (private transient int count = 10;) после десериализации всё равно будет равно 0 — инициализаторы полей выполняются в конструкторе, а он не вызывается.
4. Граф объектов
Граф исходного объекта — это сам объект и все объекты, достижимые из него по ссылкам (включая ссылки внутри вложенных объектов). При сериализации записывается весь граф, поэтому каждый объект в нём должен быть сериализуемым, а при десериализации граф восстанавливается целиком вместе со связями между объектами.
Если несколько ссылок указывают на один и тот же объект, в восстановленном графе они тоже будут указывать на один объект. Механизм сериализации запоминает уже записанные объекты и при повторной встрече пишет только ссылку на них. Благодаря этому корректно сохраняются перекрёстные и циклические ссылки, и объект не записывается дважды.
import java.io.Serializable;
public class Dog implements Serializable {
private Collar theCollar;
public Dog(Collar collar) {
theCollar = collar;
}
public Collar getCollar() {
return theCollar;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Dog dog = (Dog) o;
return theCollar != null ? theCollar.equals(dog.theCollar) : dog.theCollar == null;
}
@Override
public int hashCode() {
return theCollar != null ? theCollar.hashCode() : 0;
}
} import java.io.Serializable;
public class Collar implements Serializable {
private int collarSize;
public Collar(int size) {
collarSize = size;
}
public int getCollarSize() {
return collarSize;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Collar collar = (Collar) o;
return getCollarSize() == collar.getCollarSize();
}
@Override
public int hashCode() {
return getCollarSize();
}
} Что выведет этот код? Обратите внимание на последние две строки main().
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeDog {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
Collar collar = new Collar(3);
Dog dog1 = new Dog(collar);
Dog dog2 = new Dog(collar);
serialize(dog1, dog2);
Dog[] deserializedDogs = deserialize(2);
System.out.println(dog1.equals(deserializedDogs[0]));
System.out.println(dog2.equals(deserializedDogs[1]));
Collar desCollar1 = deserializedDogs[0].getCollar();
Collar desCollar2 = deserializedDogs[1].getCollar();
System.out.println(desCollar1.equals(desCollar2));
System.out.println(desCollar1 == desCollar2);
System.out.println(dog1 == deserializedDogs[0]);
}
private static void serialize(Dog... dogs) {
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
for (Dog dog : dogs) {
os.writeObject(dog);
}
} catch (IOException e) {
System.out.println(e.getMessage());
}
}
private static Dog[] deserialize(int dogNumber) {
Dog[] dogs = new Dog[dogNumber];
try (FileInputStream fs = new FileInputStream(FILE_NAME);
ObjectInputStream os = new ObjectInputStream(fs)) {
for (int i = 0; i < dogNumber; i++) {
dogs[i] = (Dog) os.readObject();
}
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
return dogs;
}
}
// Вывод:
// true
// true
// true
// true - обе собаки после десериализации ссылаются на ОДИН объект Collar
// false - десериализация всегда создаёт новый объект Неочевидный момент
Общая ссылка сохраняется только в пределах одного ObjectOutputStream. Если записать dog1 и dog2 в два разных потока (или файла), после чтения у каждой собаки будет свой отдельный объект Collar, и desCollar1 == desCollar2 станет false.
5. Ключевое слово transient
Модификатор transient исключает поле из сериализации: его значение не записывается в поток, а после десериализации поле получает значение по умолчанию для своего типа. Так помечают поля, которые не нужно или нельзя сохранять: пароли, кеши, вычисляемые значения, ссылки на несериализуемые объекты.
Если поле ссылается на объект класса, который не реализует Serializable, есть два варианта: сделать этот класс сериализуемым или пометить поле как transient. Если не сделать ни того ни другого, writeObject() бросит java.io.NotSerializableException с именем несериализуемого класса.
import java.io.Serializable;
public class Bird implements Serializable {
private String name;
private transient Ring ring;
public Bird(String name, Ring ring) {
this.name = name;
this.ring = ring;
}
public Ring getRing() {
return ring;
}
} public class Ring { // не реализует Serializable
private int size;
public Ring(int size) {
this.size = size;
}
public int getSize() {
return size;
}
} import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeBird {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
Bird bird = new Bird("pigeon", new Ring(3));
System.out.println("Размер кольца перед сериализацией: "
+ bird.getRing().getSize());
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
os.writeObject(bird);
} catch (IOException e) {
System.out.println(e.getMessage());
}
try (FileInputStream fis = new FileInputStream(FILE_NAME);
ObjectInputStream ois = new ObjectInputStream(fis)) {
bird = (Bird) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
// bird.getRing().getSize() здесь бросил бы NullPointerException
System.out.println("Кольцо после десериализации: " + bird.getRing());
}
}
// Вывод:
// Размер кольца перед сериализацией: 3
// Кольцо после десериализации: null Если убрать transient у поля ring, программа не дойдёт до чтения: writeObject() бросит NotSerializableException, и в консоль будет выведено сообщение Ring (имя класса). Как сохранить значение transient-поля вручную, показано в разделе writeObject() и readObject().
6. serialVersionUID и версии класса
serialVersionUID — это номер версии сериализуемого класса, который записывается в поток вместе с объектом. При десериализации JVM сравнивает номер из потока с номером класса в classpath; если они не совпадают, readObject() бросает java.io.InvalidClassException.
Если поле не объявлено явно, JVM вычисляет его по структуре класса (имя, интерфейсы, поля, методы). Любое изменение класса — даже добавление метода — может изменить вычисленное значение, а разные компиляторы могут вычислять его по-разному. Поэтому версию лучше объявлять явно:
import java.io.Serial;
import java.io.Serializable;
public class Cat implements Serializable {
@Serial // Java 14+: компилятор проверит объявление
private static final long serialVersionUID = 1L;
private String name;
// ...
} Если записать объект с serialVersionUID = 1L, поменять в классе значение на 2L и попытаться прочитать файл, получим:
java.io.InvalidClassException: Cat; local class incompatible:
stream classdesc serialVersionUID = 1, local class serialVersionUID = 2 При явно заданном и неизменном serialVersionUID совместимые изменения (добавление нового поля, удаление поля) не ломают чтение старых данных: новое поле получит значение по умолчанию, лишнее поле из потока будет проигнорировано. Менять номер стоит, только когда старые данные действительно нельзя читать новой версией класса (например, изменился тип поля).
7. Десериализация и наследование
Если сериализуемый класс наследуется от класса, который не реализует Serializable, поля такого родителя не сохраняются. При десериализации они инициализируются вызовом конструктора без параметров ближайшего несериализуемого родителя, а поля самого сериализуемого класса восстанавливаются из потока. Конструктор десериализуемого класса по-прежнему не вызывается.
Конструктор без параметров несериализуемого родителя должен существовать и быть доступен из подкласса. Если его нет, readObject() бросит java.io.InvalidClassException с сообщением вида Bug; no valid constructor.
import java.util.Objects;
public class Insect { // не реализует Serializable
private String name;
public Insect(String type) {
this.name = type;
}
public Insect() {
}
public String getName() {
return name;
}
public void setName(String type) {
this.name = type;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Insect insect = (Insect) o;
return Objects.equals(name, insect.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
@Override
public String toString() {
return "Insect{" +
"name='" + name + '\'' +
'}';
}
} import java.io.Serializable;
import java.util.Objects;
public class Bug extends Insect implements Serializable {
private boolean fly;
public Bug(String type, boolean fly) {
super(type);
this.fly = fly;
}
public boolean isFly() {
return fly;
}
public void setFly(boolean fly) {
this.fly = fly;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
if (!super.equals(o)) return false;
Bug bug = (Bug) o;
return fly == bug.fly;
}
@Override
public int hashCode() {
return Objects.hash(super.hashCode(), fly);
}
@Override
public String toString() {
return "Bug{" +
"fly=" + fly +
"} " + super.toString();
}
} import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
public class SerializeInsectExample {
private static final String FILE_NAME = "testSer.ser";
public static void main(String[] args) {
Bug bug = new Bug("Майский жук", true);
System.out.println("До сериализации " + bug);
try (FileOutputStream fs = new FileOutputStream(FILE_NAME);
ObjectOutputStream os = new ObjectOutputStream(fs)) {
os.writeObject(bug);
} catch (IOException e) {
System.out.println(e.getMessage());
}
try (FileInputStream fis = new FileInputStream(FILE_NAME);
ObjectInputStream ois = new ObjectInputStream(fis)) {
bug = (Bug) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
System.out.println(e.getMessage());
}
System.out.println("После десериализации " + bug);
}
}
// Вывод:
// До сериализации Bug{fly=true} Insect{name='Майский жук'}
// После десериализации Bug{fly=true} Insect{name='null'} Поле fly принадлежит сериализуемому классу Bug и восстановлено из потока. Поле name объявлено в несериализуемом Insect, поэтому получило значение из конструктора Insect(), то есть null. Если удалить из Insect конструктор без параметров, запись пройдёт успешно, а чтение завершится InvalidClassException.
8. Serializable и Externalizable
Интерфейс Externalizable расширяет Serializable и передаёт программисту полный контроль над форматом: класс сам записывает и читает свои данные в методах writeExternal() и readExternal(). При стандартной сериализации (Serializable) поля записываются автоматически.
| Признак | Serializable | Externalizable |
|---|---|---|
| Методы для реализации | Нет (маркер); опционально private writeObject() / readObject() | Обязательно writeExternal(ObjectOutput) и readExternal(ObjectInput) |
| Что записывается | Все нестатические и не-transient поля автоматически | Только то, что явно записано в writeExternal() |
| Конструктор при чтении | Не вызывается (кроме конструктора несериализуемого родителя) | Вызывается public-конструктор без параметров; без него — InvalidClassException |
Роль transient | Исключает поле | Не имеет значения: решает код writeExternal() |
| Когда выбирать | Почти всегда: просто и надёжно | Нужен компактный или особый формат, важна скорость |
Частичный контроль без перехода на Externalizable дают приватные методы writeObject() и readObject() в самом сериализуемом классе. JVM вызывает их вместо стандартной логики. Например, так можно сохранить размер кольца из transient-поля класса Bird:
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serial;
import java.io.Serializable;
public class Bird implements Serializable {
private String name;
private transient Ring ring;
// конструктор и getRing() - как в разделе 5
@Serial
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject(); // обычные поля (name)
out.writeInt(ring != null ? ring.getSize() : -1);
}
@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
int size = in.readInt();
ring = size >= 0 ? new Ring(size) : null;
}
}
// SerializeBird теперь выведет: Кольцо после десериализации: Ring@...
// и bird.getRing().getSize() вернёт 3 Обратите внимание
Записи (record, Java 16+) тоже сериализуются через Serializable, но десериализуются иначе: JVM вызывает канонический конструктор record. Значит, проверки в конструкторе выполняются и при чтении из потока, а методы writeObject()/readObject() для record игнорируются.
9. Сериализация и маршалинг
Маршалинг (marshalling) — это подготовка данных к передаче между процессами или системами, а сериализация — преобразование объекта в поток байтов. В бытовой речи термины часто используют как синонимы, но маршалинг шире: он может включать дополнительную информацию, необходимую для передачи (например, в Java RMI вместе с объектом передаётся адрес, откуда загрузить его класс).
В Java слово «маршалинг» чаще всего встречается в двух контекстах:
- Java RMI — аргументы и результаты удалённого вызова маршалятся с помощью стандартной сериализации;
- JAXB — класс
Marshallerпревращает объект в XML,Unmarshaller— обратно.
Встроенная сериализация Java создаёт бинарный формат, понятный только Java-программам. Для обмена данными с другими системами обычно сериализуют объекты в JSON (библиотеки Jackson, Gson), XML или Protocol Buffers: эти форматы не зависят от языка и не исполняют код при чтении.
10. Почему не сериализуется объект: частые ошибки
Чаще всего объект не сериализуется из-за того, что в его графе есть объект класса без Serializable: writeObject() бросает NotSerializableException с именем этого класса. Остальные типичные ошибки возникают уже при чтении — из-за изменённой версии класса, отсутствия конструктора или несовпадения количества записанных и прочитанных объектов.
| Симптом | Причина | Как исправить |
|---|---|---|
NotSerializableException: Ring при записи | Поле ссылается на объект класса без Serializable (на любом уровне графа) | Реализовать Serializable в этом классе или пометить поле transient |
NotSerializableException с именем внешнего класса | Сериализуется экземпляр нестатического внутреннего или анонимного класса: он хранит скрытую ссылку на внешний объект | Сделать вложенный класс static или вынести в отдельный файл |
NotSerializableException для лямбды в поле | Обычная лямбда не сериализуема | Не хранить лямбды в сериализуемых полях или пометить поле transient |
InvalidClassException: ... local class incompatible | Изменился serialVersionUID (явный или вычисленный) | Объявить serialVersionUID явно и не менять его без необходимости |
InvalidClassException: ... no valid constructor | У несериализуемого родителя нет доступного конструктора без параметров | Добавить такой конструктор в родителя |
ClassNotFoundException при чтении | Класса нет в classpath читающей программы | Подключить тот же класс (тот же пакет и имя) |
EOFException | Прочитано больше объектов, чем записано | Записывать коллекцию одним объектом или сначала количество элементов |
NullPointerException после чтения | Поле было transient и стало null | Проверять на null или восстанавливать поле в readObject() |
11. Безопасность десериализации
Десериализация данных из недоверенного источника опасна: readObject() создаёт объекты любых сериализуемых классов из classpath, и специально подготовленный поток может запустить цепочку вызовов, ведущую к выполнению произвольного кода или исчерпанию памяти. Поэтому встроенную сериализацию Java нельзя применять к данным, пришедшим от пользователя или по сети без проверки.
Что делать:
- не принимать сериализованные Java-объекты извне; для обмена данными использовать JSON или Protobuf;
- если без десериализации не обойтись — ограничить допустимые классы фильтром
ObjectInputFilter(JEP 290, Java 9; перенесён в 8u121). В Java 17 добавлены фабрики фильтров для всего приложения (JEP 415); - валидировать состояние объекта в
readObject()так же, как в конструкторе.
ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
"com.example.model.*;java.base/*;maxdepth=10;!*");
try (ObjectInputStream ois = new ObjectInputStream(new FileInputStream(FILE_NAME))) {
ois.setObjectInputFilter(filter);
Object obj = ois.readObject(); // класс вне списка -> InvalidClassException: filter status: REJECTED
} Тот же фильтр можно задать для всей JVM без изменения кода — системным свойством -Djdk.serialFilter=....
Часто задаваемые вопросы
Сериализуются ли private-поля и поля без геттеров?
Да. Встроенная сериализация работает с полями напрямую, минуя геттеры и сеттеры, поэтому модификаторы доступа значения не имеют. Исключаются только поля static и transient. Сводка по видам полей — в разделе Как работает десериализация.
Нужно ли менять serialVersionUID, если я добавил в класс новое поле?
Нет. Добавление поля — совместимое изменение: старые данные прочитаются, а новое поле получит значение по умолчанию. Номер меняют, только когда хотят намеренно запретить чтение старых данных. Подробнее — в разделе serialVersionUID и версии класса.
Можно ли сериализовать ArrayList или HashMap?
Да, стандартные коллекции реализуют Serializable. Но сериализуемыми должны быть и все их элементы (а для HashMap — ключи и значения), иначе запись завершится NotSerializableException. Объявляйте поле как List, но следите, чтобы реальная реализация была сериализуемой.
Как сделать глубокую копию объекта через сериализацию?
Записать объект в ByteArrayOutputStream через ObjectOutputStream и сразу прочитать из ByteArrayInputStream: получится независимая копия всего графа. Способ простой, но медленный; другие варианты копирования разобраны в уроке Клонирование объектов в Java.
Используют ли встроенную сериализацию Java в новых проектах?
Редко. Для обмена данными и хранения обычно выбирают JSON или Protobuf: они быстрее, понятны другим языкам и безопаснее. Встроенная сериализация остаётся в legacy-коде, RMI, некоторых кешах и HTTP-сессиях, а её устройство регулярно спрашивают на собеседованиях.
Практика по теме
Закрепите урок на задачах — у каждой есть уровень и оценка времени.
- Студент и Аспирант: сохранение группы в файл (сериализация)
- Сохранение последней корзины (сериализация)
- Глубокая копия заказа: clone(), конструктор копирования и сериализация
Комментарии