📦 Раздел 4 · Вопрос #26

Что такое fail-fast и fail-safe итераторы

При обходе коллекций в Java поведение итератора в случае модификации коллекции делится на две основные стратегии:


🟢 Junior Level

1. Простое определение концепций

При обходе коллекций в Java поведение итератора в случае модификации коллекции делится на две основные стратегии:

  • Fail-Fast («Быстрый отказ»): итератор немедленно выбрасывает исключение ConcurrentModificationException, если обнаруживает, что коллекция была изменена (добавлен, удален или заменен элемент) в обход методов самого итератора.
  • Fail-Safe («Безопасный отказ»): итератор не падает с исключением при параллельной модификации коллекции, продолжая корректный обход элементов.
// 1. Fail-Fast (ArrayList, HashMap, HashSet)
List<String> fastList = new ArrayList<>(List.of("A", "B"));
for (String s : fastList) {
    fastList.add("C"); // КАТАСТРОФА: выбросит ConcurrentModificationException!
}

// 2. Fail-Safe (CopyOnWriteArrayList, ConcurrentHashMap)
List<String> safeList = new CopyOnWriteArrayList<>(List.of("A", "B"));
for (String s : safeList) {
    safeList.add("C"); // БЕЗОПАСНО: итератор спокойно завершит цикл (обошел "A", "B")
}

2. Сравнительная таблица

Критерий Fail-Fast Fail-Safe (Snapshot) Weakly Consistent
Выброс CME ✅ Да (при любой сторонней модификации) ❌ Никогда ❌ Никогда
Примеры коллекций ArrayList, HashSet, HashMap CopyOnWriteArrayList, CopyOnWriteArraySet ConcurrentHashMap, ConcurrentLinkedQueue
Работа с копией Нет (прямой доступ к данным) Да (неизменяемый снимок массива) Нет (живые данные через volatile)
Видит ли новые данные Падает до того, как успеет увидеть ❌ Нет (изолирован в снимке) ⚠️ Может увидеть, но не гарантирует
Накладные расходы Минимальные ($O(1)$) Высокие при записи ($O(N)$ копирование) Минимальные (Lock-Free)

🟡 Middle Level

1. Механизм работы Fail-Fast: переменные modCount и expectedModCount

Внутри коллекций с fail-fast поведением определен счетчик структурных изменений:

protected transient int modCount = 0;

Любое действие, меняющее размер или внутреннее состояние структуры данных (add, remove, clear, resize), увеличивает modCount++.

Когда создается итератор, он запоминает текущее значение счетчика:

int expectedModCount = modCount;

При каждом шаге обхода в методах next() и remove() проверяется инвариант:

final void checkForComodification() {
    if (modCount != expectedModCount)
        throw new ConcurrentModificationException();
}

Если коллекция была модифицирована напрямую (например, list.add("X")), modCount увеличился, а expectedModCount итератора остался старым. На следующем же вызове it.next() происходит немедленный выброс исключения.

[!NOTE] Если элемент удаляется через метод самого итератора (it.remove()), итератор синхронизирует счетчики: expectedModCount = modCount, поэтому исключение не возникает.


2. Терминологический нюанс: официальная позиция спецификации JDK

[!IMPORTANT] Термин «Fail-Safe» не является официальным термином спецификации Java (JLS / Javadoc)! Это устоявшийся сленг разработчиков.

Официальная документация JDK классифицирует неколлидирующие итераторы на две строгие категории:

  1. Snapshot-style Iterators (Снимочные): работают по фиксированной неизменяемой копии данных (CopyOnWriteArrayList).
  2. Weakly Consistent Iterators (Слабосогласованные): работают с живой структурой данных, гарантируя отсутствие CME, прохождение по всем элементам на момент создания и отсутствие дубликатов (ConcurrentHashMap).

🔴 Senior Level

1. Почему Fail-Fast — это «Best-Effort», а не гарантия многопоточности?

Javadoc класса ArrayList содержит важное предупреждение:

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

Аппаратная причина: Переменная modCount объявлена как обычный примитив int, она НЕ является volatile:

protected transient int modCount = 0; // Нет ключевого слова volatile!

Если Поток 1 изменяет список в ядре CPU 1, значение modCount++ оседает в локальном кэше L1/L2 процессора и не сбрасывается в общую память немедленно. Поток 2, выполняющий итерацию на ядре CPU 2, может прочитать устаревшее значение modCount из своего кэша, пропустить проверку modCount != expectedModCount и продолжить итерацию по разрушенному массиву, приводя к повреждению данных или непредсказуемому ArrayIndexOutOfBoundsException.

Вывод: Fail-fast — это детектор логических ошибок в однопоточном коде, а не механизм синхронизации для многопоточности.


2. Сравнение производительности: iterator.remove() против removeIf()

При удалении элементов из ArrayList во время обхода часто используют iterator.remove(). Однако для списков на массивах это порождает тяжелую проблему производительности:

// ❌ СЛОЖНОСТЬ O(N^2):
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().startsWith("bad_")) {
        it.remove(); // Каждый вызов делает System.arraycopy для сдвига хвоста!
    }
}

// ✅ СЛОЖНОСТЬ O(N) в Java 8+:
list.removeIf(s -> s.startsWith("bad_"));

Метод removeIf в ArrayList сначала сканирует массив с помощью битовой маски BitSet, помечая индексы удаляемых элементов за один проход, а затем выполняет ровно одно компактирующее перемещение данных.


🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

«Fail-Fast итераторы (в ArrayList, HashMap) обнаруживают структурные изменения коллекции, сделанные не через методы самого итератора, и немедленно выбрасывают ConcurrentModificationException. Это реализуется сравнением счетчика modCount коллекции с expectedModCount итератора. Термин Fail-Safe неофициальный: под ним понимают либо снимочные итераторы (CopyOnWriteArrayList), которые обходят неизменяемую копию массива, либо слабосогласованные итераторы (ConcurrentHashMap), которые обходят живые volatile-узлы без блокировок и без риска CME. Механизм fail-fast не гарантирует защиту в многопоточности, так как modCount не является volatile».


3 каверзных вопроса на интервью с ответами

1. Может ли однопоточная программа без параллельных потоков выбросить ConcurrentModificationException?

Ответ: Да, и это самая распространенная причина этого исключения на практике. Если внутри обычного цикла for-each вызвать прямой метод модификации коллекции (например, list.remove(item) или list.add(item)), коллекция инкрементирует modCount. На следующем шаге цикла итератор обнаружит несовпадение modCount и expectedModCount и выбросит ConcurrentModificationException.

2. Почему разработчики JDK не сделали поле modCount переменной volatile?

Ответ: Ключевое слово volatile налагает заметный оверхед на производительность, требуя сброса кэша и генерации барьеров памяти (StoreLoad / Memory Barrier) на каждую операцию добавления или удаления элемента. Коллекции пакета java.util (как ArrayList) проектировались как максимально легковесные и быстрые однопоточные структуры. Делать modCount volatile ради негарантированной диагностики в изначально некорректном многопоточном коде было сочтено неоправданным компромиссом по скорости.

3. Что произойдет, если в процессе итерации по ConcurrentHashMap другой поток удалит текущий элемент?

Ответ: Никакого исключения выброшено не будет. Итератор ConcurrentHashMap является слабосогласованным (Weakly Consistent). Он удерживает ссылку на текущий Node, поля которого объявлены как volatile. Если элемент удаляется из мапы, его ссылки в хэш-таблице перестраиваются, но сам объект ноды продолжает корректно ссылаться на следующий узел цепочки через node.next, и итератор успешно продолжит обход оставшихся элементов.


Типичные ошибки и Red Flags

  • ❌ Утверждение, что ConcurrentModificationException возникает только в многопоточных программах: Чаще всего возникает в одном потоке при вызове list.remove() внутри цикла for-each.
  • ❌ Вера в то, что fail-fast надежно защищает многопоточный код: modCount не volatile, проверка носит статус Best-Effort.
  • ❌ Попытка использовать iterator.remove() в CopyOnWriteArrayList: Вызовет UnsupportedOperationException, так как снимок неизменяем.
  • ❌ Ожидание, что снимочный итератор увидит элементы, добавленные параллельно: Снимок полностью изолирован от последующих записей.

Связанные темы