Що таке CopyOnWriteArrayList
Головний принцип роботи:
🟢 Junior Level
1. Що таке CopyOnWriteArrayList
CopyOnWriteArrayList — це потокобезпечна реалізація інтерфейсу List, заснована на ідіомі Copy-On-Write (копіювання при записі).
Головний принцип роботи:
- Будь-яка операція читання (
get,indexOf,contains, обхід через ітератор) виконується без блокувань (Lock-Free) з максимальною швидкістю $O(1)$. - Будь-яка операція зміни (
add,set,remove) створює повну копію внутрішнього масиву, застосовує зміни до цієї копії й атомарно підміняє посилання на масив.
List<String> list = new CopyOnWriteArrayList<>();
// Потокобезпечний запис:
list.add("Java");
list.add("Kotlin");
// Потокобезпечне читання без жодних блокувань:
String lang = list.get(0); // "Java"
2. Порівняння з Collections.synchronizedList
| Критерій | Collections.synchronizedList |
CopyOnWriteArrayList |
|---|---|---|
Операція читання (get) |
Блокує весь список (synchronized) |
Lock-Free (без блокувань) |
Операція запису (add) |
Блокує весь список ($O(1)$ амортизовано) | Створює повну копію масиву ($O(N)$) |
| Ітерація | Вимагає зовнішньої ручної синхронізації | Безпечна без блокувань (Snapshot) |
| Поведінка ітератора | Fail-Fast (ConcurrentModificationException) |
Fail-Safe (ніколи не викидає CME) |
| Найкращий сценарій | Баланс читань і частих записів | Read-Heavy (читань у сотні/тисячі разів більше за записи) |
🟡 Middle Level
1. Як влаштований механізм під капотом
Усередині класу CopyOnWriteArrayList дані зберігаються в єдиному масиві з модифікатором volatile:
public class CopyOnWriteArrayList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable {
final transient Object lock = new Object();
private transient volatile Object[] array;
final Object[] getArray() {
return array;
}
final void setArray(Object[] a) {
array = a;
}
}
Механіка додавання елемента (add):
public boolean add(E e) {
synchronized (lock) { // Захист від паралельних потоків запису
Object[] es = getArray();
int len = es.length;
// 1. Створюємо новий масив на 1 елемент більший і копіюємо дані:
Object[] newElements = Arrays.copyOf(es, len + 1);
// 2. Додаємо новий елемент у кінець:
newElements[len] = e;
// 3. Атомарно підміняємо volatile-посилання:
setArray(newElements);
return true;
}
}
Оскільки посилання array оголошене як volatile, у момент виклику setArray(newElements) спрацьовує правило Happens-Before: усі наступні операції читання гарантовано побачать посилання на новий масив з усіма внесеними змінами.
2. Ітератор як незмінний знімок (Snapshot Iterator)
При виклику list.iterator() повертається легкозважений ітератор COWIterator, який зберігає посилання на масив, актуальний рівно на момент створення ітератора:
List<String> list = new CopyOnWriteArrayList<>(List.of("A", "B", "C"));
Iterator<String> it = list.iterator(); // Зафіксовано знімок: ["A", "B", "C"]
list.add("D"); // Модифікація списку: створено НОВИЙ масив ["A", "B", "C", "D"]
list.remove("A"); // Ще одна модифікація: створено масив ["B", "C", "D"]
// Ітератор спокійно продовжує обхід старого знімка:
while (it.hasNext()) {
System.out.print(it.next() + " "); // Виведе: A B C
}
Критичні властивості знімка:
- Ітератор ніколи не викидає
ConcurrentModificationException. - Ітератор не бачить змін, які сталися в списку після моменту його створення.
- Усі методи мутації через ітератор заборонені:
it.remove(); // Завжди викидає UnsupportedOperationException!
🔴 Senior Level
1. Архітектурна ніша: списки слухачів подій (Event Listeners)
Ідеальний патерн застосування CopyOnWriteArrayList — реєстрація підписників у шинах подій (Observer / Event Bus):
public class EventBus {
// Підписники реєструються рідко (під час старту модулів/сервісів),
// але події публікуються тисячі або мільйони разів на секунду:
private final List<EventListener> listeners = new CopyOnWriteArrayList<>();
public void register(EventListener listener) {
listeners.add(listener); // Рідкісний запис O(N)
}
public void publishEvent(Event event) {
// Читання O(1) без блокувань і без ризику ConcurrentModificationException,
// навіть якщо один із підписників під час обробки викличе listeners.remove(this)!
for (EventListener listener : listeners) {
listener.onEvent(event);
}
}
}
Якби тут використовувався ArrayList із блокуванням synchronized, публікація кожної події блокувала б усі потоки на час виконання onEvent(). Якби використовувався звичайний ArrayList, відписка слухача всередині onEvent() миттєво зламала б обхід винятком ConcurrentModificationException.
2. Проблеми продуктивності: GC Churn та сплески пам’яті (Memory Spikes)
Використання CopyOnWriteArrayList у сценаріях із частим записом (Write-Heavy) є грубою архітектурною помилкою:
- Тимчасовий сплеск споживання пам’яті (Memory Spike):
При додаванні елемента до списку з $10\,000\,000$ посилань ($\approx 40$ МБ на 64-бітній JVM) під час роботи
Arrays.copyOf()у купі (Heap) одночасно співіснують старий масив (40 МБ) і новий масив (40 МБ). Під час пікових навантажень це може спровокуватиOutOfMemoryError. - Тиск на збирач сміття (GC Pressure / Allocation Churn): Кожна операція запису відправляє старий масив у сміття. При 10 000 записів на секунду виділяються й викидаються гігабайти пам’яті, що викликає часті паузи збирача сміття (Stop-the-World Minor GC).
- Алгоритмічна деградація:
Додавання $N$ елементів по одному в циклі виконується за $O(N^2)$, тоді як у звичайному
ArrayListце займає амортизований час $O(N)$.
[!TIP] Якщо необхідно додати одразу кілька елементів, обов’язково використовуйте
list.addAll(collection): цей метод виконає рівно одне копіювання масиву для всієї пачки елементів, скорочуючи витрати з $O(M \cdot N)$ до $O(M + N)$.
3. Еволюція в JDK: чому в Java 11 перейшли з ReentrantLock на synchronized (lock)?
В ранніх версіях Java (до Java 10) CopyOnWriteArrayList використовував final transient ReentrantLock lock = new ReentrantLock().
Починаючи з Java 11, його було замінено на звичайний монітор: final transient Object lock = new Object() і блоки synchronized (lock).
Причини зміни:
- Зменшення споживання пам’яті (Footprint): екземпляр
Objectзаймає лише 16 байтів (із Compressed OOPs), тоді якReentrantLockє складним складеним об’єктом, що містить стан синхронізатора AQS (AbstractQueuedSynchronizer) і чергу очікування. - Оптимізації JVM: сучасні JIT-компілятори JVM надзвичайно ефективно інлайнять та оптимізують монітори
synchronizedза низької конкуренції (Lightweight Locking, Biased Locking в старих JDK). ОскількиCopyOnWriteArrayListпризначений для рідкісних записів, накладні витрати на монітор практично нульові.
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь (Elevator Pitch)
«
CopyOnWriteArrayList— це потокобезпечний список, оптимізований для переважання операцій читання (Read-Heavy). Операції читання виконуються без блокувань (Lock-Free) зvolatile-масиву. За будь-якої модифікації (add,set,remove) під ексклюзивним замком створюється повна копія внутрішнього масиву, модифікується й атомарно підміняється. Ітератор списку працює по незмінному знімку (Snapshot) масиву на момент створення, ніколи не викидаєConcurrentModificationException, але й не підтримує методremove(). Основна сфера застосування — списки підписників та слухачів подій (Listeners / Observers)».
3 каверзних запитання з інтерв’ю та відповіді
1. Чому метод iterator().remove() у CopyOnWriteArrayList викидає UnsupportedOperationException?
Відповідь: Ітератор тримає пряме посилання на зафіксований масив-знімок (snapshot), який існував у мить створення ітератора. Цей масив є незмінним. Якби метод remove() дозволили, виникла б дилема: мутувати оригінальний список під час ітерації (що суперечить семантиці знімка та потребувало б дорогої синхронізації з актуальною версією) чи видаляти елемент лише з локального масиву знімка (що не вплинуло б на базову колекцію і ввело б в оману). Тому розробники JDK заблокували всі операції модифікації через ітератор.
2. Чи гарантує CopyOnWriteArrayList сувору узгодженість (Strong Consistency) при читанні?
Відповідь: Ні, він гарантує слабку або кінцеву узгодженість (Eventual Consistency). Якщо потік $A$ створив ітератор і обходить список, а потік $B$ у цей самий час додав або видалив елементи, потік $A$ не побачить цих змін і працюватиме із застарілими даними (Stale Data). Водночас завдяки volatile-семантиці масиву будь-який прямий виклик list.get(i), виконаний після завершення запису потоком $B$, гарантовано поверне актуальні дані.
3. Чому в Java 11 розробники OpenJDK замінили ReentrantLock на synchronized (lock)?
Відповідь: Задля економії пам’яті та підвищення продуктивності. Поле final Object lock = new Object() важить значно менше за важкий об’єкт ReentrantLock з чергою AQS. Крім того, JVM чудово оптимізує монітори synchronized в умовах слабкої конкуренції (Lightweight Locking), що ідеально відповідає профілю CopyOnWriteArrayList, де записи відбуваються рідко.
Типові помилки та Red Flags
- ❌ Використання
CopyOnWriteArrayListу системах із частим записом (Write-Heavy): Призводить до колосальної деградації швидкодії через копіювання $O(N)$ під час кожної вставки та перевантаження Garbage Collector. - ❌ Спроба модифікувати колекцію через ітератор (
it.remove()): ВикидаєUnsupportedOperationExceptionпрямо в продакшені. - ❌ Поштучне додавання елементів у циклі
for: Додавання 1 000 елементів по одному створить 1 000 повних копій масиву ($O(N^2)$). Необхідно використовувати пакетний методaddAll(). - ❌ Очікування, що ітератор відобразить паралельні зміни: Ітератор читає ізольований знімок на момент свого створення і не реагує на подальші модифікації списку.