Що таке 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?
└──────────────────────────────┘ Розбіж- │ ──► 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 може виникати навіть без явних ітераторів під час роботи з проєкціями (Views) колекцій:
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 зменшується на 1 і збігається з поточною позицією курсора ітератора 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(): Робить підсписок повністю непридатним для використання.