Сериализация

Определение

Сериализация превращает состояние объекта в представление для хранения или передачи; десериализация восстанавливает объект из этого представления.

Нужно различать:

  • нативную 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-класса при чтении.
  • Десериализовывать непроверенные байты.
  • Менять формат без теста чтения предыдущей версии.

Вопросы на собеседовании

  1. Что делает marker interface Serializable?
  2. Какие поля не входят в стандартный поток?
  3. Для чего нужен serialVersionUID и чего он не гарантирует?
  4. Какие конструкторы вызываются при десериализации?
  5. Как ObjectOutputStream обрабатывает повторные ссылки и циклы?
  6. Почему нативную Java-десериализацию нельзя принимать от недоверенного клиента?

Связанные заметки

Работа с исключениями в Java · Объекты Java · Наследование · Immutable классы и данные