Сериализация
Определение
Сериализация превращает состояние объекта в представление для хранения или передачи; десериализация восстанавливает объект из этого представления.
Нужно различать:
- нативную Java-сериализацию
Serializable/ObjectOutputStream; - явные форматы данных: JSON, XML, Protobuf, Avro и другие;
- сериализацию, которую выполняет конкретный фреймворк.
Формат является контрактом. Для долговременного хранения и межсервисного обмена явная схема обычно устойчивее, чем внутреннее устройство Java-класса.
Нативная Java-сериализация
Serializable — marker interface:
final class SessionSnapshot implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private final String id;
private final Instant createdAt;
private transient String accessToken;
SessionSnapshot(String id, Instant createdAt, String accessToken) {
this.id = id;
this.createdAt = createdAt;
this.accessToken = accessToken;
}
}static-поля не относятся к состоянию экземпляра и не сериализуются. transient исключает поле из стандартного потока; после чтения оно получает значение по умолчанию, если не восстановлено вручную.
Весь достижимый граф нестатических non-transient-ссылок должен быть сериализуемым. Иначе запись завершится NotSerializableException.
Запись и чтение
byte[] write(SessionSnapshot snapshot) throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream output = new ObjectOutputStream(bytes)) {
output.writeObject(snapshot);
}
return bytes.toByteArray();
}
SessionSnapshot read(byte[] data)
throws IOException, ClassNotFoundException {
try (ObjectInputStream input = new ObjectInputStream(
new ByteArrayInputStream(data))) {
return (SessionSnapshot) input.readObject();
}
}ObjectOutputStream отслеживает уже записанные объекты: повторные ссылки и циклы графа сохраняют идентичность в рамках потока. Метод writeUnshared имеет другой контракт и нужен редко.
serialVersionUID
serialVersionUID идентифицирует сериализованную версию класса. Если его не объявить, JVM вычислит значение из структуры класса; даже небольшое изменение может сделать старые данные несовместимыми.
Совпадающий UID не гарантирует логическую совместимость. Разработчик всё равно обязан понять:
- какие поля появились или исчезли;
- какие значения получат новые поля;
- сохранены ли инварианты;
- умеет ли новая версия читать старые данные.
Нативный поток тесно связан с именами классов и их структурой, поэтому плохо подходит как независимый публичный протокол.
Конструкторы при чтении
Конструкторы Serializable-классов не выполняются обычным образом. Выполняется доступный no-args-конструктор первого несериализуемого суперкласса, после чего поля сериализуемой части восстанавливаются из потока.
Значит, инварианты только в конструкторе недостаточны: специально настроенное чтение должно валидировать восстановленное состояние.
@Serial
private void readObject(ObjectInputStream input)
throws IOException, ClassNotFoundException {
input.defaultReadObject();
if (id == null || createdAt == null) {
throw new InvalidObjectException("Incomplete snapshot");
}
}Также существуют hooks writeObject, readResolve, writeReplace, но они усложняют контракт и должны быть покрыты тестами совместимости.
Externalizable
Externalizable требует самостоятельно реализовать writeExternal и readExternal и иметь public no-args-конструктор. Он даёт полный контроль над форматом, но легко нарушить порядок полей, совместимость и инварианты. Для нового внешнего протокола обычно лучше библиотека с явной схемой.
Безопасность
Десериализация недоверенного нативного Java-потока опасна: поток управляет построением графа объектов и может активировать неожиданные цепочки поведения доступных классов.
Правила:
- не принимать
ObjectInputStreamиз недоверенного источника; - предпочитать ограниченный формат и явную схему;
- если legacy-поток неизбежен, применять
ObjectInputFilter, строгий allowlist, лимиты размера/глубины и изоляцию; - проверять инварианты после чтения;
- не считать шифрование заменой проверки допустимых типов.
Явный DTO-контракт
Для обмена лучше отделить доменный объект от формата:
record OrderMessage(
String id,
BigDecimal amount,
int schemaVersion
) {}Версия схемы, обязательность полей, формат времени/чисел и правила неизвестных полей становятся явными. Внутреннюю модель можно менять независимо через mapper.
Ловушки
- Сериализовать доменную entity как долгоживущий внешний контракт.
- Не объявлять
serialVersionUIDи надеяться на автоматическую совместимость. - Забывать про несериализуемое поле в глубине графа.
- Считать
transientзащитой секрета во всех других форматах. - Ожидать обычный вызов конструктора Serializable-класса при чтении.
- Десериализовывать непроверенные байты.
- Менять формат без теста чтения предыдущей версии.
Вопросы на собеседовании
- Что делает marker interface
Serializable? - Какие поля не входят в стандартный поток?
- Для чего нужен
serialVersionUIDи чего он не гарантирует? - Какие конструкторы вызываются при десериализации?
- Как ObjectOutputStream обрабатывает повторные ссылки и циклы?
- Почему нативную Java-десериализацию нельзя принимать от недоверенного клиента?
Связанные заметки
Работа с исключениями в Java · Объекты Java · Наследование · Immutable классы и данные