🔒 Раздел 13 · Вопрос #29

Как правильно работать с коллекциями в иммутабельных классах

Коллекции — самый уязвимый и сложный аспект при проектировании иммутабельных классов. Коллекция в Java является ссылочным мутабельным объектом, поэтому ключевого слова final у п...


🟢 Junior Level

30-секундный ответ

Коллекции — самый уязвимый и сложный аспект при проектировании иммутабельных классов. Коллекция в Java является ссылочным мутабельным объектом, поэтому ключевого слова final у поля недостаточно.

Чтобы гарантировать 100% иммутабельность, необходимо соблюдать 3 золотых правила:

  1. Никогда не сохранять ссылку на входящую коллекцию: в конструкторе всегда создавать защитную копию (Defensive Copy) через List.copyOf().
  2. Никогда не отдавать мутабельную коллекцию наружу: если поле иммутабельно (List.copyOf), возвращать его напрямую; если поле мутабельно — оборачивать в Collections.unmodifiableList().
  3. Обеспечивать глубокую защиту: если элементы внутри коллекции мутабельны, защитить сам список недостаточно — необходимо делать глубокое копирование (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

  1. До Java 10 (Эпоха Java 8):
    // Требовалось двойное действие: скопировать данные и обернуть в декоратор
    this.list = Collections.unmodifiableList(new ArrayList<>(inputList));
    
  2. В Java 10+ (Современный стандарт):
    this.list = List.copyOf(inputList); // Сразу создаёт компактный неизменяемый список!
    
  3. В 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:

  1. Использовать связку Java 8: Collections.unmodifiableList(new ArrayList<>(input)), которая корректно сохраняет и защищает элементы null.
  2. Либо использовать метод Stream.toList() (Java 16+), который возвращает неизменяемый список, но при этом допускает наличие элементов null:
    this.list = input == null ? List.of() : input.stream().toList();
    

2. Защитит ли коллекцию SortedSet или TreeSet простой вызов Set.copyOf(sortedSet)? Какие скрытые проблемы здесь возникают?

Ответ: Вызов Set.copyOf(sortedSet) защитит коллекцию от модификаций, но полностью разрушит порядок сортировки и контракт компаратора:

  1. Метод Set.copyOf() возвращает реализацию ImmutableCollections.SetN, которая является неупорядоченным хэш-множеством с рандомизированным порядком обхода!
  2. Вся сортировка элементов будет безвозвратно утеряна, а кастомный Comparator проигнорирован.
  3. Как защитить правильно: для упорядоченных коллекций необходимо использовать специализированный метод:
    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 правила работы с коллекциями в иммутабельных классах

  1. Вход (Конструктор): всегда делать защитную копию (List.copyOf(input)).
  2. Выход (Геттер): возвращать неизменяемую коллекцию (если сделан List.copyOf, можно отдавать напрямую).
  3. Глубина: если элементы коллекции мутабельны, делать глубокое копирование (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); поля объектов внутри списка можно изменить.

Связанные вопросы