Что такое 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 (никогда не падает) |
| Лучший сценарий | Баланс чтений и частых записей | 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,
// даже если один из слушателей во время обработки решит отписаться!
for (EventListener listener : listeners) {
listener.onEvent(event);
}
}
}
Если бы здесь использовался ArrayList с synchronized, публикация каждого события тормозила бы все потоки. Если бы использовался обычный ArrayList, отписка слушателя внутри метода onEvent() вызвала бы ConcurrentModificationException.
2. Проблемы производительности: GC Churn и скачки памяти (Memory Spikes)
Использование CopyOnWriteArrayList в сценариях с частой записью является тяжелой архитектурной ошибкой:
- Временный всплеск потребления памяти (Memory Spike):
При добавлении элемента в список из $10\,000\,000$ ссылок ($\approx 40$ МБ на 64-битной JVM) во время выполнения
Arrays.copyOf()в Heap одновременно сосуществуют старый массив (40 МБ) и новый массив (40 МБ). В моменты пиковых нагрузок это может спровоцироватьOutOfMemoryError. - Давление на сборщик мусора (GC Pressure): Каждая операция записи выбрасывает старый массив в мусор. При 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)$.
🎯 Шпаргалка для интервью
30-секундный ответ (Elevator Pitch)
«
CopyOnWriteArrayList— это потокобезопасный список, оптимизированный под преобладание операций чтения (Read-Heavy). Операции чтения выполняются без блокировок (Lock-Free) через чтение изvolatile-массива. При любой модификации (add,set,remove) под эксклюзивным замком создается полная копия массива, модифицируется и подменяется атомарно. Итератор работает по неизменяемому снимку (Snapshot) массива на момент создания, никогда не выбрасываетConcurrentModificationException, но не поддерживает методremove(). Главная сфера применения — списки слушателей событий (Listeners / Observers)».
3 каверзных вопроса на интервью с ответами
1. Почему метод iterator().remove() в CopyOnWriteArrayList выбрасывает UnsupportedOperationException?
Ответ: Итератор хранит прямую ссылку на фиксированный массив-снимок (snapshot), существовавший на момент вызова iterator(). Сам массив неизменяем. Если бы remove() был разрешен, пришлось бы либо мутировать базовый список (что противоречит идее снимка и не синхронизировано с текущей версией списка), либо модифицировать локальный массив снимка (что не повлияло бы на сам CopyOnWriteArrayList). Поэтому разработчики JDK намеренно сделали все методы модификации итератора неподдерживаемыми.
2. Гарантирует ли CopyOnWriteArrayList строгую согласованность (Strong Consistency) при чтении?
Ответ: Нет, он гарантирует слабую или событийную согласованность (Eventual Consistency). Если поток $A$ создал итератор и производит обход, а поток $B$ в этот момент добавил или удалил элемент, поток $A$ не увидит эти изменения и продолжит работать с устаревшими данными (Stale Data). Однако благодаря volatile-семантике массива любой прямой вызов list.get(i), совершенный после завершения записи потоком $B$, гарантированно вернет актуальные данные.
3. В Java 11 реализацию замка в CopyOnWriteArrayList изменили с ReentrantLock на synchronized (lock). Почему?
Ответ: Это оптимизация footprint’а памяти и производительности OpenJDK. Поле final Object lock = new Object() весит меньше, чем экземпляр ReentrantLock с сопутствующими узлами AQS (AbstractQueuedSynchronizer). Кроме того, современная JVM эффективно оптимизирует мониторы synchronized (Biased Locking / Lightweight Locking) в условиях слабой конкуренции, что идеально подходит для редких записей в CopyOnWriteArrayList.
Типичные ошибки и Red Flags
- ❌ Использование
CopyOnWriteArrayListв системах с частой записью (Write-Heavy): Приведет к катастрофической деградации производительности из-за $O(N)$ на каждую вставку и перегрузки сборщика мусора. - ❌ Попытка модифицировать коллекцию через итератор (
it.remove()): Вызовет исключениеUnsupportedOperationExceptionпрямо в проде. - ❌ Добавление элементов по одному в цикле
for: Добавление 1000 элементов поштучно приведет к 1000 полным копированиям массива. Следует использовать методaddAll(). - ❌ Ожидание, что итератор увидит изменения, сделанные во время его обхода: Итератор работает по изолированному снимку и изолирован от последующих модификаций.