Что такое 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! │
└───────────────────────────┘
- Поле
modCountв коллекции увеличивается на 1 при каждом вызовеadd,remove,clear,fastRemove. - При вызове
list.iterator()итератор копирует текущее значение:expectedModCount = modCount. - При каждом вызове
it.next()вызывается метод проверки:final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); } - Если был вызван метод самого итератора
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] БЕЗ КАКИХ-ЛИБО ИСКЛЮЧЕНИЙ!
Анатомия скрытого бага:
- Исходный размер списка:
size = 3. - На шаге с элементом
"B"курсор итератора равенcursor = 2. - Вызов
list.remove("B")сдвигает элементы влево и уменьшает размер:size = 2. ПолеmodCountувеличивается. - Перед следующим шагом цикл вызывает
it.hasNext():public boolean hasNext() { return cursor != size; // 2 != 2 -> false! } - Метод
hasNext()возвращаетfalse! Цикл считает, что коллекция закончилась, и завершается. - Метод
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 и тем самым защитит данные. На практике происходит другое:
- Переменная
modCountне объявлена какvolatile. - Поток-писатель на Core 1 увеличивает
modCount++в своем кэше L1/L2. - Поток-читатель на Core 2 производит обход на основе устаревших данных из своего кэша. Он не видит изменения
modCount. - В результате проверка
checkForComodification()проходит успешно, итератор пытается прочитатьelementData[cursor], но физический массив уже был уменьшен или очищен писателем. - Вместо 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 при необходимости модифицировать коллекцию во время итерации?
Ответ:
- Использовать метод
it.remove()явного итератораIterator(для списков такжеit.add()/it.set()уListIterator). - Использовать метод
list.removeIf(predicate)в Java 8+ (наиболее производительный вариант за один проход $O(N)$). - Создавать защитную копию коллекции перед обходом:
for (Item x : new ArrayList<>(list)). - Использовать коллекции с потокобезопасными слабосогласованными итераторами (
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(): Приведет к неработоспособности подсписка.