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

  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 зменшується на 1 і збігається з поточною позицією курсора ітератора 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(): Робить підсписок повністю непридатним для використання.

Пов’язані теми