W3docs

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

Сериализация объектов Java в байты с помощью интерфейса Serializable, ObjectOutputStream и serialVersionUID.

В предыдущих главах рассматривались потоки содержимого — байты, символы, примитивы, строки. Сериализация — это шаг выше по лестнице: поток объектов. Вы вызываете writeObject(someObject), и JDK обходит весь граф ссылок от этого объекта, кодируя каждое поле каждого достижимого объекта в байты и записывая результат в поток. На стороне чтения readObject() восстанавливает граф.

Это серьёзное утверждение со значительной оговоркой. Сериализация работает и работает начиная с Java 1.1; вы встретите её в старых кодовых базах (RMI, EJB, репликация сессий, некоторые кэш-слои). Однако этот подход имеет известные проблемы — хрупкое версионирование, уязвимости безопасности, тесная связь между хранением данных и формой класса, — и Oracle публично пытается его упразднить уже несколько лет. Для нового кода ответом почти всегда является JSON или Protocol Buffers. Эта глава нужна для того, чтобы вы могли читать и поддерживать уже существующий код.

Механизм

Три составляющих:

  1. Маркерный интерфейс Serializable. Класс объявляет возможность сериализации, реализуя java.io.Serializable. У интерфейса нет методов; это флаг, который JDK проверяет во время выполнения.
  2. ObjectOutputStream. Декоратор, оборачивающий любой OutputStream и добавляющий writeObject(Object). Это движок, который обходит граф и записывает байты.
  3. ObjectInputStream (следующая глава). Зеркало, которое читает байты и восстанавливает граф.
class User implements Serializable {                 // the marker
  private static final long serialVersionUID = 1L;
  String name;
  int age;
  User(String name, int age) { this.name = name; this.age = age; }
}

try (ObjectOutputStream out = new ObjectOutputStream(Files.newOutputStream(path))) {
  out.writeObject(new User("alice", 30));            // the user is now on disk
}

Это минимальный рецепт. Класс реализует Serializable; запись выполняется через ObjectOutputStream; вызов — writeObject. При следующем чтении этого файла (рассматривается в следующей главе) вы получите обратно экземпляр User.

Что записывается

По умолчанию всё, достижимое от объекта:

  • Каждое поле, не помеченное transient и не являющееся static, через рефлексию, в порядке объявления.
  • Рекурсивно — каждый объект, на который ссылаются эти поля.
  • Для каждого задействованного класса — дескриптор (имя класса, типы полей и serialVersionUID), чтобы читатель мог проверить формат.

Формат — бинарный, самоописывающийся (содержит метаданные о классах), нечитаемый для человека. Он также специфичен для системы типов Java — байты кодируют смещения полей, имена типов и иерархии наследования, которые ничего не значат за пределами Java. Это ключевое ограничение: файл User.bin не может быть прочитан Python, Go или JavaScript без специального парсера.

transient: поля, которые не нужно сериализовать

Поле, помеченное transient, пропускается при сериализации. Читатель видит его как значение по умолчанию для его типа — null, 0, false. Используйте его для:

  • Кэшей, которые можно перестроить: transient Map<String, Result> cache;
  • Полей, которые не имеют смысла между JVM: transient Thread worker;, transient Connection db;
  • Чувствительных данных, которые не должны оказаться на диске: transient String password;
class Session implements Serializable {
  private static final long serialVersionUID = 1L;
  String userId;
  long createdAt;
  transient byte[] sessionToken;                     // never gets written
}

После десериализации Session будет иметь sessionToken == null. Ваш код должен обрабатывать отсутствие этого поля после восстановления.

Статические поля также пропускаются — static принадлежит классу, а не экземпляру, поэтому не является частью состояния отдельного объекта.

serialVersionUID: объявляйте его явно

Каждый сериализуемый класс имеет serialVersionUID — 64-битный номер версии, записываемый в поток и сверяемый с классом на стороне чтения. Если они не совпадают, десериализация выбрасывает InvalidClassException.

Всегда объявляйте его явно:

private static final long serialVersionUID = 1L;

Если этого не сделать, JVM вычислит его из формы класса — каждое поле, каждая сигнатура метода, каждый интерфейс. Добавьте поле, измените тип возвращаемого значения метода, переименуйте параметр — и вычисленный UID изменится. Код, записавший User.bin с прошлонедельным классом, не сможет прочитать его с нынешним. В юнит-тестах это не проявится, потому что обе стороны видят один и тот же класс. Это проявится в продакшене, когда пользователь обновится.

Явное объявление UID даёт вам контроль. Увеличивайте его вручную только при внесении несовместимых изменений. (См. Javadoc Serializable для полных правил эволюции — они весьма запутанны.)

Что можно менять между версиями

Правила «совместимых» изменений удивительно строги. Приблизительно:

  • Безопасно: добавление новых полей, удаление transient/static-полей, расширение доступа (privatepublic).
  • Небезопасно: удаление нетранзитивных полей, изменение типа поля, изменение serialVersionUID класса, изменение цепочки наследования.

Суть: байты на диске связаны с формой иерархии классов, а не только с данными. Форматам долгосрочного хранения нужна собственная схема. Сериализация подходит для краткосрочных кэшей и внутри-JVM передачи, но ненадёжна для всего, что должно пережить деплой.

Весь граф, включая циклы

writeObject следует по каждой ссылке. Если User содержит Team, а Team содержит List<User>, включающий первого User, цикл обрабатывается: JDK отслеживает идентичность каждого записываемого объекта и при повторной встрече записывает обратную ссылку вместо рекурсивного обхода. Восстановленный граф на другой стороне имеет те же отношения идентичности.

Это мощно и в то же время опасно. Сериализуемый объект тянет за собой всё достижимое — и если любой из этих объектов не является Serializable, запись завершится ошибкой NotSerializableException с именем проблемного типа. Исправление: реализовать Serializable на нарушителе, пометить поле transient или перестроить класс так, чтобы он не хранил эту ссылку.

Безопасность: никогда не десериализуйте недоверенные байты

Это преимущественно тема следующей главы, но последствия влияют и на сторону записи. Формат сериализации Java выполняет код на стороне читателя — конструкторы классов и хуки readObject — при десериализации. Специально созданные потоки байтов использовались для удалённого выполнения кода против всех крупных Java-серверов. Правило, сложившееся по итогам многолетних CVE:

Не десериализуйте байты из любого источника, которым вы не управляете полностью.

На стороне записи это означает: не проектируйте протоколы, где одна сторона сериализует данные через ObjectOutputStream, а другая десериализует их через ObjectInputStream. Используйте JSON или Protocol Buffers на границах доверия; оставьте сериализацию для случаев «та же JVM, тот же класслоадер, та же зона доверия».

Когда использовать сериализацию (и когда нет)

Используйте её, когда:

  • Нужно создать контрольную точку для графа объектов в той же JVM для восстановления после перезапуска.
  • Вы работаете с существующим фреймворком (RMI, JMX, EJB, некоторые механизмы репликации сессий), который её требует.
  • Вам нужна реализация из 10 строк для файла «сохранение игры», совместимость которого можно сломать в любой момент.

Не используйте её, когда:

  • Формат должен пережить деплой. Используйте формат со схемой версионирования (JSON с полем версии, Protobuf, Avro).
  • Данные пересекают границу доверия. Используйте JSON или Protobuf.
  • Данные должны читать или записывать другие языки. Формат сериализации Java работает только с Java.

Для большинства нового кода Jackson.writeValueAsString(obj) в JSON-файл является лучшим выбором. Он гибок, читаем человеком и может быть распарсен из любого языка.

Практический пример: запись графа записей

Программа ниже определяет два простых сериализуемых типа, Department и Employee, с обратной ссылкой (каждый Employee знает свой Department, а каждый Department хранит список своих Employee — цикл). Она записывает граф через ObjectOutputStream, выводит количество байтов и демонстрирует NotSerializableException, которое возникает при наличии несериализуемого поля. Чтение байтов обратно — тема следующей главы; здесь мы фокусируемся на стороне записи.

java— editable, runs on the server

Что можно вынести из запуска:

  • Один вызов writeObject(eng) сериализовал Department, всех трёх Employees, обратные ссылки от Employee к Department и список внутри Department. Это главная особенность сериализации: графы, а не записи. Циклы обрабатываются, идентичность сохраняется, ручного обхода не требуется.
  • Первые четыре байта были AC ED 00 05 — «магическое число» сериализации Java и версия потока. Каждый сериализованный файл начинается с этих байтов. Если вы видите такой заголовок в файле, найденном в продакшене, перед вами вывод ObjectOutputStream.
  • Дамп байтов содержал "alice" (нетранзитивное поле) и не содержал "hash-A" (поле с transient). Пометка поля transient — это официальный способ его исключить. Чувствительные поля (пароли, токены, ключи сессий) должны быть transient.
  • Запись BadEmployee выбросила NotSerializableException, а сообщение указало на Settings — именно несериализуемый тип. Так и находят нарушителей: попытайтесь записать, прочитайте исключение, исправьте названный класс (или пометьте поле transient). Проверка происходит на уровне поля, а не класса — достаточно одной нессериализуемой ссылки.
  • serialVersionUID = 1L был объявлен для каждого сериализуемого класса. Текущий запуск не заметит его отсутствия, но будущий вы, рефакторящий класс и пытающийся загрузить старый файл с новым кодом, заметит немедленно. Объявляйте его; увеличивайте намеренно при внесении несовместимых изменений.

Что дальше

В этой главе рассматривалась запись — Serializable, ObjectOutputStream, обход графа, формат. Чтение и восстановление графа — это зеркальная операция со своим набором подводных камней (безопасность — самый серьёзный из них). Это тема следующей главы, Десериализация в Java.

Практика

Практика
Класс `Employee` имеет поле `transient String sessionToken`. Значение токена — `'abc123'` на момент сериализации. После десериализации в новой JVM чему равно `sessionToken` восстановленного объекта?
Класс `Employee` имеет поле `transient String sessionToken`. Значение токена — `'abc123'` на момент сериализации. После десериализации в новой JVM чему равно `sessionToken` восстановленного объекта?
Was this page helpful?