Сериализация ·
‹ Предыдущий Следующий ›
⏱ 5 минут чтения Обновлено: 2026-11-03

Externalizable и кастомная сериализация в Java

Синглтон, который пережил сериализацию, перестаёт быть синглтоном: после readObject() в памяти два объекта, и == возвращает false. Объект с проверкой в конструкторе можно прочитать из подделанного файла в недопустимом состоянии: конструктор при десериализации не вызывается. Оба случая решаются кастомной сериализацией.

Кастомная сериализация в Java — это управление тем, как объект записывается в поток и восстанавливается из него. Для этого есть специальные методы, которые JVM вызывает сама: writeObject() и readObject() меняют запись и чтение полей, writeReplace() и readResolve() подменяют сам объект, а интерфейс Externalizable отдаёт формат данных полностью в руки программиста.

Урок опирается на основы сериализации и serialVersionUID. Там описаны Serializable, transient и совместимость версий класса, здесь они не повторяются.

1. Какие методы управляют сериализацией

Сериализуемый класс может объявить несколько методов, которые ObjectOutputStream и ObjectInputStream находят через рефлексию и вызывают вместо стандартной логики или вместе с ней. Ни один из них не объявлен в интерфейсе Serializable: JVM ищет их по имени и сигнатуре.

Метод Когда вызывается Зачем нужен
private void writeObject(ObjectOutputStream) При записи объекта, для каждого класса иерархии Дописать или изменить данные: вычисляемые значения, transient-поля
private void readObject(ObjectInputStream) При чтении объекта, для каждого класса иерархии Восстановить transient-поля, проверить инварианты, сделать защитные копии
Object writeReplace() Перед записью, вместо объекта пишется возвращённый Записать вместо объекта его заместителя (Serialization Proxy)
Object readResolve() После чтения, вызывающий получает возвращённый объект Вернуть существующий экземпляр (синглтон) или собрать объект через конструктор
public void writeExternal(ObjectOutput)
public void readExternal(ObjectInput)
Только для Externalizable, вместо всей стандартной логики Полностью свой формат данных

Все методы, кроме writeExternal() и readExternal(), удобно помечать аннотацией @Serial (Java 14+): IDE и компилятор с -Xlint:serial предупредят об ошибке в сигнатуре. Без аннотации опечатка в имени или лишний модификатор приводят к тому, что JVM просто не находит метод и молча использует стандартную сериализацию.

2. writeObject() и readObject(): правила

Методы writeObject() и readObject() работают, только если выполнены условия:

  • Точная сигнатура: private void writeObject(ObjectOutputStream out) throws IOException и private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException. Метод public или static JVM не вызовет.
  • Сначала стандартные поля. Первым вызовом пишите out.defaultWriteObject() и, соответственно, in.defaultReadObject(). Даже если все поля transient, этот вызов оставляет место для полей, которые появятся в будущих версиях класса.
  • Тот же порядок. readObject() читает дополнительные данные в том же порядке и тех же типов, в которых их записал writeObject(). Иначе — OptionalDataException, EOFException или испорченные значения.
  • Каждый класс отвечает за себя. В иерархии JVM вызывает writeObject() каждого сериализуемого класса по очереди, от родителя к наследнику, и каждый пишет только свои поля. Вызывать «super.writeObject()» не нужно и невозможно: метод private.
public class Account implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    private String owner;
    private transient List<String> history = new ArrayList<>(); // журнал не сохраняем

    @Serial
    private void writeObject(ObjectOutputStream out) throws IOException {
        out.defaultWriteObject();      // owner
        out.writeInt(history.size());  // только размер журнала
    }

    @Serial
    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        int lost = in.readInt();
        history = new ArrayList<>();   // transient-поле пришло как null - создаём заново
        history.add("восстановлено, потеряно записей: " + lost);
    }
}

Обратите внимание на последнюю строку readObject(): transient-поле после чтения равно null, и если его не пересоздать, первый же вызов history.add() упадёт. Как с помощью readObject() задать значение новому полю при чтении старых файлов, разобрано в уроке о serialVersionUID.

3. Проверка инвариантов в readObject()

При десериализации конструктор сериализуемого класса не вызывается: JVM создаёт объект и заполняет поля значениями из потока. Значит, все проверки из конструктора при чтении не выполняются, а поток можно подделать. Проверим на классе-диапазоне, у которого начало не может быть больше конца:

public class Range implements Serializable {
    private final int from;
    private final int to;

    public Range(int from, int to) {
        if (from > to) {
            throw new IllegalArgumentException("from > to");
        }
        this.from = from;
        this.to = to;
    }

    @Override
    public String toString() {
        return "Range[from=" + from + ", to=" + to + "]";
    }
}

Запишем new Range(1, 5) в массив байтов и изменим один байт. Последние 8 байтов потока — это два int: from, затем to. Младший байт from находится на позиции length - 5:

ByteArrayOutputStream bytes = new ByteArrayOutputStream();
try (ObjectOutputStream out = new ObjectOutputStream(bytes)) {
    out.writeObject(new Range(1, 5));
}
byte[] data = bytes.toByteArray();
data[data.length - 5] = 10; // from: 1 -> 10

try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(data))) {
    System.out.println(in.readObject());
}
Range[from=10, to=5]

Объект, который нельзя создать через конструктор, спокойно прочитан из потока. Исправление — повторить проверку в readObject() и бросить InvalidObjectException:

@Serial
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
    in.defaultReadObject();
    if (from > to) {
        throw new InvalidObjectException("from > to: " + from + " > " + to);
    }
}
java.io.InvalidObjectException: from > to: 10 > 5

Если класс хранит изменяемые объекты (Date, массивы, коллекции), в readObject() стоит делать и защитные копии: подделанный поток может содержать ссылку на объект, которую злоумышленник сохранил у себя и потом изменит. Правило простое: readObject() — это ещё один публичный конструктор, и защищать его нужно так же.

4. readResolve() и синглтон

Метод readResolve() вызывается после того, как объект прочитан, и его результат подменяет прочитанный объект. Классическое применение — сохранить единственность синглтона:

public class Settings implements Serializable {
    public static final Settings INSTANCE = new Settings();

    private Settings() {
    }
}

// записали и прочитали Settings.INSTANCE
System.out.println(restored == Settings.INSTANCE);
false

Десериализация создала второй экземпляр в обход private-конструктора. Добавим readResolve():

@Serial
private Object readResolve() {
    return INSTANCE; // прочитанная копия будет выброшена сборщиком мусора
}
true

Два уточнения. Если у синглтона есть поля с состоянием, их нужно объявить transient: иначе подделанный поток может «украсть» ссылку на временный объект до того, как сработает readResolve(). А надёжнее всего сделать синглтон через enum: перечисления сериализуются по имени константы, и JVM гарантирует единственность без всякого readResolve().

public enum AppSettings {
    INSTANCE;
    // поля и методы синглтона
}

5. writeReplace() и паттерн Serialization Proxy

Метод writeReplace() срабатывает до записи: в поток попадает не сам объект, а то, что вернул метод. На этой паре методов построен паттерн Serialization Proxy (заместитель сериализации). Класс записывает вместо себя простой вложенный класс-заместитель, а заместитель при чтении в readResolve() создаёт настоящий объект через конструктор — со всеми проверками.

public final class Period implements Serializable {
    private final int from;
    private final int to;

    public Period(int from, int to) {
        if (from > to) {
            throw new IllegalArgumentException("from > to");
        }
        this.from = from;
        this.to = to;
    }

    @Serial
    private Object writeReplace() {
        return new Proxy(this);          // в поток пишется Proxy, а не Period
    }

    @Serial
    private void readObject(ObjectInputStream in) throws InvalidObjectException {
        throw new InvalidObjectException("Proxy required"); // поддельный Period в потоке не пройдёт
    }

    private static class Proxy implements Serializable {
        @Serial
        private static final long serialVersionUID = 1L;
        private final int from;
        private final int to;

        Proxy(Period p) {
            this.from = p.from;
            this.to = p.to;
        }

        @Serial
        private Object readResolve() {
            return new Period(from, to);  // объект собирается через конструктор
        }
    }
}

Обычное чтение возвращает Period[from=1, to=5]. Если подделать поток так же, как в разделе 3, при чтении сработает проверка конструктора: java.lang.IllegalArgumentException: from > to. Обратите внимание: исключение из readResolve() не оборачивается и доходит до вызывающего кода как есть.

Плюсы паттерна: поля могут оставаться final, инварианты проверяются в одном месте, а внутреннее устройство класса можно менять, не трогая формат заместителя. Минусы: чтение чуть медленнее, а с наследуемыми классами и объектами с циклическими ссылками паттерн не работает. Поэтому класс объявлен final.

6. Интерфейс Externalizable

Externalizable — наследник Serializable с двумя обязательными методами: writeExternal(ObjectOutput) и readExternal(ObjectInput). В поток пишется только имя класса и то, что вы записали сами; описание полей и данные родителей JVM не сохраняет.

public class Point implements Externalizable {
    @Serial
    private static final long serialVersionUID = 1L;
    private static final int VERSION = 1;

    private int x;
    private int y;
    private int z;

    public Point() {                     // обязателен: public и без параметров
    }

    public Point(int x, int y, int z) {
        this.x = x;
        this.y = y;
        this.z = z;
    }

    @Override
    public void writeExternal(ObjectOutput out) throws IOException {
        out.writeByte(VERSION);           // версию формата ведём сами
        out.writeInt(x);
        out.writeInt(y);
        out.writeInt(z);
    }

    @Override
    public void readExternal(ObjectInput in) throws IOException {
        int version = in.readByte();
        if (version != VERSION) {
            throw new InvalidObjectException("Unknown version: " + version);
        }
        x = in.readInt();
        y = in.readInt();
        z = in.readInt();
    }
}

Как это работает при чтении: JVM вызывает публичный конструктор без параметров, а затем readExternal(). Отсюда правила и ограничения:

  • Нет public-конструктора без параметров — java.io.InvalidClassException: Point; no valid constructor.
  • Поля не могут быть final: им присваивают значения уже после конструктора, в readExternal().
  • Состояние родительского класса никто не сохранит за вас: его нужно записать и прочитать в тех же методах.
  • transient ни на что не влияет: пишется только то, что записано явно.
  • readExternal() — публичный метод. Его может вызвать любой код и перезаписать поля уже готового объекта, поэтому неизменяемые классы через Externalizable не сделать.

Выигрыш — компактность и скорость. Один объект с тремя полями int занимает в потоке 50 байт через Serializable и 42 байта через Externalizable (вместе с байтом версии). Разница — описание полей (имена и типы), которое стандартная сериализация пишет для каждого класса. На коллекции из тысяч объектов одного класса описание пишется один раз, поэтому выигрыш в размере меньше, чем кажется, а основной плюс — отсутствие рефлексии при чтении и записи.

7. Serializable или Externalizable: что выбрать

Критерий Serializable Serializable + writeObject/readObject Externalizable
Что пишется Все не-static и не-transient поля Стандартные поля + ваши данные Только то, что вы записали
Конструктор при чтении Не вызывается Не вызывается public без параметров
final-поля Можно Можно Нельзя
Поля родителя Автоматически (если родитель Serializable) Автоматически Вручную
Совместимость версий По serialVersionUID и именам полей То же + ваш код Полностью вручную
Объём кода Нулевой Небольшой Все поля вручную
Когда выбирать По умолчанию Нужно восстановить transient-поля или проверить инварианты Формат и скорость важнее удобства

На практике Externalizable встречается редко: если формат данных настолько важен, обычно выбирают не встроенную сериализацию Java, а JSON, Protobuf или Kryo.

8. Сериализация record

Записи (record, Java 16+) сериализуются по-своему. В поток пишутся значения компонентов, а при чтении JVM вызывает канонический конструктор. Значит, проверки в конструкторе выполняются и для данных из потока. Подделка из раздела 3 на record не проходит:

public record Range(int from, int to) implements Serializable {
    public Range {
        if (from > to) {
            throw new IllegalArgumentException("from > to");
        }
    }
}
java.io.InvalidObjectException: from > to
Caused by: java.lang.IllegalArgumentException: from > to

Исключение конструктора JVM оборачивает в InvalidObjectException, а исходное доступно через getCause(). Остальные особенности:

  • Методы writeObject(), readObject() и readObjectNoData() у record игнорируются: JVM их не вызывает, даже если они объявлены.
  • writeReplace() и readResolve() работают как у обычных классов.
  • Если record реализует Externalizable, код скомпилируется, но writeExternal() и readExternal() тоже будут проигнорированы: record всё равно сериализуется по компонентам.
  • Проверка совпадения serialVersionUID для record отключена.

Поэтому для объектов-носителей данных, которые нужно сериализовать, record — самый безопасный вариант встроенной сериализации: проверки в одном месте и никакого кода сериализации вручную.

9. Почему встроенная сериализация опасна

Встроенная сериализация Java считается одним из главных источников уязвимостей в Java-приложениях. Причины:

  • Поток решает, какие классы создать. readObject() создаёт объекты любых сериализуемых классов из classpath, а не только тех, которые вы ждёте. Приведение типа (Order) in.readObject() происходит уже после того, как все объекты прочитаны.
  • Чужой код выполняется при чтении. Методы readObject() и readResolve() классов из библиотек вызываются автоматически. Злоумышленник собирает из них цепочку (gadget chain), которая заканчивается вызовом произвольного кода. В 2015 году такие цепочки на классах Apache Commons Collections позволили удалённо выполнять код на серверах WebLogic, JBoss, Jenkins и других.
  • Отказ в обслуживании. Небольшой поток может описать огромный массив или глубоко вложенные коллекции: чтение исчерпает память или процессор.
  • Обход конструкторов. Как показано в разделе 3, инварианты, которые проверяет конструктор, при чтении не действуют.

Главное правило: не десериализуйте данные из недоверенных источников — от пользователей, из сети, из файлов, которые мог подменить кто-то другой. Для обмена данными используйте JSON или Protobuf. Если отказаться от встроенной сериализации нельзя (старый протокол, RMI, кэш), ограничьте список классов фильтром.

10. ObjectInputFilter: шаблоны и лимиты

ObjectInputFilter (Java 9, JEP 290) проверяет каждый класс и размеры данных до создания объекта. Фильтр задают строкой шаблонов через точку с запятой; шаблоны проверяются слева направо, первый совпавший решает судьбу класса.

Шаблон Значение
com.example.Order Разрешить конкретный класс
com.example.* Разрешить все классы пакета (без подпакетов)
com.example.** Разрешить пакет и все его подпакеты
java.base/* Разрешить все классы модуля
!com.example.Evil Запретить класс (префикс ! работает с любым шаблоном)
!* Запретить всё остальное — ставится последним
maxdepth=10 Максимальная глубина вложенности объектов
maxrefs=1000 Максимальное число ссылок на объекты в потоке
maxbytes=100000 Максимальный размер потока в байтах
maxarray=10000 Максимальная длина массива

Безопасный подход — белый список: перечислить нужные классы и закончить шаблоном !*. Попробуем прочитать ArrayList с объектами Order:

ObjectInputFilter filter = ObjectInputFilter.Config.createFilter(
        "Order;java.util.ArrayList;!*");

try (ObjectInputStream in = new ObjectInputStream(new FileInputStream("orders.ser"))) {
    in.setObjectInputFilter(filter);
    List<?> orders = (List<?>) in.readObject();
}
java.io.InvalidClassException: filter status: REJECTED

Неожиданно: и Order, и ArrayList разрешены. Дело в том, что ArrayList хранит элементы во внутреннем массиве Object[], а для массива фильтр проверяет тип элемента — java.lang.Object. Рабочий фильтр: "Order;java.util.ArrayList;java.lang.Object;!*". С ним список читается, а если в потоке окажется посторонний класс, чтение снова закончится filter status: REJECTED. Это обычная ситуация с белыми списками: проверяйте фильтр на реальных данных.

Лимиты защищают от отказа в обслуживании. Фильтр "maxdepth=2;Order;java.util.ArrayList;java.lang.Object;!*" отклонит список списков с заказами: глубина вложенности объектов в таком потоке больше двух.

Фильтр можно задать не только для одного потока:

  • Для всей JVM — системным свойством -Djdk.serialFilter="Order;java.util.ArrayList;java.lang.Object;!*" или в файле conf/security/java.security. Код менять не нужно.
  • Фабрика фильтров (Java 17, JEP 415) — ObjectInputFilter.Config.setSerialFilterFactory() или свойство -Djdk.serialFilterFactory: позволяет выбирать фильтр для каждого потока в зависимости от контекста, например разный для разных библиотек.

Часто задаваемые вопросы

В чём разница между Serializable и Externalizable?

Serializable — маркерный интерфейс: JVM сама записывает все не-static и не-transient поля и при чтении не вызывает конструктор. Externalizable требует реализовать writeExternal() и readExternal(): пишется только то, что вы записали, а при чтении вызывается публичный конструктор без параметров, поэтому поля не могут быть final.

Почему методы writeObject() и readObject() приватные, если их вызывает JVM?

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

Как защитить синглтон от сериализации?

Добавить метод readResolve(), который возвращает существующий экземпляр, и объявить поля с состоянием transient. Ещё надёжнее — реализовать синглтон через enum: JVM сама гарантирует единственность констант перечисления при десериализации.

Вызываются ли writeObject() и readObject() у record?

Нет, у record JVM их игнорирует. Record сериализуется по значениям компонентов, а при чтении создаётся через канонический конструктор, поэтому проверки в конструкторе работают и для данных из потока. writeReplace() и readResolve() у record работают.

Достаточно ли ObjectInputFilter, чтобы безопасно десериализовать данные из сети?

Фильтр с белым списком и лимитами сильно снижает риск, но не убирает его: разрешённые классы тоже могут содержать уязвимый код. Самый надёжный вариант — не принимать сериализованные Java-объекты из недоверенных источников и использовать JSON или Protobuf.

Практика по теме

Закрепите урок на задачах — у каждой есть уровень и оценка времени.

Все задачи модуля →

Комментарии

Зарегистрируйтесь или войдите, чтобы иметь возможность оставить комментарий.