Externalizable и кастомная сериализация в Java - Вопросы
Всего: 5 вопросов
1. Какие правила нужно соблюдать при объявлении writeObject() и readObject() и зачем проверять инварианты в readObject()?
Какие правила нужно соблюдать при объявлении writeObject() и readObject() и зачем проверять инварианты в readObject()?
Методы не объявлены в интерфейсе Serializable: ObjectOutputStream и ObjectInputStream ищут их через рефлексию по имени и сигнатуре. Поэтому они работают только при соблюдении правил:
- Точная сигнатура:
private void writeObject(ObjectOutputStream out) throws IOExceptionиprivate void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException. МетодpublicилиstaticJVM не вызовет и молча применит стандартную сериализацию. - Сначала стандартные поля: первым вызовом —
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() защищает синглтон при десериализации и как 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?
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 отличается от сериализации обычного класса и какие методы сериализации у 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?
Почему встроенная сериализация 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. Фильтр снижает риск, но не убирает его: разрешённые классы тоже могут быть уязвимы.