Среди наиболее популярных вопросов на собеседованиях по языку Java есть и такой: В чем различие между fail-fast и fail-safe итераторами? Максимально упрощенный ответ на него: Fail-fast итератор генерирует исключение ConcurrentModificationException, если коллекция меняется во время итерации, а fail-safe нет. Хотя это звучит достаточно осмысленно, остается непонятным, что интервьюер понимает под fail-safe? Спецификации языка Java не определяют этот термин в отношении итераторов. Однако существуют четыре стратегии конкурентной модификации. Если коротко: fail-fast итератор (ArrayList, HashMap) бросает ConcurrentModificationException, как только замечает, что коллекцию изменили в обход итератора, а под «fail-safe» на собеседовании обычно имеют в виду любую из трех остальных стратегий: слабо согласованный итератор (ConcurrentHashMap), снимок состояния (CopyOnWriteArrayList) или неопределенное поведение (Enumeration у Vector и Hashtable).

Кратко

  • Конкурентная модификация это любое изменение коллекции во время обхода не через сам итератор; многопоточность для нее не обязательна, достаточно list.remove() внутри цикла.
  • Fail-fast (ArrayList, HashMap и почти все коллекции java.util): итератор сравнивает счетчик modCount с сохраненной копией и бросает ConcurrentModificationException, но только «по мере возможности», без гарантии.
  • Слабая согласованность (ConcurrentHashMap, большинство очередей java.util.concurrent): исключения нет никогда, каждый элемент, существовавший на момент создания итератора, обходится ровно один раз, а более поздние изменения могут быть видны, а могут и нет.
  • Снимок состояния (CopyOnWriteArrayList, CopyOnWriteArraySet): итератор обходит копию массива на момент своего создания, изменений не видит, а remove, set и add через него невозможны; выгодно, когда обходов намного больше, чем изменений.
  • Неопределенное поведение: Enumeration у Vector и Hashtable может пропускать или дублировать элементы; в новом коде не используйте.
  • Термина «fail-safe» в документации Java нет: на собеседовании под ним обычно имеют в виду любую стратегию, кроме fail-fast, чаще всего снимок состояния.
Старинная иллюстрация: морда совы крупным планом с большими сине-зелеными глазами

Конкурентная модификация

Во-первых, давайте определимся, что такое конкурентная (или параллельная) модификация. Допустим у нас есть коллекция и при активном итераторе происходят какие-либо ее изменения, не исходящие от данного итератора. В таком случае у нас получается конкурентная модификация. Приведу простейший пример: допустим, у нас есть несколько нитей. Первая нить выполняет итерации, а вторая вставляет элементы в ту же коллекцию или удаляет их из нее. Однако мы можем получить исключение ConcurrentModificationException и при работе в однопоточной среде:
List<String> cities = new ArrayList<>();
cities.add("Warsaw");
cities.add("Prague");
cities.add("Budapest");

Iterator<String> cityIterator = cities.iterator();
cityIterator.next();
cities.remove(1);
cityIterator.next(); // генерирует ConcurrentModificationException

Fail-fast

Вышеприведенный фрагмент кода это пример fail-fast итератора. Как вы можете видеть, при попытке извлечения второго элемента из итератора было сгенерировано исключение ConcurrentModificationException. Откуда итератор узнает, что коллекция была модифицирована после его создания? Например, в коллекции может быть метка даты/времени, скажем, lastModified. При создании итератора вам стоит скопировать это поле и сохранить его в объекте итератора. Затем, при каждом вызове метода next(), нужно будет просто сравнить значение lastModified из коллекции с копией из итератора. Очень близкий подход используется, например, в реализации класса ArrayList. В нем есть переменная экземпляра modCount (она объявлена в AbstractList, а ArrayList ее наследует), в которой хранится количество структурных модификаций списка:
final void checkForComodification() {
   if (modCount != expectedModCount)
       throw new ConcurrentModificationException();
}
Важно отметить, что fail-fast итераторы работают на основе принципа "по мере возможности", то есть не дается никаких гарантий генерации исключения ConcurrentModificationException в случае конкурентной модификации. Так что полагаться на это не стоит: скорее, их следует использовать для обнаружения ошибок. Большинство неконкурентных коллекций предоставляют fail-fast итераторы.

Слабая согласованность

Большинство конкурентных коллекций из пакета java.util.concurrent (например, ConcurrentHashMap и большинство Queue) предоставляют слабо согласованные итераторы. Смысл этого термина очень хорошо разъясняется в документации:
  • Они могут обрабатываться конкурентно с другими операциями
  • Они никогда не генерируют исключение ConcurrentModificationException
  • Они гарантированно обходят существовавшие на момент создания итератора элементы ровно один раз, и могут (но не обязаны) отражать последующие модификации.
С Java 8 то же самое документация обещает и для Spliterator, а значит, и для стримов по таким коллекциям.

Снимок состояния

При такой стратегии итератор связывается с состоянием коллекции на момент его создания, это и есть снимок состояния (снепшот) коллекции. Любые произведенные над исходной коллекцией изменения приводят к созданию новой версии нижележащей структуры данных. При этом наш снимок состояния остается неизменным, так что он не отражает изменения в коллекции, которые произошли после создания итератора. Это старая добрая методика копирования при записи (copy-on-write, COW). Она полностью решает проблему конкурентных модификаций, поэтому исключение ConcurrentModificationException при таком подходе не генерируется. Кроме того, итераторы не поддерживают операции, которые меняют элементы. Коллекции с копированием при записи обычно требуют слишком больших расходов ресурсов при использовании, но имеет смысл воспользоваться ими, если изменения происходят намного реже, чем обходы итераторов. Примерами могут служить классы CopyOnWriteArrayList и CopyOnWriteArraySet.

Неопределенное поведение

Неопределенное поведение может встретиться вам в устаревших унаследованных типах коллекций, таких как Vector и Hashtable. В обеих есть стандартные fail-fast итераторы, но кроме этого, они позволяют использовать реализации интерфейса Enumeration, а они не знают, как себя вести в случае конкурентной модификации. Вы можете столкнуться с тем, что некоторые элементы повторяются или оказываются пропущенными, а то и вовсе увидите какие-то странные исключения. Лучше с ними не играться!

Четыре стратегии рядом

Сведем описанное в одну таблицу:
Стратегия Где встречается ConcurrentModificationException Видит ли изменения после создания итератора Когда уместна
Fail-fast ArrayList, HashMap, LinkedList и почти все коллекции java.util да, но только по мере возможности нет: заметив изменение, бросает исключение однопоточный код; обнаружение ошибок
Слабая согласованность ConcurrentHashMap, ConcurrentLinkedQueue и большинство очередей java.util.concurrent никогда может, но не обязана; каждый исходный элемент ровно один раз многопоточный доступ без блокировок
Снимок состояния CopyOnWriteArrayList, CopyOnWriteArraySet никогда нет: обходит копию на момент создания обходов намного больше, чем изменений (списки слушателей, настройки)
Неопределенное поведение Enumeration у Vector и Hashtable нет, но результат непредсказуем как повезет: пропуски и дубли нигде, только в унаследованном коде

Вопросы и ответы

Чем fail-fast итератор отличается от fail-safe?

Fail-fast итератор (ArrayList, HashMap и другие коллекции java.util) бросает ConcurrentModificationException, если коллекцию изменили после его создания не через сам итератор. Термина fail-safe в документации Java нет, так называют итераторы, которые исключение не бросают: слабо согласованные (ConcurrentHashMap видит часть изменений) и снимки состояния (CopyOnWriteArrayList обходит копию и изменений не видит вовсе). Разница в том, что fail-fast защищает от ошибки шумно, а fail-safe тихо, ценой копирования или неполной картины.

Почему ConcurrentModificationException возникает в одном потоке?

Потому что итератор проверяет не потоки, а счетчик изменений. ArrayList увеличивает modCount при каждом add и remove, итератор при создании запоминает его значение, а в каждом next() сравнивает. Классический случай: цикл for-each под капотом использует итератор, и list.remove(x) внутри такого цикла ломает счетчик.

Почему удаление предпоследнего элемента в for-each не бросает исключение?

Из-за проверки hasNext(): у итератора ArrayList она сравнивает позицию с размером списка (cursor != size). После удаления предпоследнего элемента размер уменьшается на единицу и совпадает с позицией, hasNext() возвращает false, и цикл тихо заканчивается, так и не дойдя до последнего элемента. Проверка modCount живет в next(), а до него дело не доходит. Это хуже исключения: ошибка есть, а сигнала о ней нет.

Как безопасно удалить элементы из коллекции во время обхода?

Через сам итератор: iterator.remove() удаляет текущий элемент и синхронизирует счетчик. С Java 8 короче list.removeIf(x -> условие), он делает то же самое внутри. Для Map работает map.entrySet().removeIf(...) или map.values().removeIf(...). Еще два варианта: собрать кандидатов в отдельный список и удалить после цикла, либо идти по индексам с конца.

Гарантирует ли fail-fast итератор исключение при конкурентной модификации?

Нет. Документация Oracle прямо пишет, что fail-fast итераторы бросают ConcurrentModificationException по мере возможности (best-effort), а при несинхронизированном доступе из нескольких потоков жестких гарантий дать невозможно: проверка счетчика сама не атомарна. Поэтому строить на этом исключении логику программы нельзя, оно только для обнаружения ошибок.

Когда брать CopyOnWriteArrayList вместо Collections.synchronizedList?

Когда обходов намного больше, чем изменений: списки слушателей, редко меняющиеся настройки. У synchronizedList итератор все равно fail-fast, и на время обхода документация требует вручную синхронизироваться на списке. CopyOnWriteArrayList при каждом изменении копирует массив, зато обход идет без блокировок и никогда не бросает ConcurrentModificationException. Если запись частая или список большой, копирование съест всю выгоду.