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