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

Що таке Vector і чим він відрізняється від ArrayList

За своєю внутрішньою будовою Vector аналогічний до ArrayList (обидва зберігають елементи в масиві Object[]), проте практично всі публічні методи Vector позначені ключовим словом...


🟢 Junior Level

Vector — це застарілий клас динамічного масиву зі складу JDK 1.0, який у Java 2 (JDK 1.2) був адаптований для реалізації інтерфейсу List.

За своєю внутрішньою будовою Vector аналогічний до ArrayList (обидва зберігають елементи в масиві Object[]), проте практично всі публічні методи Vector позначені ключовим словом synchronized.

Головні відмінності між Vector та ArrayList

Критерій Vector ArrayList
Потокобезпечність Синхронізований (методи містять synchronized) Не синхронізований
Продуктивність Низька (накладні витрати на захоплення блокувань) Висока (відсутність блокувань)
Коефіцієнт розширення Подвоює розмір ($2.0\times$) за замовчуванням Збільшує розмір в $1.5\times$
Налаштування розширення Можна задати точний крок capacityIncrement Фіксований крок $1.5\times$
Застарілі інтерфейси Підтримує Enumeration та Iterator Тільки Iterator та Spliterator
Версія появи Java 1.0 (Legacy) Java 1.2 (Java Collections Framework)
// ❌ АНТИПАТЕРН: Використання Vector у новому коді
List<String> vector = new Vector<>();

// ✅ ПРАВИЛЬНО: ArrayList для однопотокового використання
List<String> list = new ArrayList<>();

// ✅ ПРАВИЛЬНО: Потокобезпечні сучасні альтернативи
List<String> cowList = new CopyOnWriteArrayList<>();
List<String> syncList = Collections.synchronizedList(new ArrayList<>());

🟡 Middle Level

1. Чому синхронізація Vector — це ілюзія безпеки?

Наявність synchronized на кожному окремому методі захищає лише атомарність одиничної операції (наприклад, один виклик add або один get). Проте вона не захищає від стану гонитви (Race Condition) у складених операціях (Check-then-Act):

// ❌ НЕПОТОКОБЕЗПЕЧНИЙ КОД ІЗ VECTOR:
if (!vector.isEmpty()) {
    // У цей момент інший потік встигає викликати vector.clear() або vector.remove(0)
    String item = vector.remove(0); // Викине ArrayIndexOutOfBoundsException!
}

// ❌ Те саме під час перевірки на дублікати:
if (!vector.contains(element)) {
    vector.add(element); // Два потоки можуть одночасно додати дублікат!
}

Щоб зробити складену операцію дійсно потокобезпечною, розробник все одно зобов’язаний використовувати зовнішню синхронізацію:

synchronized (vector) {
    if (!vector.isEmpty()) {
        String item = vector.remove(0);
    }
}

Але якщо для безпеки все одно необхідний зовнішній блок synchronized, то внутрішні блокування кожного методу Vector перетворюються на абсолютно марне гальмо, яке сповільнює виконання в рази.

2. Ітерація по Vector: ризик ConcurrentModificationException

Багато хто помилково вважає, що елементи Vector можна безпечно обходити з різних потоків:

// ❌ ВИКЛИЧЕ ConcurrentModificationException В ІНШОМУ ПОТОЦІ!
for (String s : vector) { // Викликає vector.iterator()
    System.out.println(s);
}

Метод iterator() у Vector повертає стандартний Fail-Fast ітератор, який перевіряє лічильник модифікацій modCount. Якщо під час ітерації інший потік викличе vector.add(), ітератор негайно викине ConcurrentModificationException. Будь-яку ітерацію по Vector у багатопотоковому середовищі обов’язково необхідно огортати в зовнішній блок synchronized(vector)!

3. Різниця в алгоритмі розширення (Resizing)

// Усередині Vector:
int newCapacity = oldCapacity + ((capacityIncrement > 0) ? capacityIncrement : oldCapacity);
  • Якщо в конструкторі new Vector<>(initialCapacity, capacityIncrement) не вказано параметр приросту, місткість подвоюється ($2.0\times$). Це призводить до швидшого зростання невикористаної пам’яті в купі порівняно з $1.5\times$ в ArrayList.
  • В ArrayList можливості задати фіксований крок розширення немає — він завжди множить розмір на $\approx 1.5$.

🔴 Senior Level

1. Проблема суперництва за блокування (Lock Contention)

У Vector всі методи синхронізуються на одному спільному моніторі екземпляра (this):

  • Навіть якщо 50 потоків виконують виключно операції читання vector.get(i), вони не здатні читати дані паралельно!
  • Потоки шикуються в чергу до єдиного монітора, викликаючи колосальний простій процесора (Lock Contention) та зводячи паралелізм нанівець.

Для багатопотокових сценаріїв у сучасній Java застосовують:

  • CopyOnWriteArrayList: операції читання виконуються взагалі без блокувань (Lock-Free), а запис копіює підкладний масив. Ідеально підходить за співвідношення читань до записів 99 до 1.
  • Блокувальні черги (LinkedBlockingQueue, ArrayBlockingQueue): використовують роздільні блокування на читання і запис (takeLock та putLock) і умовні змінні (Condition).

2. Порушення принципу підстановки Лісков (LSP) у java.util.Stack

Клас java.util.Stack є класичним прикладом архітектурної помилки проєктування стандартної бібліотеки Java: його було оголошено як public class Stack<E> extends Vector<E>.

Stack<String> stack = new Stack<>();
stack.push("Нижній");
stack.push("Верхній");

// ❌ Грубе порушення контракту стека (LIFO):
stack.add(1, "Вклинений елемент"); // Метод Vector порушує інваріант LIFO!
stack.remove(0); // Видалення із самого дна стека!
  • Згідно з принципом Лісков (LSP), підклас зобов’язаний коректно замінювати базовий тип. Проте стек не є окремим випадком динамічного масиву: він повинен забороняти довільний доступ за індексом.
  • Рішення: замість Stack завжди слід використовувати інтерфейс Deque та реалізацію ArrayDeque (Deque<String> stack = new ArrayDeque<>()).

3. Enumeration проти Iterator

Vector зберігає історичний метод elements(), який повертає інтерфейс java.util.Enumeration<E>:

  • Enumeration — інтерфейс JDK 1.0 із методами hasMoreElements() та nextElement();
  • НЕ є Fail-Fast: він не відстежує лічильник modCount і під час паралельної модифікації колекції може повернути некоректні дані або взагалі зациклитися;
  • Не містить методу безпечного видалення remove().

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

30-секундна відповідь для співбесіди

«Vector — це застарілий потокобезпечний аналог ArrayList із JDK 1.0, у якому кожен публічний метод синхронізований за монітором екземпляра (synchronized). За замовчуванням Vector подвоює місткість ($2.0\times$), тоді як ArrayList збільшує її в $1.5$ раза. Синхронізація методів Vector надлишково уповільнює однопотоковий код і створює жорстке суперництво за блокування (Lock Contention) під час багатопотокового читання. До того ж Vector не захищає від гонитв у складених операціях (check-then-act). У сучасному коді Vector не використовується: для одного потоку обирають ArrayList, а для багатопотокових завдань — CopyOnWriteArrayList або конкурентні черги».

Питання підвищеної складності з відповідями

  1. Чому Vector не вважається повноцінно потокобезпечним класом, попри те що всі його методи синхронізовані? Відповідь: Синхронізація методів Vector гарантує потокобезпечність лише окремих ізольованих викликів (наприклад, одного методу add або get). Проте в реальних застосунках операції майже завжди є складеними (Check-then-Act): наприклад, перевірка if (!vector.contains(e)) vector.add(e) або вилучення if (!vector.isEmpty()) vector.remove(0). Між перевіркою та виконанням дії монітор вивільняється, й інший потік може встигнути змінити стан колекції, викликавши виняток або логічну помилку. Для безпеки складених операцій розробник все одно змушений синхронізувати весь блок коду зовнішнім блокуванням synchronized(vector).

  2. Як правильно організувати потокобезпечний обхід елементів Vector? Відповідь: Ітератор vector.iterator() або цикл for-each викидають ConcurrentModificationException, якщо інший потік змінить Vector під час обходу. Щоб безпечно обійти Vector у багатопотоковому середовищі, весь цикл ітерації необхідно явно огорнути в синхронізований блок за самим об’єктом вектора:
    synchronized (vector) {
        for (String item : vector) {
            process(item);
        }
    }
    
  3. Чому клас java.util.Stack вважається невдалим архітектурним рішенням у Java? Відповідь: Stack напряму успадковує Vector (class Stack<E> extends Vector<E>), що порушує принцип підстановки Барбари Лісков (LSP) та принцип єдиної відповідальності. Стек повинен підтримувати лише операції з вершиною (LIFO: push, pop, peek). Проте через успадкування від Vector клієнт стека може викликати stack.add(index, e) або stack.remove(0), довільно порушуючи фундаментальний інваріант LIFO. Крім того, спадкування від Vector накладає на всі виклики стека непотрібні накладні витрати моніторів synchronized.

  4. У чому різниця між Enumeration із Vector та стандартним Iterator? Відповідь: Enumeration — застарілий інтерфейс із JDK 1.0 (hasMoreElements(), nextElement()). Він не підтримує видалення елементів під час обходу (немає методу remove()) і, найголовніше, він не є Fail-Fast — він не перевіряє лічильник модифікацій modCount, тому при модифікації колекції іншим потоком може зациклитися або повернути спотворені дані. Сучасний Iterator є fail-fast і негайно викидає ConcurrentModificationException.

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

  • ❌ Стверджувати, що Vector слід використовувати завжди, коли є кілька потоків. (Red Flag: незнання java.util.concurrent та проблем блокування моніторів).
  • ❌ Думати, що елементи Vector можна безпечно обходити у циклі for-each без зовнішнього synchronized. (Red Flag: нерозуміння механіки Fail-Fast ітераторів).
  • ❌ Використовувати клас Stack для нових завдань. (Замість нього завжди слід застосовувати ArrayDeque).

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