Немодифицируемые коллекции в Java
Создание неизменяемых коллекций в Java с помощью List.of, Set.of, Map.of и обёрток Collections.unmodifiable*.
Модифицируемая коллекция позволяет любому, у кого есть ссылка, изменять её содержимое. Немодифицируемая коллекция — нет: вызов add, remove, put, clear или set бросает UnsupportedOperationException. В Java есть два взаимодополняющих способа создать такую коллекцию: фабрики .of(...), появившиеся в Java 9 (List.of, Set.of, Map.of, Map.ofEntries), и старые обёртки Collections.unmodifiable*. На точке вызова они похожи, но отличаются в двух важных аспектах, и выбор инструмента зависит от того, что именно вам нужно.
Эта глава завершает часть о фреймворке коллекций, предоставляя вам чистый, современный рецепт для «дайте мне константу» и «дайте мне снимок».
Зачем нужна неизменяемость
Три конкретных преимущества, оправдывающих этот паттерн:
- Безопасное совместное использование. Передайте немодифицируемый список конструктору, рабочему потоку или потребителю событий, и вам не нужно беспокоиться о том, что они изменят ваше состояние. Тип на уровне компилятора не говорит «только для чтения», но среда выполнения — да.
- Безопасное хеширование. Помещать изменяемый
ListвHashSet— это ошибка: если содержимое списка изменится, изменится егоhashCode, и множество потеряет этот элемент. Немодифицируемые коллекции полностью обходят эту проблему. - Лучший дизайн API. Возврат немодифицируемого представления из геттера говорит: «это моё — читай, но не меняй». Без этого каждый вызывающий сам должен решать, делать ли защитную копию.
Два подхода
List.of, Set.of, Map.of, Map.ofEntries — по-настоящему неизменяемые коллекции
Добавлены в Java 9. Они создают новую коллекцию со своим внутренним хранилищем. Ни у кого больше нет ссылки на него:
List<String> roles = List.of("admin", "editor", "viewer");
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
Map<String, Integer> ages = Map.of("alice", 30, "bob", 25);
Map<String, Integer> many = Map.ofEntries(
Map.entry("alice", 30),
Map.entry("bob", 25),
Map.entry("carol", 28)
);Используйте их для констант и литералов — небольших фиксированных коллекций, которые вы вписываете в код. JIT компилирует их в очень компактные, малонакладные представления (часто в виде одного встроенного массива). Затраты на выделение памяти равны нулю сверх того, что занимает сам литерал.
Три ограничения, которые нужно помнить:
- Нет элементов
null, нет ключейnull, нет значенийnull.List.of("a", null)бросаетNullPointerExceptionпри создании. Если нужно представить «отсутствие», используйтеOptionalили пропустите ключ в map. - Нет дубликатов для
Set.ofиMap.of.Set.of("a", "a")бросаетIllegalArgumentException. Они предназначены для литеральных данных, которые вы контролируете. Map.ofимеет перегрузки только до 10 записей. Для 11 и более используйтеMap.ofEntries(Map.entry(...), Map.entry(...), ...).
Collections.unmodifiableList(coll) и др. — представления существующей коллекции
Оборачивают коллекцию в представление только для чтения (view). Оригинал по-прежнему изменяем, и изменения через оригинал видны через представление:
List<String> mutable = new ArrayList<>(List.of("a", "b", "c"));
List<String> view = Collections.unmodifiableList(mutable);
view.add("d"); // throws UnsupportedOperationException
mutable.add("d"); // legal — and the view sees the change
System.out.println(view); // [a, b, c, d]Используйте их, когда хотите открыть доступ к внутренней коллекции без копирования и без предоставления вызывающим права на изменение. Классический паттерн — геттер:
public List<String> getNames() {
return Collections.unmodifiableList(this.names);
}Вызывающий не сможет изменить this.names через возвращённое представление. Вы — сможете. Если хотите запретить и себе, скопируйте:
return List.copyOf(this.names);…это и есть третий подход.
List.copyOf, Set.copyOf, Map.copyOf — снимок, затем заморозка
Сокращение для «скопировать текущее содержимое в новую неизменяемую коллекцию»:
List<String> snapshot = List.copyOf(mutable);После этого вызова snapshot полностью независим от mutable. Последующие изменения mutable невидимы через snapshot. Есть и умная оптимизация: если источник уже является немодифицируемой коллекцией, созданной List.of / List.copyOf, вызов возвращает сам источник — без выделения памяти.
copyOf отклоняет элементы null, так же как of. Если источник может содержать null, используйте вместо этого Collections.unmodifiableList(new ArrayList<>(source)).
Три паттерна в сводке
| Паттерн | Независим от источника? | Допускает null? | Используйте когда |
|---|---|---|---|
List.of("a", "b") | н/д (нет источника) | Нет | Литеральные константы |
List.copyOf(source) | Да — собственное хранилище | Нет | Снимок в момент времени |
Collections.unmodifiableList(source) | Нет — представление | Да | Открыть внутреннее состояние только для чтения |
Когда на точке вызова читается «это буквально жёстко закодированные данные?» — берите of. Когда читается «хочу замороженный снимок того, что есть прямо сейчас» — берите copyOf. Когда читается «хочу, чтобы текущее содержимое было видимым, но не изменяемым через эту ссылку» — берите unmodifiableList.
Поверхностная, а не глубокая неизменяемость
Все три подхода являются поверхностными — они замораживают структуру коллекции, но не элементы внутри неё.
List<int[]> arrays = List.of(new int[]{1, 2}, new int[]{3, 4});
arrays.add(new int[]{5}); // UnsupportedOperationException
arrays.get(0)[0] = 99; // OK — and now the list contains {99, 2}Если нужна глубокая неизменяемость, выбирайте типы элементов, которые сами по себе неизменяемы. Records с примитивными полями или полями типа String — да. Records с изменяемыми полями — нет. Это та же оговорка, что применяется к final-ссылкам в целом: привязка фиксирована, целевой объект — нет.
Set.of и Map.of имеют неопределённый порядок итерации
Два намеренных дизайнерских решения, которые часто удивляют:
Set.ofиMap.ofнамеренно рандомизируют порядок итерации между запусками одной JVM. Если вы пишете код, зависящий от определённого порядка этих коллекций, тесты будут нестабильными. ИспользуйтеList.of(который сохраняет порядок литерала) илиLinkedHashSet/LinkedHashMap, обёрнутые вCollections.unmodifiable*, когда порядок действительно важен.Set.of(a, b)иSet.of(b, a)могут итерироваться по-разному даже в рамках одного запуска, если значения хешируются по-разному. Не сравнивайте через toString.
Это сделано намеренно — Java не даёт вам случайно зависеть от порядка, чтобы реализация была свободна его менять.
Что немодифицируемость не даёт
- Она не обеспечивает потокобезопасность при чтении изменяемых полей элементов. Если элементы изменяемы и другой поток их изменяет, синхронизация необходима в любом случае.
- Она не делает базовую коллекцию потокобезопасной.
Collections.unmodifiableList(arrayList)— это представление не потокобезопасного списка; если другой поток делаетaddвarrayList, чтение через представление может увидеть разорванное состояние. Для потокобезопасной неизменяемости подходитList.copyOf(илиList.of) — они владеют приватным хранилищем. - Она не делает
.equalsнезависимым от порядка.List, возвращённыйList.of, по-прежнему сравнивается по позиции с другими списками, а не по содержимому.
Рабочий пример: литералы, снимки, представления и ловушка поверхностности
Программа ниже демонстрирует все три подхода рядом друг с другом, показывает сюрприз «представление видит мутации», обещание «копия независима» и ловушку поверхностности, которая удивляет всех в первый раз.
Что следует вынести из запуска:
List.ofиList.copyOfоба создают настоящую неизменяемую коллекцию — они отклоняют любые мутации. Разница лишь в том, задали ли вы данные литерально или скопировали откуда-то ещё.- Представление
Collections.unmodifiableListотклонилоview.add, но принялоbacking.addчерез оригинальную ссылку. Изменения через базовый список стали видны через представление. Это определяющая особенность представления и причина, по которой этот подход не является заменойcopyOfв недоверенном коде. - Ловушка поверхностности реальна: элементы
int[]неизменяемогоList<int[]>сами по себе изменяемы, и редактирование одного перезаписывает «замороженный» список. Если нужна глубокая неизменяемость, элементы сами должны быть неизменяемыми. Set.ofотклонил дубликат, аMap.ofотклонил значениеnull— оба при создании. Эти коллекции быстро и громко сигнализируют об ошибках; это особенность.List.copyOfуже неизменяемого списка вернул тот же экземпляр без выделения памяти. Это оптимизация JDK, и именно поэтому «всегда копировать на выходе» дёшево, когда источник уже неизменяем.
Что дальше — и вперёд к части 12
На этом закрывается часть Collections Framework. Теперь вы знаете все реализации (ArrayList, LinkedList, HashMap, TreeMap, очереди, деки и остальное), все интерфейсы (Collection, List, Set, Map, Queue, Deque), курсоры итерации (Iterator, ListIterator), интерфейсы упорядочивания (Comparable, Comparator), статический инструментарий (Collections) и историю с неизменяемостью.
Следующая часть — Functional Programming — кардинально меняет направление. Вместо как хранить данные она рассматривает как выражать преобразования данных. Первая глава, Functional Programming in Java, вводит ментальную модель: функции как значения, неизменяемость, чистые функции и композиция. Далее часть охватывает лямбды, ссылки на методы, встроенные функциональные интерфейсы (Function, Predicate, Consumer, Supplier), Optional и потоки — которые используют изученные вами коллекции в качестве источника и стока.
Большинство паттернов в этой части — list.sort(Comparator.comparing(Person::name)), map.getOrDefault(k, 0), stream().filter(...).toList() — уже функциональны по духу. Часть 12 делает этот дух явным и показывает, как применять его ко всему остальному.