
Базовая и расширенная сериализация Java
Гид по сериализации в Java: сохранение состояния, контроль версий и защита данных через ObjectInputFilter в 2026 году.
Основные идеи
Мнение автора
Безопасность системы дико зависит от фильтрации через ObjectInputFilter. Рекомендую всегда явно прописывать serialVersionUID для стабильности. Очевидно, это единственный способ избежать нереальных багов при рефакторинге.
Сериализация — это процесс преобразования объекта Java в последовательность байтов, чтобы его можно было записать на диск, отправить по сети или сохранить вне памяти. Позже виртуальная машина Java (JVM) считывает эти байты и восстанавливает исходный объект. Этот процесс называется десериализацией.
В обычных условиях объекты существуют только в памяти и исчезают после завершения работы программы. Сериализация позволяет сохранить состояние объекта после завершения работы программы, которая его создала, или передать его между различными контекстами выполнения.
Интерфейс Serializable
в Java позволяет сериализовать не все объекты. Класс должен явно поддерживать сериализацию, реализуя Serializable интерфейс, как показано здесь:
Serializable implements Challenger class public {
private Long id;
private String name;
public Challenger(Long id, String name) {
this.id = id;
this.name = name;
}
}Serializable (java.io.Serializable) — это интерфейс-маркер, то есть он не определяет никаких методов. Реализуя его, класс сигнализирует JVM, что его экземпляры можно преобразовывать в байты. Если Java попытается сериализовать объект, класс которого не реализует интерфейс Serializable, во время выполнения возникнет ошибка NotSerializableException. Предупреждения во время компиляции не будет.
Сериализация проходит по всему объектному графу. Каждое непереходящее поле должно ссылаться на объект, который сам по себе сериализуем. Если какой-либо объект, на который ссылается ссылка, не может быть сериализован, вся операция завершается неудачей. Все примитивные типы-оболочки (Integer, Long, Boolean и другие), а также Stringреализуют Serializable, поэтому их можно безопасно использовать в сериализованных графах объектов.
Ограничения сериализации в Java
Сериализация сохраняет состояние экземпляра и ссылочную идентичность в графе объектов (общие ссылки и циклические ссылки). Она не сохраняет поведение или идентичность JVM при повторных запусках. При использовании сериализации помните о следующих рекомендациях:
- Поля экземпляра записываются в поток байтов.
- Поведение не сериализуется.
- Статические поля не сериализуются.
- Идентичность объекта сохраняется.
Также обратите внимание: если до сериализации два поля ссылаются на один и тот же объект, эта связь сохраняется после десериализации.
Пример сериализации в Java
В качестве примера сериализации рассмотрим следующий код игрока Java Challengers:
Челленджер новый = герцог Челленджер(1L, "Герцог");
Что вы заметили? Давайте разберёмся.
1. Запись объекта
Сначала Java проверяет, реализует ли класс Serializable, преобразует значения полей объекта в байты и записывает их в файл:
Изучите связанные с этим вопросы
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream(«duke.ser»))) {
out.writeObject(duke); }
2. Чтение объекта
Во время десериализации собственный конструктор сериализуемого класса не вызывается. JVM создает объект с помощью внутреннего механизма и присваивает значения полей непосредственно из сериализованных данных. Однако если класс наследует от несериализуемого суперкласса, то вызывается конструктор суперкласса без аргументов:
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("duke.ser"))) {
Challenger duke = (Challenger) in.readObject();
}Такое поведение часто удивляет разработчиков, когда они впервые отлаживают десериализованный объект, поскольку инварианты нарушаются без каких-либо уведомлений. Это различие также важно для иерархий классов, о которых мы поговорим далее в этой статье.
Обратные вызовы сериализации
Поскольку JVM управляет созданием объектов и восстановлением полей во время сериализации, она также предоставляет возможности для настройки того, как класс записывает и восстанавливает свое состояние. Класс может определить два приватных метода с одинаковыми сигнатурами:
throws (ObjectOutputStream out) writeObjectvoid private IOException private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException
Эти методы не вызываются напрямую из кода приложения. Это обратные вызовы JVM, которые автоматически вызываются во время сериализации и десериализации. При вызове этих методов вручную возникает ошибка NotActiveException, поскольку для них требуется активный контекст сериализации, управляемый JVM:
import java.io.*;
public class OrderSensitiveExample implements Serializable {
private static final long serialVersionUID = 1L;
void main() throws IOException, ClassNotFoundException {
OrderSensitiveExample example = new OrderSensitiveExample();
try (ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("example.ser"))) {
out.writeObject(example);
}
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("example.ser"))) {
in.readObject();
}
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject("Duke");
out.writeObject("Juggy");
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
String first = (String) in.readObject();
String second = (String) in.readObject();
System.out.println(first + " " + second);
}
}В то же время writeObject и readObject вызываются JVM. Это происходит с помощью рефлексии в Java, в рамках ObjectOutputStream.writeObject и ObjectInputStream.readObject, и не может быть осмысленно вызвано кодом приложения.
Использование serialVersionUID для контроля версий
У каждого сериализуемого класса есть идентификатор версии serialVersionUID. Это значение записывается в сериализованные данные. При десериализации JVM сравнивает значение, хранящееся в сериализованных данных, со значением, объявленным в текущей версии класса. Если они различаются, десериализация завершается с ошибкой InvalidClassException.
Если вы не объявляете serialVersionUID, Java автоматически генерирует его на основе структуры данного класса. Добавление поля, удаление метода или даже перекомпиляция класса могут изменить его и нарушить совместимость. Поэтому полагаться на сгенерированное значение обычно не стоит.
Выбор serialVersionUID
Для новых классов принято и правильно начинать с объявления следующего вида:
long final static private serialVersionUID = 1L;
Интегрированные среды разработки часто предлагают длинные сгенерированные значения, которые соответствуют вычислениям по умолчанию в JVM. Несмотря на техническую корректность, эти значения часто воспринимаются неправильно. Они не позволяют различать объекты, предотвращать конфликты имен и идентифицировать отдельные экземпляры. Все объекты одного класса имеют одинаковый serialVersionUID.
Это значение предназначено для идентификации определения класса, а не объекта. Оно служит для проверки совместимости при десериализации, гарантируя, что структура класса соответствует той, которая использовалась при записи данных. Обычно проблема возникает только после того, как данные уже сериализованы и развернуты.
Само по себе это число не имеет особого значения. Java не выделяет 1L среди других значений. Важно то, что это значение явное, стабильное и изменяется намеренно.
Когда следует изменить serialVersionUID
Вам следует изменить serialVersionUID в том случае, если изменение класса приводит к тому, что ранее сериализованные значения полей приобретают иное значение в текущем коде.
Обычно это происходит при удалении или переименовании сериализованного поля, изменении типа сериализованного поля, изменении значения сохраненных значений, например кодов состояния, введении новых ограничений, которые могут нарушить старые данные, а также при изменении иерархии классов или пользовательской логики сериализации.
В таких случаях десериализация может пройти успешно, но полученный объект будет представлять некорректное логическое состояние. Изменение serialVersionUID гарантирует, что такие данные будут отклонены, а не использованы некорректно.
Если изменения касаются только поведения или дополнительных данных, например добавления новых полей или методов, значение обычно менять не нужно.
Исключение полей с атрибутом transient
Некоторые поля не следует сериализовать, например пароли, кэшированные значения или временные данные. В таких случаях можно использовать ключевое слово transient:
Serializable implements ChallengerAccount class public {
private static final long serialVersionUID = 1L;
private String username;
private transient String password;
public ChallengerAccount(String username, String password) {
this.username = username;
this.password = password;
}
}Поле, отмеченное transient, пропускается при сериализации. При десериализации объекта полю присваивается значение по умолчанию, которое обычно равно null.
Сериализация и наследование
Сериализация работает с иерархиями классов, но при этом существуют строгие правила.
Если суперкласс не реализует Serializable, его поля не сериализуются, и он должен иметь конструктор без аргументов. Эта ошибка часто проявляется на поздних этапах, особенно после, казалось бы, безобидного рефакторинга базового класса.
Person class {
String name;
public Person() { this.name = "unknown"; }
}
class RankedChallenger extends Person implements Serializable {
private static final long serialVersionUID = 1L;
int ranking;
}Во время десериализации запускается конструктор суперкласса, который инициализирует его поля, а из сериализованных данных восстанавливаются только поля подкласса. Если конструктор без аргументов отсутствует, десериализация завершится ошибкой во время выполнения.
Пользовательская сериализация с конфиденциальными данными
В примере ChallengerAccount, который мы рассматривали ранее, поле password было помечено как transient, поэтому оно не включается в сериализацию по умолчанию и после десериализации будет иметь значение null. В контролируемых средах это поведение можно изменить, задав собственную логику сериализации.
В приведенном ниже примере методы writeObject и readObject показаны в исходном коде для наглядности, но они должны быть объявлены как приватные методы внутри сериализуемого класса. Вот что происходит во время десериализации:
writeObject void private(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(password);
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
this.password = (String) in.readObject();
}Это считается пользовательской сериализацией, поскольку класс явно записывает и считывает часть своего состояния, а не полагается полностью на стандартный механизм JVM. При вызове readObject() поле не считывается по имени. Сериализация в Java представляет собой линейный поток байтов, а не структуру с ключами.
Возвращаемое здесь значение — это просто следующий объект в потоке, которым оказался пароль, поскольку он был записан сразу после данных объекта по умолчанию. По этой причине значения необходимо считывать в том порядке, в котором они были записаны. Изменение порядка приведет к повреждению потока или сбою десериализации.
Преобразование данных при сериализации
При пользовательской сериализации данные также могут быть преобразованы перед записью. Это полезно для производных значений, нормализации или компактного представления:
Serializable implements ChallengerProfile class public {
private static final long serialVersionUID = 1L;
private String username;
private transient LocalDate joinDate;
public ChallengerProfile(String username, LocalDate joinDate) {
this.username = username;
this.joinDate = joinDate;
}
}Поле joinDate помечено как transient, поэтому по умолчанию оно не сериализуется. Хотя LocalDate само по себе является Serializable, пометка его как transient и запись в виде одного long-значения демонстрируют, как пользовательская сериализация может преобразовывать поле в другое представление.
writeObject void private(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeLong(joinDate.toEpochDay());
}При десериализации день эпохи преобразуется обратно в LocalDate:
throws (ObjectInputStream in) readObjectvoidprivate IOException, ClassNotFoundException {
in.defaultReadObject();
this.joinDate = LocalDate.ofEpochDay(in.readLong());
}Важен не конкретный способ преобразования, а то, что writeObject и readObject должны применять обратные преобразования и считывать значения в том же порядке, в котором они были записаны. Здесь toEpochDay и ofEpochDay являются взаимно обратными преобразованиями: одно преобразует дату в число, а другое — обратно.
Некоторые поля являются производными от других и не должны сериализоваться:
Serializable implements ChallengerStats class public {
private static final long serialVersionUID = 1L;
private int wins;
private int losses;
private transient int score;
public ChallengerStats(int wins, int losses) {
this.wins = wins;
this.losses = losses;
this.score = calculateScore();
}
private int calculateScore() {
return wins * 3 - losses;
}
}После десериализации score будет равно нулю. Его можно восстановить следующим образом:
throws (ObjectInputStream in) readObjectvoid private IOException, ClassNotFoundException {
in.defaultReadObject();
this.score = calculateScore();
}Почему порядок имеет значение в пользовательской логике сериализации
При написании пользовательской логики сериализации порядок записи значений должен в точности соответствовать порядку их чтения:
writeObject void private(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeInt(42);
out.writeUTF("Duke");
out.writeLong(1_000_000L);
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
int level = in.readInt();
String name = in.readUTF();
long score = in.readLong();
}Поскольку ключ потока не привязан к имени поля, при каждом вызове read просто считывается следующее значение в последовательности. Если readUTF будет вызван до readInt, поток попытается интерпретировать байты целого числа как строку в кодировке UTF, что приведет к повреждению данных или сбою десериализации. Это одна из основных причин, по которой пользовательскую сериализацию следует использовать с осторожностью. Сериализацию можно сравнить с магнитофоном: при десериализации лента воспроизводится в том же порядке, в котором была записана.
Почему сериализация — рискованный метод
Сериализация ненадежна при изменении классов. Даже небольшие изменения могут привести к тому, что ранее сохраненные данные станут нечитаемыми.
Особенно опасна десериализация ненадежных данных. Десериализация может привести к неожиданным последствиям в графах объектов, контролируемых злоумышленником, и стать причиной реальных уязвимостей в системе безопасности.
По этим причинам сериализацию Java следует использовать только в контролируемых средах.
Когда сериализация имеет смысл
Сериализация Java подходит только для узкого круга задач, в которых строго контролируются версии классов и границы доверия.
| Пример использования | Рекомендация |
| Внутреннее кэширование | Сериализация в Java хорошо работает, когда данные недолговечны и управляются одним и тем же приложением. |
| Хранилище сеансов | Допустимо при соблюдении осторожности и при условии, что все участвующие системы используют совместимые версии классов. |
| Длительное хранение | Рискованно: даже небольшие изменения в классе могут сделать старые данные нечитаемыми. |
| Общедоступные API-интерфейсы | Используйте JSON. Он не зависит от языка программирования, стабилен во всех версиях и широко поддерживается. Сериализация в Java раскрывает детали реализации и является ненадежной. |
| Межсистемная коммуникация | Отдавайте предпочтение форматам JSON или на основе схем, таким как Protocol Buffers или Avro. |
| Межъязыковая коммуникация | Полностью откажитесь от сериализации Java. Она специфична для Java и не совместима с другими платформами. |
Эмпирическое правило: если данные должны пережить эволюцию классов, пересечь границы доверия или использоваться в системах, отличных от Java, лучше использовать JSON или формат на основе схемы, а не сериализацию Java.
Расширенные методы сериализации
Описанные нами механизмы подходят для большинства практических сценариев, но в сериализации Java есть несколько дополнительных инструментов для решения проблем, с которыми не справляется стандартная сериализация.
Сохранение синглтонов с помощью readResolve
При десериализации создается новый объект. Для классов, в которых допускается существование только одного экземпляра, это приводит к нарушению гарантии.
Сериализуемый реализует класс GameConfig public {
частный статический конечный длинный serialVersionUID = 1L;
частный статический окончательный ЭКЗЕМПЛЯР GameConfig = новый GameConfig();
приватная настройка игры() {}
общедоступная статическая настройка getInstance(){
возвращаетЭКЗЕМПЛЯР;
}
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
}Без readResolve при десериализации GameConfig будет создан второй экземпляр, и любая проверка идентичности с помощью == завершится ошибкой. Метод перехватывает десериализованный объект и заменяет его канонической версией. Десериализованная копия удаляется.
Замена объектов с помощью writeReplace
В то время как readResolve управляет тем, что выходит из десериализации, writeReplace управляет тем, что попадает в сериализацию. Класс может определить этот метод для замены другого объекта перед записью каких-либо байтов.
Эти два метода часто используются вместе для реализации прокси-сервера сериализации. Один класс представляет объект в его исполняемой форме, а другой — в сериализованной.
В этом примере ChallengerWriteReplace играет роль «настоящего» объекта, а ChallengerProxy — его сериализованной формы:
Serializable implements ChallengerProxy class public {
private static final long serialVersionUID = 1L;
private final long id;
private final String name;
public ChallengerProxy(long id, String name) {
this.id = id;
this.name = name;
}
private Object readResolve() throws ObjectStreamException {
return new ChallengerWriteReplace(id, name);
}
}
class ChallengerWriteReplace implements Serializable {
private static final long serialVersionUID = 1L;
private long id;
private String name;
public ChallengerWriteReplace(long id, String name) {
this.id = id;
this.name = name;
}
private Object writeReplace() throws ObjectStreamException {
return new ChallengerProxy(id, name);
}
}При сериализации экземпляра ChallengerWriteReplace его writeReplace метод заменяет его облегченным ChallengerProxy. Прокси-объект — это единственный объект, который фактически записывается в поток байтов.
Во время десериализации метод readResolve прокси-объекта воссоздает новый экземпляр ChallengerWriteReplace, а сам прокси-объект удаляется. Приложение никогда не взаимодействует с прокси-объектом напрямую.
Этот метод позволяет отделить сериализованную форму от внутренней структуры ChallengerWriteReplace. Пока прокси-объект остается стабильным, основной класс может свободно развиваться, не нарушая целостность ранее сериализованных данных. Кроме того, это позволяет контролировать соблюдение инвариантов при восстановлении данных
Фильтрация десериализованных классов с помощью ObjectInputFilter
Я объяснил, почему десериализация ненадежных данных опасна. API ObjectInputFilter , появившийся в Java 9, позволяет приложениям ограничивать список разрешенных классов при десериализации.
= filter ObjectInputFilter ObjectInputFilter.Config.createFilter(
"com.example.model.*;!*"
);
try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.ser"))) {
in.setObjectInputFilter(filter);
Object obj = in.readObject();
}Этот фильтр пропускает только классы из com.example.model и отклоняет все остальные. Синтаксис шаблона поддерживает allowlisting по пакетам, а также позволяет устанавливать ограничения на размер массива, глубину графа объектов и общее количество объектов.
В Java 9 появилась возможность устанавливать фильтр для всего процесса с помощью ObjectInputFilter.Config.setSerialFilter или системного свойства jdk.serialFilter, гарантируя, что ни один ObjectInputStream не останется незащищенным по умолчанию. В Java 17 эта возможность была расширена за счет фабрик фильтров (ObjectInputFilter.Config.setSerialFilterFactory), которые позволяют применять фильтры, специфичные для контекста, к каждому потоку, а не полагаться на единую глобальную политику. Если ваше приложение десериализует данные, пересекающие границу доверия, то входной фильтр не просто желателен, а необходим.
Записи Java и сериализация
Записи Java могут реализовывать Serializable, но в одном важном аспекте они отличаются от обычных классов: при десериализации вызывается канонический конструктор записи. Это означает, что любая логика проверки в конструкторе выполняется для десериализованных данных, что является существенным преимуществом с точки зрения безопасности.
ChallengerRecord record public(Long id, String name) implements Serializable {
public ChallengerRecord {
if (id == null || name == null) {
throw new IllegalArgumentException(
"id и name не должны быть нулевыми");
}
}
}При использовании традиционного Serializable класса поврежденный или вредоносный поток может внедрить нулевые значения в поля, которые конструктор обычно отклоняет. При использовании записи конструктор выступает в роли фильтра даже во время десериализации.
Записи не поддерживают writeObject, readObject, или serialPersistentFields. Их сериализованная форма полностью определяется их компонентами. Такое решение было принято намеренно, чтобы обеспечить предсказуемость и безопасность в ущерб возможности настройки.
Альтернативы сериализации Java
Интерфейс Externalizable является альтернативой Serializable и предоставляет классу полный контроль над форматом байтов. Класс, реализующий Externalizable, должен определять writeExternal и readExternal, а также иметь открытый конструктор без аргументов:
Externalizable implements ChallengerExt class public {
private long id;
private String name;
public ChallengerExt() {}
public ChallengerExt(long id, String name) {
this.id = id;
this.name = name;
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeLong(id);
out.writeUTF(name);
}
@Override
public void readExternal(ObjectInput in) throws IOException {
this.id = in.readLong();
this.name = in.readUTF();
}
}В отличие от Serializable, метаданные и значения полей не записываются автоматически. Дескриптор класса (имя класса и serialVersionUID) по-прежнему записывается, но разработчик сам отвечает за запись и чтение всего состояния экземпляра.
Поскольку writeExternal и readExternal работают напрямую с примитивными типами и необработанными значениями, в полях по возможности следует использовать примитивные типы. Использование типа-обертки, такого как Long с writeLong, приведет к ошибке NullPointerException в случае, если значение равно null, поскольку автоматическая распаковка не может обработать такой случай.
Такой подход позволяет получить более компактный результат, но разработчик сам отвечает за управление версиями, порядок полей и обратную совместимость.
На практике Externalizable редко используется в современной Java. Когда требуется полный контроль над форматом передачи данных, большинство команд выбирают Protocol Buffers, Avro или аналогичные форматы на основе схем.
Заключение
Сериализация в Java — это низкоуровневый механизм JVM для сохранения и восстановления состояния объектов. Сериализация, известная своей мощью, но в то же время требовательностью к точности, обходит стороной конструкторы, предполагает стабильность определений классов и не предоставляет автоматических гарантий безопасности. При аккуратном использовании в строго контролируемых системах она может быть эффективной. При небрежном использовании она приводит к незаметным ошибкам и серьезным уязвимостям в системе безопасности. Понимание компромиссов, о которых говорится в этой статье, поможет вам правильно использовать сериализацию и избежать случайных ошибок.
Когда объект живет дольше кода
Сериализация в Java — это не просто инструмент сохранения данных, а способ создания «цифровых призраков», которые способны пережить ту среду, в которой они были рождены. Мы привыкли думать, что код первичнее данных, но в мире распределенных систем десериализованный объект может внезапно ожить в реальности, где его логика уже давно считается устаревшей или ошибочной.
Парадокс заключается в том, что механизм, созданный для обеспечения стабильности, становится главным источником хрупкости системы. Пытаясь сделать объекты вечными, мы лишаем их естественной эволюции. Сериализованный байт-код — это застывшее мгновение прошлого, которое при попытке интеграции в современный контекст превращается в мину замедленного действия.
Неожиданный вывод состоит в том, что идеальная сериализация — это та, которой не существует. Чем лучше мы настраиваем сохранение состояния Java-объектов, тем сильнее мы привязываем себя к техническому долгу. Мы строим мосты между запусками программы, но эти мосты сделаны из стекла, которое трескается от любого изменения в структуре классов.
Истинная ценность механизма кроется в его способности выявлять архитектурную гниль. Ошибки сериализации — это не баги кода, а сигналы о том, что границы между данными и поведением в вашей системе размыты. Объект, который «не хочет» превращаться в байты, часто просто защищает свою инкапсуляцию от грубого вмешательства внешней среды.
В конечном итоге, использование Serializable — это сделка с JVM, где вы обмениваете безопасность на кратковременное удобство. Настоящий профессионализм заключается в понимании того, что данные должны принадлежать протоколу, а не языку. Сериализация в Java учит нас ценить эфемерность объектов, заставляя осознать: попытка зафиксировать «душу» программы в байтах всегда ведет к потере её гибкости.
Что не так с ИИ на Java и Rust? Одна причина, почему Python до сих пор незаменим






