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

Всего: 5 вопросов

1. 

Какие правила нужно соблюдать при объявлении writeObject() и readObject() и зачем проверять инварианты в readObject()?

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

  • Точная сигнатура: private void writeObject(ObjectOutputStream out) throws IOException и private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException. Метод public или static JVM не вызовет и молча применит стандартную сериализацию.
  • Сначала стандартные поля: первым вызовом — out.defaultWriteObject() / in.defaultReadObject(), даже если все поля transient: так остаётся место для полей будущих версий класса.
  • Тот же порядок: дополнительные данные читаются в том же порядке и тех же типов, в которых записаны; иначе — OptionalDataException, EOFException или испорченные значения.
  • Каждый класс отвечает за себя: JVM вызывает методы каждого сериализуемого класса иерархии от родителя к наследнику; «super.writeObject()» вызывать не нужно и невозможно — метод private.

Инварианты нужно проверять потому, что при десериализации конструктор не вызывается и поток можно подделать: класс Range с проверкой from > to в конструкторе читается из изменённого потока как Range[from=10, to=5]. Исправление — после defaultReadObject() повторить проверку и бросить InvalidObjectException; для изменяемых полей (Date, массивы, коллекции) — ещё и сделать защитные копии.

2. 

Как readResolve() защищает синглтон при десериализации и как writeReplace() используется в паттерне Serialization Proxy?

readResolve() вызывается после чтения объекта, и вызывающий код получает то, что вернул метод. Без него десериализация синглтона создаёт второй экземпляр в обход private-конструктора: restored == Settings.INSTANCE даёт false. С методом

@Serial
private Object readResolve() {
    return INSTANCE;
}

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

writeReplace() вызывается до записи: в поток попадает возвращённый им объект. В паттерне Serialization Proxy класс записывает вместо себя простой вложенный класс-заместитель, а readResolve() заместителя создаёт настоящий объект через конструктор со всеми проверками. Метод readObject() самого класса бросает InvalidObjectException("Proxy required"), чтобы поддельный экземпляр в потоке не прошёл. Если заместителя подделать, сработает проверка конструктора, и IllegalArgumentException дойдёт до вызывающего кода как есть: исключение из readResolve() не оборачивается.

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

3. 

Как работает Externalizable при чтении объекта, какие у него ограничения и когда его стоит выбирать вместо Serializable?

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

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

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

По умолчанию выбирают Serializable; если нужно восстановить transient-поля или проверить инварианты — Serializable с writeObject()/readObject(); Externalizable — когда формат и скорость важнее удобства. На практике в таких случаях чаще берут JSON, Protobuf или Kryo.

4. 

Чем сериализация record отличается от сериализации обычного класса и какие методы сериализации у record работают?

Record (Java 16+) пишет в поток значения компонентов, а при чтении JVM вызывает канонический конструктор, а не заполняет поля в обход него. Поэтому проверки конструктора выполняются и для данных из потока: подделанный поток для

public record Range(int from, int to) implements Serializable {
    public Range {
        if (from > to) {
            throw new IllegalArgumentException("from > to");
        }
    }
}

даёт java.io.InvalidObjectException: from > to, а исходное IllegalArgumentException доступно через getCause(). У обычного класса тот же поток прочитался бы как Range[from=10, to=5].

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

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

5. 

Почему встроенная сериализация Java опасна и как настроить ObjectInputFilter: шаблоны, лимиты, фильтр для всей JVM?

Причины опасности:

  • Поток решает, какие классы создать: readObject() создаёт объекты любых сериализуемых классов из classpath, а приведение (Order) in.readObject() происходит уже после чтения.
  • Чужой код выполняется при чтении: readObject() и readResolve() библиотечных классов вызываются автоматически, из них собирают цепочку (gadget chain), ведущую к выполнению произвольного кода.
  • Отказ в обслуживании: небольшой поток описывает огромный массив или глубоко вложенные коллекции.

Главное правило — не десериализовать недоверенные данные, а для обмена использовать JSON или Protobuf. Если без встроенной сериализации не обойтись, ObjectInputFilter проверяет классы и размеры до создания объекта. Шаблоны через ; проверяются слева направо, решает первый совпавший:

  • com.example.* — пакет без подпакетов; com.example.** — с подпакетами; java.base/* — модуль;
  • ! перед любым шаблоном запрещает; !* в конце запрещает всё остальное (белый список);
  • лимиты maxdepth, maxrefs, maxbytes, maxarray защищают от отказа в обслуживании.

Ловушка: фильтр "Order;java.util.ArrayList;!*" отклоняет список заказов (filter status: REJECTED), потому что для внутреннего массива Object[] проверяется тип элемента; рабочий вариант — "Order;java.util.ArrayList;java.lang.Object;!*". Для всей JVM фильтр задают свойством -Djdk.serialFilter. Фильтр снижает риск, но не убирает его: разрешённые классы тоже могут быть уязвимы.

Страница 1 из 1