Что делать, если поле класса ссылается на мутабельный объект
Если поле проектируемого иммутабельного класса ссылается на мутабельный (изменяемый) объект — такой как коллекция (ArrayList, HashMap), массив (int[], byte[]), дата (java.util.D...
🟢 Junior Level
30-секундный ответ
Если поле проектируемого иммутабельного класса ссылается на мутабельный (изменяемый) объект — такой как коллекция (ArrayList, HashMap), массив (int[], byte[]), дата (java.util.Date) или изменяемый доменный объект, необходимо применять стратегию защитного копирования (Defensive Copying) по «правилу двух точек»:
- Точка 1 (Вход / Конструктор): никогда не сохранять прямую ссылку на переданный извне мутабельный объект. Обязательно создать его независимую копию и сохранить ссылку на неё.
- Точка 2 (Выход / Геттер): никогда не возвращать прямую ссылку на внутренний объект, если клиент может его модифицировать. Возвращать либо новую копию, либо неизменяемое представление (Unmodifiable View).
Наглядный пример: Дата рождения (мутабельный класс java.util.Date)
import java.util.Date;
import java.util.Objects;
public final class Employee {
private final String name;
private final Date birthDate; // Date мутабелен через метод setTime()!
public Employee(String name, Date birthDate) {
this.name = Objects.requireNonNull(name);
Objects.requireNonNull(birthDate, "Birth date required");
// 1. ЗАЩИТНАЯ КОПИЯ НА ВХОДЕ:
// Создаём новый объект Date с тем же timestamp, изолируя внутреннее состояние
this.birthDate = new Date(birthDate.getTime());
}
public String getName() {
return name;
}
public Date getBirthDate() {
// 2. ЗАЩИТНАЯ КОПИЯ НА ВЫХОДЕ:
// Возвращаем копию, чтобы вызов getBirthDate().setTime(0) не изменил поле класса
return new Date(birthDate.getTime());
}
}
🟡 Middle Level
1. Защита коллекций в современных версиях Java
До Java 10 для защитного копирования коллекций требовалось создавать копию и оборачивать её:
// Java 8 паттерн:
this.items = Collections.unmodifiableList(new ArrayList<>(items));
Начиная с Java 10, рекомендуется использовать методы List.copyOf(), Set.copyOf(), Map.copyOf():
public final class ShoppingCart {
private final List<String> itemSkus;
public ShoppingCart(List<String> itemSkus) {
// List.copyOf() проверяет аргумент на null, создаёт компактную
// неизменяемую копию и не допускает элементов null
this.itemSkus = (itemSkus == null) ? List.of() : List.copyOf(itemSkus);
}
public List<String> getItemSkus() {
// Возвращаем напрямую, так как List.copyOf гарантирует иммутабельность коллекции
return itemSkus;
}
}
2. Защита массивов
Массивы в Java всегда мутабельны. Для массивов вызов clone() или Arrays.copyOf() обязателен на входе и на выходе:
public final class SecurityToken {
private final byte[] tokenBytes;
public SecurityToken(byte[] tokenBytes) {
// Копия на входе
this.tokenBytes = tokenBytes.clone();
}
public byte[] getTokenBytes() {
// Копия на выходе
return tokenBytes.clone();
}
}
3. Глубокое копирование (Deep Copy) коллекций объектов
Если список содержит мутабельные объекты, одного лишь List.copyOf недостаточно, так как копируются только ссылки на элементы:
public record Order(Long id, List<OrderItem> items) {
public Order {
// Клонируем КАЖДЫЙ элемент списка через конструктор копирования:
items = (items == null) ? List.of() : items.stream()
.map(item -> new OrderItem(item.getSku(), item.getPrice(), item.getQuantity()))
.toList();
}
}
🔴 Senior Level
Угроза Time-of-Check to Time-of-Use (TOCTOU) в конструкторах
Фундаментальное правило многопоточной безопасности: защитную копию параметров конструктора необходимо снимать ДО их валидации, а не после:
// ❌ УЯЗВИМЫЙ КОД: подвержен TOCTOU-атаке в параллельной среде
public UserPeriod(Date start, Date end) {
if (start.after(end)) { // 1. Time-of-Check (валидация оригинала)
throw new IllegalArgumentException("start must be before end");
}
// В этот микромомент параллельный поток вызывает start.setTime(System.currentTimeMillis() + 1000000)!
this.start = new Date(start.getTime()); // 2. Time-of-Use (копирование подменённого состояния)
this.end = new Date(end.getTime());
}
Безопасная реализация (Защита от TOCTOU):
// ✅ АБСОЛЮТНО БЕЗОПАСНЫЙ КОД
public UserPeriod(Date start, Date end) {
// 1. Сначала делаем защитную локальную копию:
Date copyStart = new Date(start.getTime());
Date copyEnd = new Date(end.getTime());
// 2. Валидируем уже СОБСТВЕННУЮ локальную копию:
if (copyStart.after(copyEnd)) {
throw new IllegalArgumentException("start must be before end");
}
// 3. Сохраняем проверенную копию:
this.start = copyStart;
this.end = copyEnd;
}
Атака через полиморфизм и метод clone()
Джошуа Блох в Effective Java (Статья 50) акцентирует внимание: никогда не используйте метод clone() для защитного копирования параметров, типы которых могут быть расширены сторонним кодом:
- Если метод принимает
Date dateи делаетthis.date = (Date) date.clone();, злоумышленник может передать объект вредоносного подкласса:public class MaliciousDate extends Date { private Date stolenReference; @Override public Object clone() { this.stolenReference = this; // Перехват ссылки return this; // Возврат себя же вместо копии! } } - В результате защитное копирование обойдено! Использовать
clone()безопасно только для массивов, так как массивы имеют закрытый фиксированный тип времени исполнения.
Производительность: Персистентные структуры данных (Structural Sharing)
Копирование коллекций размера $N$ имеет сложность $O(N)$ по времени и памяти. Если иммутабельный объект должен часто обновляться в высоконагруженных системах (например, торговые стаканы, финансовые книги):
- Вместо защитного копирования стандартных
java.util.Listиспользуют библиотеки персистентных структур данных (Vavr, PCollections). - Персистентные структуры данных (например, префиксные деревья HAMT — Hash Array Mapped Trie) обеспечивают неизменяемость с совместным использованием структуры (Structural Sharing), выполняя модификации за $O(\log_{32} N)$ с минимальным выделением памяти.
4 Tricky Questions
1. Почему защитное копирование в конструкторе обязано выполняться ДО проверки аргументов на валидность, а не после? Приведите сценарий эксплойта.
Ответ: Если проверка аргументов выполняется до копирования, возникает уязвимость TOCTOU (Time-of-Check to Time-of-Use). Сценарий эксплойта:
- Поток А вызывает конструктор
new DateInterval(start, end), передавая валидные даты. - Конструктор выполняет
if (start.after(end)), проверка проходит успешно. - До вызова строки
this.start = new Date(start.getTime())планировщик операционной системы переключает контекст на Поток Б. - Поток Б, удерживающий ссылку на объект
start, вызываетstart.setTime(Long.MAX_VALUE). - Поток А возобновляет работу и копирует в
this.startуже скомпрометированную дату из будущего. Инвариант класса нарушен: в иммутабельном объектеstartоказался позжеend. Выполнение копирования до проверки исключает этот вектор атаки, так как проверяется уже приватная локальная копия, недоступная другим потокам.
2. Почему Джошуа Блох запрещает использовать метод clone() для защитного копирования объектов нестандартных классов, но разрешает для массивов?
Ответ:
Метод clone() виртуальный. Если класс аргумента не объявлен как final (например, java.util.Date), переданный объект может быть экземпляром недоверенного подкласса (SubDate extends Date), созданного злоумышленником.
Подкласс может переопределить clone() так, чтобы вернуть исходный объект (или сохранить ссылку на него в статическом поле), сделав «защитную копию» фикцией.
Для массивов это правило безопасно, поскольку массивы в JVM являются объектами специального низкоуровневого типа, который невозможно унаследовать или переопределить: array.clone() гарантированно вызывается из рантайма JVM и возвращает физически новый массив.
3. Оптимизирует ли метод List.copyOf() выделение памяти, если в него передан список, уже созданный через List.of()?
Ответ:
Да, метод List.copyOf(Collection<? extends E> coll) оптимизирован:
- Он проверяет, является ли входная коллекция экземпляром внутреннего системного класса платформы
java.util.ImmutableCollections.AbstractImmutableCollection. - Если входная коллекция уже неизменяема (создана через
List.of(),Set.of(),List.copyOf()), метод не выделяет память и мгновенно возвращает переданный экземпляр:return (List<E>) coll;. - Если же передан обычный
ArrayListилиLinkedList, создаётся новая компактная неизменяемая коллекция.
4. Допустим, мы сделали защитную копию списка через this.items = List.copyOf(items). Список содержит объекты типа StringBuilder. Является ли наш класс глубоко иммутабельным?
Ответ:
Нет, класс является только поверхностно иммутабельным (Shallow Immutable).
Метод List.copyOf() замораживает только сам список (нельзя добавить или удалить элемент). Однако элементами списка являются ссылки на мутабельные объекты StringBuilder.
Любой клиент, вызвавший obj.getItems().get(0).append("HACKED"), изменит внутреннее состояние объекта в куче, нарушив неизменяемость.
Для достижения глубокой иммутабельности необходимо скопировать сами элементы, преобразовав их в неизменяемые строки: items.stream().map(Object::toString).toList().
🎯 Шпаргалка для интервью
Свод правил защиты мутабельных полей
| Тип поля | Как защитить на входе (Конструктор) | Как защитить на выходе (Геттер) |
| :— | :— | :— |
| Коллекция (List, Set, Map) | List.copyOf(input) (Java 10+) | Возврат ссылки на List.copyOf напрямую |
| Массив (byte[], int[]) | input.clone() | this.array.clone() |
| Дата (Date) | new Date(input.getTime()) | new Date(this.date.getTime()) (лучше перейти на Instant) |
| Коллекция мутабельных объектов | input.stream().map(Item::new).toList() | Возврат неизменяемого списка клонированных копий |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Для защиты коллекции в конструкторе достаточно вызвать
Collections.unmodifiableList(list)» — это обёртка над оригиналом, оригинал снаружи можно продолжать менять. - ❌ «Сначала нужно проверить параметры на валидность, а потом делать копию» — грубая ошибка безопасности, открывающая TOCTOU-атаку.
- ❌ «Метод
clone()— лучший способ скопировать объект» —clone()архитектурно сломан и подвержен атаке через полиморфные подклассы (кроме массивов). - ❌ «Если мы сделали
List.copyOf(), то элементы списка защищены от изменений» — защищён только контейнер, мутабельные элементы остаются уязвимыми.