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илиstaticJVM не вызовет. - Сначала стандартные поля. Первым вызовом пишите
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.
Практика по теме
Закрепите урок на задачах — у каждой есть уровень и оценка времени.
Все задачи модуля →
Комментарии