Какой механизм объясняет выброс ConcurrentModificationException итератором ArrayList после структурного изм...

Какой механизм объясняет выброс ConcurrentModificationException итератором ArrayList после структурного изменения коллекции?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

ArrayList использует счётчик структурных изменений, обычно представленный полем modCount. Итератор запоминает его значение при создании и при последующих операциях сравнивает с текущим; расхождение может привести к ConcurrentModificationException.

Это fail-fast-механизм, предназначенный прежде всего для раннего обнаружения ошибок в логике обхода. Он не является гарантией потокобезопасности и не обязан обнаруживать каждое конкурентное изменение.

Исторический контекст

Java Collections Framework предоставил единые интерфейсы коллекций и итераторов для разных реализаций. Это позволило отделить алгоритм обхода от конкретной структуры данных, но одновременно потребовало понятного поведения при изменении коллекции во время итерации.

Fail-fast-подход решает диагностическую проблему: вместо потенциально непредсказуемого обхода изменившейся структуры итератор старается быстро сообщить о нарушении ожидаемого состояния. Такой подход дешевле и проще, чем автоматическая синхронизация каждого обхода.

Постановка проблемы

Если коллекцию структурно изменить напрямую во время обхода, итератор может пропустить элементы, обработать элемент повторно или получить внутренне несогласованное представление данных. Особенно опасен такой код при передаче коллекции между потоками без явной синхронизации.

ConcurrentModificationException не означает, что изменение обязательно произошло из другого потока. Исключение может возникнуть и в одном потоке, если коллекция изменяется напрямую во время работы её итератора.

Подробное решение

При создании итератора ArrayList он сохраняет ожидаемое значение счётчика изменений. Операции вроде добавления или удаления элемента изменяют структуру списка и увеличивают счётчик. Перед некоторыми операциями итератор сравнивает сохранённое значение с текущим.

Если изменение выполнено через поддерживаемый метод самого итератора, например Iterator.remove(), итератор обновляет своё ожидаемое состояние и может продолжить обход. Изменение значения существующего элемента через List.set() обычно не считается структурным, поскольку размер и расположение элементов не меняются.

import java.util.ArrayList; import java.util.Iterator; import java.util.List; List<String> names = new ArrayList<>(); names.add("Ann"); names.add("Bob"); Iterator<String> iterator = names.iterator(); names.add("Cat"); iterator.next(); // возможен ConcurrentModificationException

Механизм обычно реализован как проверка modCount и expectedModCount, но полагаться на конкретные внутренние имена полей нельзя: это деталь реализации, а не публичный контракт API.

Проверка не делает коллекцию потокобезопасной. Без внешней синхронизации сама проверка и изменение коллекции могут происходить с гонками, а видимость изменений между потоками не гарантируется. Кроме того, fail-fast-поведение является лучшей попыткой обнаружения ошибки, поэтому приложение не должно использовать ConcurrentModificationException как средство управления корректной бизнес-логикой.

Если нужно удалить элементы во время обхода, следует использовать Iterator.remove() или подходящий метод вроде removeIf(). Если коллекция действительно разделяется потоками, выбирают синхронизацию, неизменяемый снимок, CopyOnWriteArrayList или специализированную конкурентную коллекцию в зависимости от соотношения чтений, записей и требований к согласованности.

Ситуация из практики

Сервис хранит список правил, который часто читается несколькими потоками и редко полностью заменяется. Один поток обновляет список, пока другие обходят его. Использование обычного ArrayList без синхронизации приводит к диагностическим исключениям и, что важнее, не решает проблему безопасного обмена данными.

Рассматривались три варианта. Глобальная блокировка вокруг чтения и записи даёт понятную согласованность, но может увеличить задержки и снизить параллелизм. CopyOnWriteArrayList позволяет обходить стабильный снимок без блокировки чтения, но дорог при частых изменениях и расходует память на копирование. Передача неизменяемого снимка при каждой публикации хорошо изолирует читателей, но требует дисциплины публикации новой версии списка.

Был выбран неизменяемый снимок правил, публикуемый через потокобезопасную ссылку. Читатели всегда обходят одну стабильную версию, а обновление формирует новую коллекцию целиком. Это устранило исключения и гонки; решение оправдано тем, что чтений значительно больше, чем обновлений.

Что кандидаты часто упускают

1. Гарантирует ли отсутствие ConcurrentModificationException, что обход коллекции безопасен?

Нет. Отсутствие исключения не доказывает потокобезопасность и не гарантирует корректную видимость данных между потоками. Fail-fast-проверка является диагностической и может не сработать из-за особенностей межпоточной видимости, момента проверки или конкретной реализации.

2. Почему удаление через Iterator.remove() допустимо, а прямой вызов удаления коллекции во время обхода может завершиться исключением?

Итератор знает, что операция выполнена через разрешённый ему канал изменения, поэтому после удаления может синхронизировать своё ожидаемое состояние с коллекцией. При прямом изменении коллекции итератор не получает такого уведомления и при следующей проверке обнаруживает расхождение счётчиков.

Это правило относится к конкретному поддерживаемому итератором контракту. Нельзя автоматически переносить его на произвольные пользовательские коллекции или предполагать, что любой метод изменения во время обхода будет безопасен.

3. Чем fail-fast-коллекция отличается от CopyOnWriteArrayList при обходе?

Итератор обычного ArrayList наблюдает изменяемую структуру и может обнаружить её структурное изменение. Итератор CopyOnWriteArrayList работает со снимком массива, существовавшим на момент создания итератора, поэтому последующие изменения не меняют уже начатый обход и обычно не вызывают ConcurrentModificationException.

Компромисс состоит в цене записи: CopyOnWriteArrayList копирует внутренний массив при каждом изменении. Поэтому она подходит для сценариев с частыми чтениями и редкими записями, но обычно не подходит для интенсивно изменяемых списков.