Сериализация в Java
Сериализация объектов Java в байты с помощью интерфейса Serializable, ObjectOutputStream и serialVersionUID.
В предыдущих главах рассматривались потоки содержимого — байты, символы, примитивы, строки. Сериализация — это шаг выше по лестнице: поток объектов. Вы вызываете writeObject(someObject), и JDK обходит весь граф ссылок от этого объекта, кодируя каждое поле каждого достижимого объекта в байты и записывая результат в поток. На стороне чтения readObject() восстанавливает граф.
Это серьёзное утверждение со значительной оговоркой. Сериализация работает и работает начиная с Java 1.1; вы встретите её в старых кодовых базах (RMI, EJB, репликация сессий, некоторые кэш-слои). Однако этот подход имеет известные проблемы — хрупкое версионирование, уязвимости безопасности, тесная связь между хранением данных и формой класса, — и Oracle публично пытается его упразднить уже несколько лет. Для нового кода ответом почти всегда является JSON или Protocol Buffers. Эта глава нужна для того, чтобы вы могли читать и поддерживать уже существующий код.
Механизм
Три составляющих:
- Маркерный интерфейс
Serializable. Класс объявляет возможность сериализации, реализуяjava.io.Serializable. У интерфейса нет методов; это флаг, который JDK проверяет во время выполнения. ObjectOutputStream. Декоратор, оборачивающий любойOutputStreamи добавляющийwriteObject(Object). Это движок, который обходит граф и записывает байты.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-полей, расширение доступа (
private→public). - Небезопасно: удаление нетранзитивных полей, изменение типа поля, изменение
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, которое возникает при наличии несериализуемого поля. Чтение байтов обратно — тема следующей главы; здесь мы фокусируемся на стороне записи.
Что можно вынести из запуска:
- Один вызов
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.