Как правильно работать с коллекциями в иммутабельных классах
Коллекции — самый уязвимый и сложный аспект при проектировании иммутабельных классов. Коллекция в Java является ссылочным мутабельным объектом, поэтому ключевого слова final у п...
🟢 Junior Level
30-секундный ответ
Коллекции — самый уязвимый и сложный аспект при проектировании иммутабельных классов. Коллекция в Java является ссылочным мутабельным объектом, поэтому ключевого слова final у поля недостаточно.
Чтобы гарантировать 100% иммутабельность, необходимо соблюдать 3 золотых правила:
- Никогда не сохранять ссылку на входящую коллекцию: в конструкторе всегда создавать защитную копию (Defensive Copy) через
List.copyOf(). - Никогда не отдавать мутабельную коллекцию наружу: если поле иммутабельно (
List.copyOf), возвращать его напрямую; если поле мутабельно — оборачивать вCollections.unmodifiableList(). - Обеспечивать глубокую защиту: если элементы внутри коллекции мутабельны, защитить сам список недостаточно — необходимо делать глубокое копирование (Deep Copy) каждого элемента.
Наглядный пример: Уязвимости и их решение
// ❌ УЯЗВИМЫЙ КЛАСС:
public final class VulnerableGroup {
private final List<String> members; // final защищает только ссылку на список!
public VulnerableGroup(List<String> members) {
this.members = members; // ОШИБКА 1: Сохранена прямая ссылка!
}
public List<String> getMembers() {
return members; // ОШИБКА 2: Отдана ссылка на мутабельный список!
}
}
// ✅ ПРАВИЛЬНЫЙ ИММУТАБЕЛЬНЫЙ КЛАСС (Java 10+):
public final class ImmutableGroup {
private final List<String> members;
public ImmutableGroup(List<String> members) {
// Защитная копия: изолирует от изменений оригинала и запрещает модификацию!
this.members = (members == null) ? List.of() : List.copyOf(members);
}
public List<String> getMembers() {
// Безопасно: List.copyOf уже гарантирует неизменяемость
return members;
}
}
🟡 Middle Level
Эволюция методов защиты коллекций в версиях Java
- До Java 10 (Эпоха Java 8):
// Требовалось двойное действие: скопировать данные и обернуть в декоратор this.list = Collections.unmodifiableList(new ArrayList<>(inputList)); - В Java 10+ (Современный стандарт):
this.list = List.copyOf(inputList); // Сразу создаёт компактный неизменяемый список! - В Java 16+ (Stream API):
this.list = inputList.stream().map(Item::copy).toList(); // Неизменяемый список
Аналогичные методы применяются для множеств и карт: Set.copyOf(set) и Map.copyOf(map).
Ловушка Shallow Immutability (Поверхностной защиты)
List.copyOf() гарантирует, что методы add(), remove() и clear() заблокированы. Однако если элементами списка являются обычные мутабельные объекты, их поля можно свободно изменить:
public record Department(String name, List<Employee> staff) {
public Department {
staff = List.copyOf(staff); // Защищён только контейнер!
}
}
// Взлом состояния:
Department dept = getDepartment();
// dept.staff().add(...) бросит исключение, но:
dept.staff().get(0).setSalary(1_000_000); // 💥 Состояние отдела мутировало!
Решение: Глубокое копирование (Deep Copy) элементов
Если элементы мутабельны, в конструкторе необходимо рекурсивно скопировать каждый объект:
public record Department(String name, List<Employee> staff) {
public Department {
staff = (staff == null) ? List.of() : staff.stream()
.map(Employee::new) // Конструктор копирования каждого сотрудника
.toList();
}
}
Если же элементами являются неизменяемые типы (String, Integer, record, LocalDate), глубокое копирование избыточно.
🔴 Senior Level
Records и коллекции: Компактный конструктор
В записях (record) геттеры генерируются компилятором автоматически и возвращают прямую ссылку на поле компонента.
Поэтому единственное место для защиты коллекции — компактный конструктор:
public record SecurityPolicy(String name, Set<String> permissions) {
public SecurityPolicy {
Objects.requireNonNull(name, "Name required");
// Защитная копия обязательна!
permissions = (permissions == null) ? Set.of() : Set.copyOf(permissions);
}
}
JMM §17.5 Final Field Freeze и коллекции
Старший инженер должен понимать разницу между заморозкой ссылки и заморозкой содержимого:
- Модификатор
finalу поляprivate final List<String> listгарантирует выполнение действия Freeze Action по спецификации JMM §17.5 только для самой ссылкиlist. - Если поле ссылается на стандартный
new ArrayList<>(), то его внутренний массивelementData[]и счётчикsizeне защищены семантикой final-полей! - Поток на другом ядре CPU может прочитать ссылку на
ArrayList, но увидеть неинициализированный внутренний массивnullили прочитатьsize = 0. - Вывод: использование
List.copyOf()критично не только для неизменяемости, но и для потокобезопасной публикации (Safe Publication), так как внутренние реализацииImmutableCollectionsтакже используют строго финальные поля для своих элементов.
Оптимизация $O(1)$ в List.copyOf()
Разработчики OpenJDK реализовали элегантную оптимизацию:
static <E> List<E> copyOf(Collection<? extends E> coll) {
if (coll instanceof ImmutableCollections.AbstractImmutableList) {
return (List<E>) coll; // Возврат this за O(1) без выделения памяти!
}
return (List<E>) List.of(coll.toArray());
}
Если в List.copyOf() передать коллекцию, которая уже была создана через List.of() или List.copyOf(), JVM не дублирует массив, а мгновенно возвращает исходный экземпляр за $O(1)$, сохраняя процессорное время и память.
4 Tricky Questions
1. Что произойдёт, если передать в List.copyOf(list) список, содержащий null? Как правильно обработать такой сценарий?
Ответ:
Метод List.copyOf() немедленно выбросит NullPointerException, так как фабрики неизменяемых коллекций в Java 9+ являются строго нетерпимыми к null (null-hostile).
Если по контракту бизнес-логики в коллекции легитимно могут присутствовать элементы null:
- Использовать связку Java 8:
Collections.unmodifiableList(new ArrayList<>(input)), которая корректно сохраняет и защищает элементыnull. - Либо использовать метод
Stream.toList()(Java 16+), который возвращает неизменяемый список, но при этом допускает наличие элементовnull:this.list = input == null ? List.of() : input.stream().toList();
2. Защитит ли коллекцию SortedSet или TreeSet простой вызов Set.copyOf(sortedSet)? Какие скрытые проблемы здесь возникают?
Ответ:
Вызов Set.copyOf(sortedSet) защитит коллекцию от модификаций, но полностью разрушит порядок сортировки и контракт компаратора:
- Метод
Set.copyOf()возвращает реализациюImmutableCollections.SetN, которая является неупорядоченным хэш-множеством с рандомизированным порядком обхода! - Вся сортировка элементов будет безвозвратно утеряна, а кастомный
Comparatorпроигнорирован. - Как защитить правильно: для упорядоченных коллекций необходимо использовать специализированный метод:
this.sortedSet = Collections.unmodifiableSortedSet(new TreeSet<>(sortedSet)); // или Collections.unmodifiableNavigableSet(new TreeSet<>(navigableSet));
3. Почему создание защитной копии через new ArrayList<>(input) с последующим Collections.unmodifiableList(...) в многопоточном коде подвержено уязвимости TOCTOU?
Ответ:
Если входящая коллекция input передана из другого потока и в этот момент модифицируется:
- Конструктор
new ArrayList<>(input)вызываетinput.toArray(). - Если другой поток параллельно вызывает
input.remove(), размер массива и фактическое количество элементов расходятся. МетодArrays.copyOf()в недрахArrayListпадает сIndexOutOfBoundsExceptionили записываетnullв ячейки. - Если перед копированием выполнялась валидация (
if (input.size() > 10) throw ...), то между проверкой и копированием другой поток может изменить размер коллекции (Time-of-Check to Time-of-Use — TOCTOU). - Правило: валидация коллекции должна выполняться строго ПОСЛЕ создания локальной копии!
4. В каком случае использование библиотеки Vavr предпочтительнее стандартного List.copyOf() при проектировании иммутабельных классов?
Ответ: Библиотека Vavr предпочтительнее, когда иммутабельный объект часто «эволюционирует» (добавляются или удаляются элементы):
- При использовании
List.copyOf()добавление одного элемента требует создания нового списка и полного $O(N)$ копирования массива элементов. Для списка из 10 000 элементов это дорого. - Персистентные коллекции Vavr используют Structural Sharing (разделение структуры): добавление элемента в
io.vavr.collection.Listзанимает $O(1)$ по времени и памяти (новый узел-голова просто ссылается на старый хвост). - Если коллекция большая и часто обновляется, использование персистентных структур Vavr кардинально снижает нагрузку на CPU и сборщик мусора.
🎯 Шпаргалка для интервью
3 правила работы с коллекциями в иммутабельных классах
- Вход (Конструктор): всегда делать защитную копию (
List.copyOf(input)). - Выход (Геттер): возвращать неизменяемую коллекцию (если сделан
List.copyOf, можно отдавать напрямую). - Глубина: если элементы коллекции мутабельны, делать глубокое копирование (Deep Copy) каждого элемента.
Сравнение методов защиты коллекций
| Метод | Версия Java | Делает копию? | Разрешён null? | Связь с оригиналом |
| :— | :— | :—: | :—: | :— |
| List.copyOf(list) | Java 10+ | ✅ Да ($O(N)$ или $O(1)$) | ❌ NPE | 🟢 Разорвана |
| Collections.unmodifiableList(list) | Java 1.2+ | ❌ Нет (обёртка) | ✅ Да | 🔴 Живая связь! |
| Collections.unmodifiableList(new ArrayList<>(list)) | Java 1.2+ | ✅ Да ($O(N)$) | ✅ Да | 🟢 Разорвана |
| stream.toList() | Java 16+ | ✅ Да ($O(N)$) | ✅ Да | 🟢 Разорвана |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Если поле объявлено как
private final List<String> list, то коллекция защищена от изменений» — final защищает только ссылку, в список можно свободно добавлять элементы черезlist.add(). - ❌ «Collections.unmodifiableList(original) защищает от изменений исходного списка» — это обёртка; изменение оригинала отразится в обёртке.
- ❌ «List.copyOf() защищает мутабельные объекты внутри списка» — это поверхностная защита (Shallow Copy); поля объектов внутри списка можно изменить.