Как защитить коллекцию от изменений
В Java существует три основных способа защиты коллекций от изменений, каждый из которых предназначен для своего сценария:
🟢 Junior Level
30-секундный ответ
В Java существует три основных способа защиты коллекций от изменений, каждый из которых предназначен для своего сценария:
Collections.unmodifiableList(original)(Java 1.2+): создаёт легковесную обёртку (View) поверх существующей коллекции. При вызове любых методов модификации (add(),remove(),clear()) выбрасываетсяUnsupportedOperationException. Однако если оригинальная коллекция изменится, эти изменения отразятся и в обёртке!List.of(e1, e2, ...)(Java 9+): фабричный метод для создания компактных, истинно неизменяемых коллекций из предопределённого набора элементов (литералов/констант). Запрещает элементыnull.List.copyOf(collection)(Java 10+): создаёт независимую защитную копию существующей коллекции. Любые последующие изменения оригинала никак не влияют на полученный список. Запрещает элементыnull.
Аналогичные методы существуют для Set (Set.of(), Set.copyOf(), Collections.unmodifiableSet()) и Map (Map.of(), Map.copyOf(), Collections.unmodifiableMap()).
Наглядный пример
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class CollectionProtectionDemo {
public static void main(String[] args) {
List<String> mutable = new ArrayList<>();
mutable.add("Java");
// Способ 1: Обёртка (живая связь)
List<String> view = Collections.unmodifiableList(mutable);
// Способ 2: Защитная копия (полная изоляция)
List<String> copy = List.copyOf(mutable);
// Попытка модифицировать защищённые списки:
try {
view.add("Kotlin"); // UnsupportedOperationException!
} catch (UnsupportedOperationException e) {
System.out.println("Модификация view запрещена!");
}
// Модифицируем исходный список:
mutable.add("Scala");
System.out.println("view: " + view); // [Java, Scala] — ОБЁРТКА ИЗМЕНИЛАСЬ!
System.out.println("copy: " + copy); // [Java] — КОПИЯ ЗАЩИЩЕНА!
}
}
🟡 Middle Level
Сравнительный анализ подходов
| Характеристика | Collections.unmodifiable* | List.of(...) | List.copyOf(...) | Stream.toList() (Java 16+) |
| :— | :— | :— | :— | :— |
| Время создания | $O(1)$ | $O(N)$ (для $N \le 2$ — $O(1)$) | $O(N)$ (или $O(1)$) | $O(N)$ |
| Выделение памяти | Только объект-обёртка | Минимальное (нет capacity) | Минимальное | Минимальное |
| Связь с оригиналом | Да (Live View) | Нет | Нет | Нет |
| Null-элементы | Разрешены (если есть в базе) | ❌ Бросает NPE | ❌ Бросает NPE | ✅ Разрешены! |
| Оптимизация повторов | Нет | Нет | Да (возвращает this для ImmutableCollections) | Нет |
Опасность «Живой связи» (Live View)
Collections.unmodifiableList не копирует элементы. В памяти создаётся объект-декоратор, хранящий ссылку на исходный ArrayList:
[Client Code] ──► [UnmodifiableList Wrapper] ──► [Original ArrayList]
▲
[Attacker / External Code] ─────────────────────────────┘
Если ссылка на Original ArrayList осталась доступна вызывающей стороне, вызов original.add("hacked") мгновенно изменит содержимое коллекции, на которую смотрит клиент через unmodifiableList.
Shallow Immutability (Поверхностная защита)
Ни один стандартный метод JDK не защищает объекты, находящиеся внутри коллекции:
record User(String name) {
// Допустим, у User есть мутабельные поля
}
List<User> list = List.of(new User("Alice"));
// list.add(...) бросит исключение, но:
list.get(0).setSomeMutableField("New Value"); // Внутреннее состояние объекта мутирует!
Для защиты изменяемых элементов требуется глубокое копирование (Deep Copy) всей иерархии объектов.
🔴 Senior Level
Внутреннее устройство: JEP 269 (Immutable Collections Architecture)
Коллекции List.of() и List.copyOf() реализованы в закрытом пакете java.util.ImmutableCollections:
- Для 0, 1 и 2 элементов используются специализированные легковесные классы:
List0,List12:// В List12 нет даже массива! Поля объявлены напрямую: static final class List12<E> extends AbstractImmutableList<E> { private final E e0; private final E e1; // null, если размер 1 }Это исключает аллокацию внутреннего массива
Object[]и оверхед заголовка массива в куче. - Для 3+ элементов используется
ListN, оборачивающий точно подогнанный массивObject[]без избыточного запаса ёмкости (capacity). - В них отсутствуют поля
modCountи механизмы расширения, что экономит до 60% памяти по сравнению сnew ArrayList<>().
Рандомизация порядка итерации в Set.of() и Map.of()
При запуске JVM для Set.of() и Map.of() инициализируется случайное число соли (salt):
- Порядок обхода элементов в
Set.of("a", "b", "c")не фиксирован и меняется от одного запуска JVM к другому. - Зачем это сделано разработчиками OpenJDK:
- Защита от скрытых зависимостей: предотвратить написание кода, неявно полагающегося на порядок итерации в коллекциях, не гарантирующих порядок по контракту интерфейса.
- Защита от DoS-атак на основе коллизий хэшей (Hash Flooding Attack).
Stream.toList() vs List.copyOf() в Java 16+
Частый вопрос на собеседованиях Senior-уровня:
- Метод
Stream.toList()(Java 16+) возвращает неизменяемый список (ImmutableCollections.ListN). - Критическое отличие:
List.of()иList.copyOf()строго запрещаютnull, выбрасываяNullPointerException. ОднакоStream.toList()допускает наличие элементовnull!List<String> list = Stream.of("A", null, "B").toList(); // УСПЕШНО! List.copyOf(list); // ❌ NullPointerException!
4 Tricky Questions
1. Может ли чтение из Collections.unmodifiableList(list) выбросить ConcurrentModificationException в однопоточном или многопоточном коде?
Ответ:
Да, абсолютно!
Поскольку unmodifiableList является лишь обёрткой над исходным списком:
- В многопоточной среде, если Поток 1 итерируется по
unmodifiableList, а Поток 2 в это время вызываетadd()илиremove()на базовомArrayList, счётчикmodCountбазового списка изменится. Итератор Потока 1 немедленно выброситConcurrentModificationException. - Защищённая обёртка гарантирует только отсутствие модификаций через свой собственный интерфейс, но не является потокобезопасной и не изолирует от гонок данных на оригинальной коллекции.
2. Почему Set.of() и Map.of() намеренно рандомизируют порядок обхода элементов между перезапусками JVM, а Collections.unmodifiableSet(new HashSet<>()) — нет?
Ответ:
В стандартном HashSet порядок итерации детерминирован алгоритмом вычисления hashCode() и размером таблицы бакетов: при одинаковом наборе строк с фиксированным hashCode порядок в HashSet стабилен между запусками.
В реализациях Set.of() и Map.of() разработчики JDK намеренно добавили случайную константу-соль в алгоритм вычисления бакетов (SALT = (new Random()).nextLong()).
Это преследует две цели:
- Защитить веб-сервисы от атак типа Hash-DoS (преднамеренный подбор строк с одинаковыми коллизиями для перегрузки CPU).
- Заставить разработчиков писать корректный код: если порядок элементов важен, разработчик обязан явно использовать
LinkedHashSetилиTreeSet, а не полагаться на случайный порядокSet.
3. Чем с точки зрения расхода памяти List.of("a", "b") превосходит Collections.unmodifiableList(new ArrayList<>(List.of("a", "b"))) в 64-битной HotSpot JVM?
Ответ: Разница в расходе памяти кардинальна:
Collections.unmodifiableList(new ArrayList<>(...)):- Объект
UnmodifiableList(16 байт заголовок + 4 байта ссылка = 24 байта с padding). - Объект
ArrayList(16 байт заголовок + 4 байта ссылка на массив + 4 байтаsize+modCount= 32 байта). - Массив
Object[10](по умолчанию буферArrayListаллоцирует минимум 10 элементов: 16 байт заголовок + 10 * 4 байта = 56 байт). - Итого: более 110 байт только на служебные заголовки и пустые ячейки!
- Объект
List.of("a", "b"):- Инстанциирует
ImmutableCollections.List12. В нём нет внутреннего массива вообще! - 16 байт заголовок + две ссылки
e0,e1(8 байт) = ровно 24 байта в куче. - Экономия памяти составляет более 75%!
- Инстанциирует
4. Допустим, мы вызвали List.copyOf(input) на списке, содержащем null. Что произойдёт? Почему в дизайн List.of и List.copyOf было заложено такое поведение?
Ответ:
Вызов немедленно завершится с NullPointerException.
Разработчики архитектуры Java (в частности, Brian Goetz и Stuart Marks) намеренно сделали новые фабрики коллекций «нетерпимыми к null» (null-hostile):
- Более 90% багов в коллекциях вызваны неоднозначностью
null: означает лиmap.get(key) == null, что ключа нет, или что ключу сопоставлено значениеnull? - Запрет
nullна этапе создания коллекции позволяет сразу локализовать ошибку в месте её возникновения, не допуская распространения испорченных данных по кодовой базе.
🎯 Шпаргалка для интервью
Сводная таблица методов защиты коллекций
| Метод | Когда применять | Плюсы | Минусы |
| :— | :— | :— | :— |
| List.of(...) | Константы, литералы (до 10 шт.) | Минимальная память, сверхбыстрый $O(1)$ | Запрещён null, фиксированное число аргументов |
| List.copyOf(...) | Защитная копия в конструкторе | Отсекает оригинал, оптимизация для immutable | Запрещён null, $O(N)$ копирование |
| Collections.unmodifiable* | Возврат из геттера (если оригинал скрыт) | $O(1)$ создание, разрешён null | «Живая связь» с оригиналом, риск CME |
| Stream.toList() | Результат пайплайна Stream API | Неизменяем, разрешает null | $O(N)$ аллокация |
Красные флаги на собеседовании (Чего говорить нельзя)
- ❌ «Collections.unmodifiableList полностью защищает коллекцию от любых изменений» — нет, если изменить оригинал, данные в обёртке изменятся.
- ❌ «List.of() и List.copyOf() работают точно так же, как new ArrayList()» — нет, они иммутабельны, компактны и бросают NPE при наличии
null. - ❌ «Если упаковать коллекцию в List.of(), то и объекты внутри неё нельзя изменить» — это поверхностная защита; мутабельные методы объектов внутри списка продолжат работать.
- ❌ «UnmodifiableList делает коллекцию потокобезопасной» — нет, при параллельной модификации оригинала итератор обёртки выбросит
ConcurrentModificationException.