🔒 Розділ 13 · Питання #12

Як захистити колекцію від змін

В Java існує три основних способи захисту колекцій від змін, кожен з яких призначений для свого сценарію:


🟢 Junior Level

30-секундна відповідь

В Java існує три основних способи захисту колекцій від змін, кожен з яких призначений для свого сценарію:

  1. Collections.unmodifiableList(original) (Java 1.2+): створює легковажну обгортку (View) поверх наявної колекції. При виклику будь-яких методів модифікації (add(), remove(), clear()) викидається UnsupportedOperationException. Однак якщо оригінальна колекція зміниться, ці зміни негайно відобразяться і в обгортці!
  2. List.of(e1, e2, ...) (Java 9+): фабричний метод для створення компактних, істинно незмінних колекцій із заздалегідь визначеного набору елементів (літералів/констант). Суворо забороняє елементи null.
  3. 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:

[Клієнтський код] ──► [UnmodifiableList Wrapper] ──► [Оригінальний 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)

Колекції 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:
    1. Захист від прихованих залежностей: запобігти написанню коду, що неявно покладається на порядок ітерації в колекціях, де порядок не гарантується контрактом інтерфейсу.
    2. Захист від 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()). Це переслідує дві мети:

  1. Захистити вебсервіси від атак типу Hash-DoS (навмисний підбір ключів з однаковими колізіями для перевантаження CPU).
  2. Змусити розробників писати коректний код: якщо порядок елементів важливий, розробник зобов’язаний явно використовувати LinkedHashSet або TreeSet, а не покладатися на випадковий порядок Set.

3. Чим з точки зору використання пам’яті List.of("a", "b") перевершує Collections.unmodifiableList(new ArrayList<>(List.of("a", "b"))) в 64-бітній HotSpot JVM?

Відповідь: Різниця у витратах пам’яті є кардинальною:

  1. Collections.unmodifiableList(new ArrayList<>(...)):
    • Об’єкт UnmodifiableList (16 байт заголовок + 4 байти посилання = 24 байти з вирівнюванням).
    • Об’єкт ArrayList (16 байт заголовок + 4 байти посилання на масив + 4 байти size + modCount = 32 байти).
    • Масив Object[10] (за замовчуванням буфер ArrayList алокує мінімум 10 елементів: 16 байт заголовок + 10 * 4 байти = 56 байт).
    • Разом: понад 110 байтів тільки на службові заголовки та порожні слоти!
  2. 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 на етапі створення колекції дозволяє локалізувати помилку безпосередньо в місці її виникнення (fail-fast), не дозволяючи поширювати некоректні дані кодовою базою.

🎯 Шпаргалка для інтерв’ю

Зведена таблиця методів захисту колекцій

| Метод | Коли застосовувати | Плюси | Мінуси | | :— | :— | :— | :— | | 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(), то й об’єкти всередині неї не можна змінити» — це поверхневий захист (Shallow Immutability); виклик методів-мутаторів на об’єктах усередині списку змінить їх стан.
  • ❌ «UnmodifiableList робить колекцію потокобезпечною» — ні, при паралельній модифікації оригіналу ітератор обгортки викине ConcurrentModificationException.

Пов’язані питання