В многопоточном сценарии итератор CopyOnWriteArrayList не видит элементы, добавленные после его создания. Какой механизм реализации это объясняет?
CopyOnWriteArrayList предоставляет итератор по снимку внутреннего массива, существовавшему в момент создания итератора. Каждая модификация создаёт новый массив и заменяет ссылку на него, поэтому уже созданный итератор продолжает читать старую версию и не видит последующие изменения.
Такой подход появился для коллекций со множеством чтений и редкими изменениями, где блокировка каждой операции чтения слишком дорога. Вместо синхронизации итерации с записью коллекция делает записи дороже, а чтения и обходы — проще и безопаснее.
Обычная изменяемая коллекция может столкнуться с изменением структуры во время обхода: итератор может увидеть непоследовательное состояние или сообщить об ошибке. Для сценариев вроде списка слушателей, таблицы маршрутов или конфигурационных объектов это нежелательно, особенно когда чтения происходят значительно чаще записей.
Неверный выбор CopyOnWriteArrayList тоже опасен: добавление или удаление элемента требует копирования всего массива. При частых изменениях это приводит к высокой временной сложности, расходу памяти и дополнительной нагрузке на сборщик мусора.
Внутри коллекция хранит ссылку на массив элементов. При создании итератора эта ссылка запоминается в итераторе; далее он работает именно с сохранённым массивом, а не повторно обращается к текущему состоянию коллекции.
Модификация выполняется под внутренней синхронизацией: создаётся копия массива, в неё вносится изменение, затем ссылка на новый массив публикуется как текущее состояние. Благодаря этому читатель видит либо старый массив, либо новый, но не промежуточное состояние.
Итератор не бросает ConcurrentModificationException из-за таких изменений, но это не означает, что он отражает актуальное состояние. Его семантика — обход согласованного снимка; операция удаления через такой итератор не поддерживается и приводит к UnsupportedOperationException.
Операция чтения по индексу имеет сложность O(1), обход снимка — O(n). Добавление, удаление и замена элемента обычно требуют копирования массива и имеют сложность O(n), а во время записи временно существуют старый и новый массивы.
Коллекция подходит для read-mostly-сценариев: читатели не блокируют друг друга и не обязаны синхронизировать итерацию с записью. Она не подходит как универсальная замена синхронизированному списку: составные операции, включающие несколько отдельных вызовов, нужно проектировать отдельно, даже если сами методы потокобезопасны.
Сервис хранит список обработчиков событий: чтение списка происходит при каждом событии, а регистрация обработчика — редко. При обычном ArrayList пришлось бы синхронизировать обход и изменения либо применять внешнюю стратегию публикации копий.
Рассматривались три варианта. Collections.synchronizedList проще, но обход требует ручной синхронизации на весь период итерации и может надолго блокировать регистрацию. ConcurrentLinkedQueue хорошо подходит для очереди, но не выражает семантику индексируемого списка обработчиков. CopyOnWriteArrayList делает редкую регистрацию дорогой, зато обходы не блокируются и получают стабильный снимок.
Выбран был CopyOnWriteArrayList, потому что соотношение чтений и записей было сильно смещено в пользу чтений. В результате обработчики обходились без внешней блокировки, а изменения не влияли на уже начатые итерации; при высокой частоте регистраций пришлось бы выбрать другую структуру.
Нет. Уже созданный итератор навсегда привязан к массиву-снимку, который он получил при создании. Новый итератор, созданный после добавления, увидит обновлённый массив; поэтому для чтения актуального состояния нужно начинать новый обход.
Итератор не проверяет, что текущая коллекция осталась неизменной, и не использует изменяемый массив коллекции. Он читает неизменяемый для него старый массив, поэтому структурная модификация не нарушает его внутреннее состояние. Цена этой гарантии — устаревшие данные и запрет изменения списка через итератор.
Потокобезопасность не отменяет стоимости копирования: одна вставка или удаление может потребовать выделить и заполнить массив размера O(n). При частых записях это создаёт конкуренцию за внутреннюю блокировку, повышает объём выделений памяти и нагрузку на сборщик мусора; для write-heavy-сценария обычно нужна другая структура данных или иная модель доступа.