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

Что такое ConcurrentModificationException

Синтаксический сахар for (String fruit : fruits) компилируется в байт-код с использованием скрытого Iterator. Метод fruits.remove() меняет структуру списка «за спиной» у итерато...


🟢 Junior Level

1. Что такое ConcurrentModificationException (CME)

ConcurrentModificationException — это непроверяемое (unchecked) исключение времени выполнения, сигнализирующее о том, что коллекция подверглась структурному изменению (добавление, удаление, очистка) во время её последовательного обхода (итерации) способом, нарушающим контракт итератора.

Главный парадокс названия

[!WARNING] Слово «Concurrent» в названии вводит в заблуждение начинающих разработчиков. В 95% случаев это исключение возникает в абсолютно однопоточном коде, когда программист пытается удалить или добавить элемент прямо внутри обычного цикла for-each!

List<String> fruits = new ArrayList<>(List.of("Яблоко", "Банан", "Груша"));

// ❌ ТИПИЧНЕЙШАЯ ОШИБКА:
for (String fruit : fruits) {
    if (fruit.equals("Банан")) {
        fruits.remove(fruit); // Выбросит ConcurrentModificationException!
    }
}

Почему цикл for-each падает: Синтаксический сахар for (String fruit : fruits) компилируется в байт-код с использованием скрытого Iterator. Метод fruits.remove() меняет структуру списка «за спиной» у итератора, нарушая его внутренний указатель позиции.


2. Как правильно избежать CME в повседневном коде

// Вариант 1: Использование явного iterator.remove()
Iterator<String> it = fruits.iterator();
while (it.hasNext()) {
    if (it.next().equals("Банан")) {
        it.remove(); // БЕЗОПАСНО: итератор сам обновляет свой счетчик
    }
}

// Вариант 2: Использование removeIf() в Java 8+ (САМЫЙ ЭФФЕКТИВНЫЙ)
fruits.removeIf(f -> f.equals("Банан"));

🟡 Middle Level

1. Внутренний механизм: modCount и expectedModCount

В коллекциях стандартной библиотеки (ArrayList, HashMap, HashSet) реализован механизм быстрого обнаружения повреждений (Fail-Fast):

       ArrayList (Коллекция)                     Iterator (Обходчик)
  ┌──────────────────────────────┐          ┌───────────────────────────┐
  │ int modCount = 5;            │          │ int cursor = 0;           │
  │                              │          │ int expectedModCount = 5; │
  └──────────────┬───────────────┘          └─────────────┬─────────────┘
                 │                                        │
                 │ list.add("X")                          │ it.next()
                 ▼                                        ▼
  ┌──────────────────────────────┐          ┌───────────────────────────┐
  │ int modCount = 6; (Инкремент)│ ◄─────── │ modCount != expectedModCount?
  └──────────────────────────────┘  Mismatch│ ──► THROW NEW CME!        │
                                            └───────────────────────────┘
  1. Поле modCount в коллекции увеличивается на 1 при каждом вызове add, remove, clear, fastRemove.
  2. При вызове list.iterator() итератор копирует текущее значение: expectedModCount = modCount.
  3. При каждом вызове it.next() вызывается метод проверки:
    final void checkForComodification() {
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
    }
    
  4. Если был вызван метод самого итератора it.remove(), итератор сам удаляет элемент и синхронизирует поля: expectedModCount = modCount;, предотвращая аварийное завершение.

2. Аномалия предпоследнего элемента (The Penultimate Element Anomaly)

Классический вопрос на Senior-собеседовании: почему при удалении предпоследнего элемента в цикле for-each исключение CME иногда НЕ выбрасывается?

List<String> list = new ArrayList<>();
list.add("A");
list.add("B"); // Предпоследний элемент
list.add("C");

for (String s : list) {
    if (s.equals("B")) {
        list.remove(s); // Удаляем предпоследний элемент напрямую!
    }
}
System.out.println(list); // Выведет [A, C] БЕЗ КАКИХ-ЛИБО ИСКЛЮЧЕНИЙ!

Анатомия скрытого бага:

  1. Исходный размер списка: size = 3.
  2. На шаге с элементом "B" курсор итератора равен cursor = 2.
  3. Вызов list.remove("B") сдвигает элементы влево и уменьшает размер: size = 2. Поле modCount увеличивается.
  4. Перед следующим шагом цикл вызывает it.hasNext():
    public boolean hasNext() {
        return cursor != size; // 2 != 2 -> false!
    }
    
  5. Метод hasNext() возвращает false! Цикл считает, что коллекция закончилась, и завершается.
  6. Метод next() и проверка checkForComodification() вообще не вызываются!

Исключение не вылетело, но произошла тяжелая логическая ошибка: последний элемент "C" был молча пропущен и не обработан! Это подтверждает, что fail-fast — это инструмент Best-Effort, а не абсолютная гарантия.


🔴 Senior Level

1. Чувствительность проекций (Views) к modCount

Исключение CME может возникать даже без вызова итераторов при работе с проекциями коллекций:

List<String> parentList = new ArrayList<>(List.of("1", "2", "3", "4", "5"));
List<String> subList = parentList.subList(1, 3); // View диапазона ["2", "3"]

// Модификация родительского списка напрямую:
parentList.add("6");

// Попытка обратиться к подсписку:
subList.get(0); // КАТАСТРОФА: выбросит ConcurrentModificationException!

Почему это происходит: Класс SubList сохраняет значение modCount родительского списка на момент создания (this.modCount = root.modCount). При каждом обращении к subList (get, size, set) он сверяет свой сохраненный modCount с текущим значением у родителя. Модификация родителя в обход subList делает срез инвалидным.

Аналогично ведут себя представления мап: если изменить HashMap во время итерации по map.keySet() или map.entrySet(), итератор проекции выбросит CME.


2. Почему в реальной многопоточности вместо CME часто вылетает AIOOBE?

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

  1. Переменная modCount не объявлена как volatile.
  2. Поток-писатель на Core 1 увеличивает modCount++ в своем кэше L1/L2.
  3. Поток-читатель на Core 2 производит обход на основе устаревших данных из своего кэша. Он не видит изменения modCount.
  4. В результате проверка checkForComodification() проходит успешно, итератор пытается прочитать elementData[cursor], но физический массив уже был уменьшен или очищен писателем.
  5. Вместо CME поток с грохотом падает с ArrayIndexOutOfBoundsException или зацикливается навсегда (в старых версиях Java 7 с HashMap).

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

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

«ConcurrentModificationException — это сигнал механизма Fail-Fast о нарушении инварианта структуры коллекции во время её обхода. Он реализуется путем сравнения счетчика модификаций коллекции modCount со снимком expectedModCount, сделанным итератором в момент старта. Вопреки названию, в подавляющем большинстве случаев ошибка возникает в одном потоке при вызове list.remove() / add() внутри цикла for-each. В многопоточности CME не дает 100% гарантий обнаружения гонок, поскольку поле modCount не является volatile. Безопасные альтернативы: вызов Iterator.remove(), метод Collection.removeIf() или использование конкурентных структур (CopyOnWriteArrayList, ConcurrentHashMap)».


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

1. Почему удаление предпоследнего элемента в ArrayList через list.remove() в цикле for-each не вызывает ConcurrentModificationException?

Ответ: При удалении предпоследнего элемента размер списка size уменьшается и становится равным текущей позиции курсора итератора cursor. На следующем шаге метод hasNext() выполняет проверку cursor != size, получает false и завершает цикл. Метод next(), внутри которого происходит проверка modCount == expectedModCount, просто не успевает вызваться. В итоге элемент удаляется, но последний элемент списка никогда не будет обработан.

2. Как избежать CME при необходимости модифицировать коллекцию во время итерации?

Ответ:

  1. Использовать метод it.remove() явного итератора Iterator (для списков также it.add() / it.set() у ListIterator).
  2. Использовать метод list.removeIf(predicate) в Java 8+ (наиболее производительный вариант за один проход $O(N)$).
  3. Создавать защитную копию коллекции перед обходом: for (Item x : new ArrayList<>(list)).
  4. Использовать коллекции с потокобезопасными слабосогласованными итераторами (ConcurrentHashMap) или снимками (CopyOnWriteArrayList).

3. Почему изменение родительского списка ломает уже созданный subList?

Ответ: subList не копирует элементы, а является легковесной проекцией (View), ссылающейся на массив и modCount родительского списка. При любых операциях subList сверяет сохраненный при создании expectedModCount с актуальным parent.modCount. Если родитель изменился напрямую, смещения элементов внутри проекции становятся невалидными, и subList защищает данные выбросом CME.


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

  • ❌ Утверждение, что CME — это ошибка исключительно многопоточного кода: 95% случаев происходят в одном потоке при неаккуратной модификации в цикле for-each.
  • ❌ Попытка ловить CME через try-catch: Это антипаттерн; наличие CME означает грубую ошибку архитектуры кода, которую нужно исправить через removeIf или Iterator.remove.
  • ❌ Удаление через list.remove(i) в обычном цикле for (int i=0; i<size; i++): Хотя CME не вылетит, сдвиг элементов влево приведет к пропуску следующего за удаленным элемента.
  • ❌ Модификация родительской коллекции после создания subList(): Приведет к неработоспособности подсписка.

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