📦 Розділ 4 · Питання #24

Як працює Collections.unmodifiableList() всередині

Клас Collections.unmodifiableList() спроєктований як класичний Декоратор (Decorator / Wrapper). Він не створює новий список і не дублює елементи в оперативній пам'яті. Натомість...


🟢 Junior Level

1. Архітектурний принцип

Клас Collections.unmodifiableList() спроєктований як класичний Декоратор (Decorator / Wrapper). Він не створює новий список і не дублює елементи в оперативній пам’яті. Натомість він повертає об’єкт спеціального внутрішнього класу Collections.UnmodifiableList, який зберігає пряме посилання на вихідний список.

  • Методи читання (get, size, contains, indexOf) прозоро делегуються базовому списку.
  • Методи модифікації (add, set, remove, clear) перевизначені й безумовно викидають UnsupportedOperationException.
List<String> rawList = new ArrayList<>();
rawList.add("A");

List<String> unmodifiable = Collections.unmodifiableList(rawList);

unmodifiable.get(0);   // Делегує виклик: rawList.get(0) -> повертає "A"
unmodifiable.add("B"); // Блокує операцію: throw new UnsupportedOperationException()

2. Схема делегування викликів

                 Виклик get(0)
  Клієнт ────────────────────────► UnmodifiableList (Обгортка)
                                            │
                                            │ Делегує: list.get(0)
                                            ▼
                                     ArrayList (Оригінал)
                                            │
                                  Повертає елемент

                 Виклик add("X")
  Клієнт ────────────────────────► UnmodifiableList (Обгортка)
                                            │
                                            ▼
                                  throw new UnsupportedOperationException()

🟡 Middle Level

1. Розбір вихідного коду OpenJDK

Усередині java.util.Collections визначено ланцюжок вкладених статичних класів:

// Базовий декоратор для всіх типів колекцій:
static class UnmodifiableCollection<E> implements Collection<E>, Serializable {
    @SuppressWarnings("serial")
    final Collection<? extends E> c; // Посилання на оригінал

    UnmodifiableCollection(Collection<? extends E> c) {
        if (c == null) throw new NullPointerException();
        this.c = c;
    }

    // Операції читання прозоро делегують:
    public int size()                   { return c.size(); }
    public boolean isEmpty()            { return c.isEmpty(); }
    public boolean contains(Object o)   { return c.contains(o); }

    // Мутаційні операції жорстко блокуються:
    public boolean add(E e) {
        throw new UnsupportedOperationException();
    }
    public boolean remove(Object o) {
        throw new UnsupportedOperationException();
    }
    public void clear() {
        throw new UnsupportedOperationException();
    }

    // Ітератор огортається в unmodifiable-проекцію:
    public Iterator<E> iterator() {
        return new Iterator<E>() {
            private final Iterator<? extends E> i = c.iterator();
            public boolean hasNext() { return i.hasNext(); }
            public E next()          { return i.next(); }
            public void remove() {
                throw new UnsupportedOperationException();
            }
        };
    }
}

Клас UnmodifiableList успадковує UnmodifiableCollection та перевизначає методи списку:

static class UnmodifiableList<E> extends UnmodifiableCollection<E> implements List<E> {
    final List<? extends E> list;

    UnmodifiableList(List<? extends E> list) {
        super(list);
        this.list = list;
    }

    public E get(int index) { return list.get(index); }
    
    public E set(int index, E element) {
        throw new UnsupportedOperationException();
    }
    public void add(int index, E element) {
        throw new UnsupportedOperationException();
    }
    public E remove(int index) {
        throw new UnsupportedOperationException();
    }

    // Захист default-методів Java 8:
    public void replaceAll(UnaryOperator<E> operator) {
        throw new UnsupportedOperationException();
    }
    public void sort(Comparator<? super E> c) {
        throw new UnsupportedOperationException();
    }
}

2. Динамічний вибір реалізації: оптимізація RandomAccess

У фабричному методі Collections.unmodifiableList() реалізовано критичне розгалуження:

public static <T> List<T> unmodifiableList(List<? extends T> list) {
    return (list instanceof RandomAccess ?
            new UnmodifiableRandomAccessList<>(list) :
            new UnmodifiableList<>(list));
}
  • Якщо передати ArrayList, метод поверне UnmodifiableRandomAccessList (реалізує маркерний інтерфейс RandomAccess). Алгоритми на кшталт Collections.binarySearch() будуть застосовувати прямий доступ за індексом за $O(1)$.
  • Якщо передати LinkedList, метод поверне базовий UnmodifiableList (без RandomAccess). Алгоритми орієнтуватимуться на ітератори, запобігаючи деградації до квадратичної складності $O(N^2)$.

🔴 Senior Level

1. JIT-компіляція та накладні витрати делегування

На рівні машинного коду апаратний оверхед від виклику unmodifiableList.get(i) практично відсутній:

  • При виклику методу обгортки виникає додатковий рівень індирекції list.get(index).
  • У «гарячих» циклах JIT-компілятор JVM (C2 Server Compiler) виконує інлайнінг методів (Method Inlining): тіло методу get() обгортки вбудовується безпосередньо в точку виклику.
  • Завдяки мономорфному профілюванню точок виклику (Monomorphic Call Site) непрямий виклик зводиться до прямого читання пам’яті за 1 такт процесора. Продуктивність ідентична прямій роботі з ArrayList.

2. Вразливість до рефлексії та захист через JPMS (Java 9+)

Оскільки посилання на оригінальний список зберігається у final-полі list, у Java 8 та старіших версіях існувала відома вразливість інкапсуляції:

List<String> secret = new ArrayList<>(List.of("password123"));
List<String> publicView = Collections.unmodifiableList(secret);

// Обхід захисту через Reflection (Java 8):
Field field = publicView.getClass().getDeclaredField("list");
field.setAccessible(true);
List<String> backdoor = (List<String>) field.get(publicView);
backdoor.add("hacked"); // Успішно змінили оригінальний список в обхід захисту!

Вирішення в сучасній платформі:

  1. Java Platform Module System (Java 9+): Пакет java.util у системному модулі java.base захищений від глибокої рефлексії за замовчуванням. Спроба виклику field.setAccessible(true) призведе до викидання InaccessibleObjectException.
  2. Справжні незмінні колекції (List.of, List.copyOf): Класи ImmutableCollections.ListN зберігають дані у власних компактних масивах і взагалі не містять посилання на мутабельний екземпляр.

3. Механіка subList() та listIterator()

  • Метод subList(int fromIndex, int toIndex) повертає новий екземпляр UnmodifiableList або UnmodifiableRandomAccessList навколо підсписку базової колекції list.subList(from, to). Спроба мутувати отриманий зріз також буде негайно заблокована.
  • Метод listIterator() повертає UnmodifiableListIterator, у якому методи add(e), set(e) та remove() перевизначені з викиданням UnsupportedOperationException. При цьому навігаційні методи (next, previous, nextIndex, previousIndex) працюють повноцінно.

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

30-секундна відповідь (Elevator Pitch)

«Collections.unmodifiableList() функціонує за патерном «Декоратор». Він повертає легкозважений екземпляр UnmodifiableList (або UnmodifiableRandomAccessList, якщо вихідний список реалізує RandomAccess), який зберігає пряме посилання на базову колекцію. Усі методи читання прозоро делегуються оригіналу, а мутуючі методи (add, set, remove, sort, replaceAll) безумовно викидають UnsupportedOperationException. Завдяки JIT-інлайнінгу накладні витрати на виклики зводяться до нуля, проте обгортка не дублює дані: будь-які зміни в базовому списку миттєво відбиваються у представленні».


3 каверзних запитання з інтерв’ю та відповіді

1. Чому в Java 8 методи replaceAll() та sort() довелося явно перевизначати в UnmodifiableList?

Відповідь: В інтерфейсі List у Java 8 з’явилися default-методи sort() та replaceAll(). За замовчуванням їхня реалізація мутує список через ListIterator.set(). Якби розробники JDK не перевизначили їх у класі UnmodifiableList, виклик unmodifiableList.sort() почав би переставляти елементи, і виняток UnsupportedOperationException вилетів би лише всередині сортування під час першої спроби виклику set(). Це залишило б список у напіврозібраному (пошкодженому) стані. Явне перевизначення забезпечує миттєвий збій (Fail-Fast) ще до початку будь-яких мутацій.

2. Навіщо потрібен окремий клас UnmodifiableRandomAccessList?

Відповідь: Інтерфейс RandomAccess є маркерним. Якби існував лише єдиний клас UnmodifiableList, що реалізує RandomAccess, то при загортанні LinkedList алгоритми (наприклад, Collections.binarySearch()) сприймали б його як список зі швидким довільним доступом і зверталися за індексом через get(i), що призвело б до падіння швидкодії до $O(N^2)$. Якби UnmodifiableList взагалі не реалізував RandomAccess, то ArrayList втрачав би цю оптимізацію. Наявність двох окремих класів розв’язує цю дилему.

3. Чи успадковує UnmodifiableList властивість серіалізовності базового списку?

Відповідь: Клас UnmodifiableCollection прямо реалізує інтерфейс java.io.Serializable. Проте якщо переданий базовий список не реалізував Serializable (наприклад, кастомний List без маркера), то під час спроби серіалізувати обгортку через ObjectOutputStream буде викинуто виняток java.io.NotSerializableException.


Типові помилки та Red Flags

  • ❌ Стверджувати, що unmodifiableList() копіює елементи в новий масив: Це обгортка-декоратор, яка тримає лише посилання на вихідний список.
  • ❌ Думка, що зріз subList() повертає змінний список: Обгортка рекурсивно повертає unmodifiable-зрізи.
  • ❌ Очікування захисту від змін іншими потоками: Модифікація вихідного списку іншим потоком викличе ConcurrentModificationException під час обходу обгортки ітератором.
  • ❌ Вважати, що listIterator() дозволяє змінювати елементи: Ітератор списку також обгорнутий і блокує виклики set, add та remove.

Пов’язані теми