Как избежать ConcurrentModificationException в Java: проверенные методы
Для кого эта статья:
- Java-разработчики, работающие с коллекциями и многопоточностью
- Специалисты по обработке данных и разработке программного обеспечения
Студенты и обучающиеся на курсах программирования, желающие улучшить свои навыки в Java
Если вы когда-либо сталкивались с загадочной
ConcurrentModificationException, то знаете, как она может превратить ваш рабочий день в настоящий кошмар. Эта ошибка — как минное поле на пути Java-разработчиков: невидима, пока не наступишь, но когда это происходит — последствия могут быть разрушительны для вашего приложения. По статистике, это одно из самых распространенных исключений среди Java-разработчиков всех уровней, от новичков до экспертов. Давайте разберемся с действенными способами обхода этой проблемы, превращая хрупкий код в надежную крепость. 🛡️
Хотите раз и навсегда избавиться от проблем с многопоточностью и коллекциями в Java? На Курсе Java-разработки от Skypro вы не только освоите продвинутые техники работы с коллекциями, но и научитесь писать безопасный многопоточный код под руководством действующих разработчиков. Студенты курса сообщают о снижении ошибок в производственном коде на 78% уже после первых месяцев обучения. Присоединяйтесь и превратите ConcurrentModificationException в реликт прошлого!
Почему возникает ConcurrentModificationException в Java
Представьте, что вы — библиотекарь, и в момент, когда вы пересчитываете книги на полке, кто-то начинает убирать или добавлять книги. Ваш подсчет неизбежно собьется. Именно это происходит, когда Java выбрасывает ConcurrentModificationException — структура коллекции меняется во время её обхода.
Это исключение — защитный механизм, предотвращающий непредсказуемое поведение кода. Оно выбрасывается, когда вы пытаетесь изменить коллекцию (добавить, удалить, обновить элементы) во время итерации по ней, исключая случаи, когда модификация происходит через специально предназначенные методы итератора.
Классический пример, вызывающий эту ошибку:
List<String> names = new ArrayList<>();
names.add("Алексей");
names.add("Борис");
names.add("Виктор");
for (String name : names) {
if (name.equals("Борис")) {
names.remove(name); // Здесь возникнет ConcurrentModificationException
}
}
Почему это происходит? При использовании enhanced for-loop (for-each) Java неявно создает итератор. Этот итератор ведет учет ожидаемых структурных изменений через счетчик модификаций (modCount). Когда вы вызываете names.remove() напрямую, вы обходите итератор, счетчик модификаций меняется, и при следующей попытке итератора получить следующий элемент, он обнаруживает, что коллекция изменилась "незаконно", и выбрасывает исключение.
| Операция | Вызывает исключение? | Причина |
|---|---|---|
| collection.remove() во время итерации | Да | Изменение структуры коллекции в обход итератора |
| collection.add() во время итерации | Да | Изменение структуры коллекции в обход итератора |
| iterator.remove() | Нет | Безопасная модификация через API итератора |
| Чтение элементов без модификации | Нет | Отсутствие структурных изменений |
Павел Черных, Lead Java Developer
В одном из финансовых проектов мы столкнулись с интересным случаем: система падала каждый понедельник примерно в 9:15 утра. Логи показывали ConcurrentModificationException в коде обработки транзакций. Оказалось, что в это время происходил пик запросов — сотрудники банков начинали рабочий день и массово запускали выгрузку отчетов. Транзакции хранились в стандартной коллекции ArrayList, и когда одни потоки считывали данные для отчетов, другие параллельно добавляли новые транзакции.
Мы потратили два дня на отладку, пока не осознали фундаментальную проблему: общий ресурс изменялся одновременно из нескольких потоков. Решение было элементарным — мы заменили ArrayList на ConcurrentHashMap с группировкой транзакций по времени. Система мгновенно стабилизировалась, а нагрузочное тестирование показало троекратный рост производительности. Иногда самые сложные баги исправляются простыми решениями.
Когда возникает такая ошибка, часто разработчики идут по пути наименьшего сопротивления, оборачивая код в try-catch. Это не решает проблему — лишь маскирует симптомы. Правильный подход — применять структурные решения, которые мы рассмотрим далее.

Способ 1: Безопасное удаление через Iterator.remove()
Самым элегантным решением проблемы ConcurrentModificationException при необходимости удаления элементов из коллекции является использование метода remove() у самого итератора. Этот подход считается каноническим, поскольку Iterator был спроектирован с учетом возможных структурных изменений коллекции во время итерации.
Рассмотрим, как правильно модифицировать наш предыдущий пример:
List<String> names = new ArrayList<>();
names.add("Алексей");
names.add("Борис");
names.add("Виктор");
Iterator<String> iterator = names.iterator();
while (iterator.hasNext()) {
String name = iterator.next();
if (name.equals("Борис")) {
iterator.remove(); // Безопасное удаление
}
}
Ключевое отличие этого подхода — Iterator сам следит за согласованностью внутреннего состояния коллекции. Когда вы вызываете iterator.remove(), итератор не только удаляет элемент, но и обновляет свой внутренний счетчик модификаций, синхронизируя его с состоянием коллекции.
Преимущества использования Iterator.remove():
- Гарантированная безопасность удаления во время итерации
- Стандартный подход, понятный опытным Java-разработчикам
- Работает со всеми типами коллекций, реализующими Iterable
- Не требует дополнительной памяти (в отличие от создания копий коллекции)
Однако у этого метода есть ограничения:
- Вы можете удалить только текущий элемент (тот, который был возвращен последним вызовом
next()) - Вызов
remove()дважды для одного элемента вызоветIllegalStateException - Метод не подходит для добавления новых элементов (хотя некоторые специализированные итераторы, например ListIterator, это поддерживают)
Максим Волков, Senior Java Developer
Работая над системой аналитики трафика, я обнаружил необъяснимые падения приложения в промышленной среде, хотя на тестовом стенде всё работало идеально. Анализ логов показал ConcurrentModificationException при фильтрации данных трафика.
Проблема скрывалась в коде, который выглядел абсолютно безобидно:
JavaСкопировать кодfor (TrafficEntry entry : trafficData) { if (entry.getSize() < threshold) { trafficData.remove(entry); } }В тестовой среде набор данных был маленьким, и мы просто везло, что исключение не возникало. В продакшене с миллионами записей оно появлялось стабильно.
Решением стало использование итератора:
JavaСкопировать кодIterator<TrafficEntry> it = trafficData.iterator(); while (it.hasNext()) { TrafficEntry entry = it.next(); if (entry.getSize() < threshold) { it.remove(); } }Простое изменение полностью устранило проблему. Теперь это первое, что я проверяю при просмотре кода коллег — правильное использование итераторов для модификации коллекций. Этот урок научил меня, что даже самый опытный программист может упустить такие фундаментальные вещи.
Для коллекций, поддерживающих индексированный доступ (как ArrayList), существует альтернатива — итерация в обратном порядке:
List<String> names = new ArrayList<>();
// ... заполнение списка
for (int i = names.size() – 1; i >= 0; i--) {
if (names.get(i).equals("Борис")) {
names.remove(i);
}
}
Этот трюк работает, потому что при удалении элементов сзади наперед, вы не влияете на еще не обработанные элементы. Однако такой подход менее универсален и может привести к путанице в логике программы.
Способ 2: Применение потокобезопасных коллекций
Если ваша программа работает в многопоточной среде, или вы предвидите, что коллекция может изменяться из разных потоков, стоит обратить внимание на потокобезопасные коллекции из пакета java.util.concurrent. Эти специализированные структуры данных созданы именно для сценариев, когда требуется конкурентный доступ и модификация.
Вот некоторые из наиболее полезных потокобезопасных коллекций:
- CopyOnWriteArrayList — потокобезопасная альтернатива ArrayList, оптимизированная для сценариев с большим количеством операций чтения и редкими модификациями
- ConcurrentHashMap — высокопроизводительная альтернатива HashMap с поддержкой параллельных операций
- ConcurrentSkipListSet и ConcurrentSkipListMap — потокобезопасные отсортированные коллекции
Рассмотрим пример использования CopyOnWriteArrayList для избежания ConcurrentModificationException:
List<String> names = new CopyOnWriteArrayList<>();
names.add("Алексей");
names.add("Борис");
names.add("Виктор");
for (String name : names) {
if (name.equals("Борис")) {
names.remove(name); // Не вызовет ConcurrentModificationException
}
}
Как работает CopyOnWriteArrayList? При каждой модификации (добавлении, удалении, обновлении) создается новая копия внутреннего массива. Это гарантирует, что итераторы, созданные до модификации, продолжат работать с неизмененной копией массива, тем самым предотвращая ConcurrentModificationException.
| Коллекция | Производительность чтения | Производительность записи | Потребление памяти | Лучший сценарий использования |
|---|---|---|---|---|
| ArrayList | Высокая | Средняя | Низкое | Однопоточные приложения с частым чтением |
| CopyOnWriteArrayList | Высокая | Низкая | Высокое | Многопоточные с редкими модификациями |
| ConcurrentHashMap | Высокая | Высокая | Среднее | Интенсивные конкурентные операции |
| Collections.synchronizedList | Средняя | Средняя | Низкое | Простая многопоточность без высокой нагрузки |
Важно понимать компромиссы при выборе потокобезопасных коллекций:
- Производительность — потокобезопасные коллекции обычно медленнее стандартных в однопоточном контексте из-за накладных расходов на синхронизацию
- Память — коллекции типа Copy-on-Write создают копии данных при каждой модификации, что увеличивает потребление памяти
- Семантика согласованности — некоторые потокобезопасные коллекции могут иметь более слабую гарантию согласованности, чем вы ожидаете
Если вы не работаете в многопоточной среде, использование этих коллекций может быть избыточным. В таких случаях лучше применять другие техники из этой статьи. Однако в конкурентных сценариях потокобезопасные коллекции являются незаменимым инструментом. 🔄
Способ 3: Использование Stream API и метода filter
С появлением в Java 8 Stream API разработчики получили мощный декларативный инструмент для обработки коллекций. Stream API предлагает элегантный способ фильтрации элементов без риска возникновения ConcurrentModificationException, поскольку операции выполняются в последовательной или параллельной манере с промежуточными результатами.
Вместо того чтобы явно удалять элементы из существующей коллекции, вы создаете новую коллекцию, содержащую только нужные элементы:
List<String> names = new ArrayList<>();
names.add("Алексей");
names.add("Борис");
names.add("Виктор");
// Создаем новую коллекцию без элемента "Борис"
List<String> filteredNames = names.stream()
.filter(name -> !name.equals("Борис"))
.collect(Collectors.toList());
// Или заменяем оригинальную коллекцию
names = names.stream()
.filter(name -> !name.equals("Борис"))
.collect(Collectors.toList());
Этот подход особенно полезен, когда вам нужно выполнить сложную фильтрацию или преобразование данных. Stream API позволяет создавать цепочки операций, которые выполняются лаконично и выразительно.
Начиная с Java 8, коллекции также получили удобный метод removeIf(), который безопасно удаляет элементы, соответствующие предикату:
List<String> names = new ArrayList<>();
names.add("Алексей");
names.add("Борис");
names.add("Виктор");
// Безопасно удаляем все элементы, удовлетворяющие предикату
names.removeIf(name -> name.equals("Борис"));
Метод removeIf() внутренне использует итератор для безопасного удаления, что делает его идеальной альтернативой для простых случаев фильтрации. По сути, это синтаксический сахар над классическим паттерном с итератором, который мы обсуждали ранее.
Преимущества использования Stream API и removeIf():
- Декларативный стиль программирования, повышающий читаемость кода
- Отсутствие риска ConcurrentModificationException
- Возможность легкого перехода к параллельной обработке (для Stream API)
- Сочетаемость с другими функциональными операциями (map, reduce и др.)
При выборе между Stream API и removeIf() руководствуйтесь следующими соображениями:
- Используйте Stream API, когда нужны сложные преобразования или создание новой коллекции не вызывает проблем
- Предпочитайте removeIf(), когда необходимо просто удалить элементы из существующей коллекции
- Stream API может быть менее эффективным для простых операций из-за накладных расходов на создание потока
Stream API особенно хорошо сочетается с новыми возможностями Java, такими как лямбда-выражения и ссылки на методы, что делает код еще более лаконичным и выразительным. 🌊
Способ 4: Создание копии коллекции перед итерацией
Иногда самое простое решение оказывается наиболее практичным. Если вам нужно модифицировать коллекцию во время итерации, и ни один из предыдущих методов не подходит, создание копии коллекции перед итерацией может быть разумным компромиссом.
Идея проста — вы итерируете по копии, но модифицируете оригинал:
List<String> names = new ArrayList<>();
names.add("Алексей");
names.add("Борис");
names.add("Виктор");
// Создаем копию для безопасной итерации
List<String> namesCopy = new ArrayList<>(names);
for (String name : namesCopy) {
if (name.equals("Борис")) {
names.remove(name); // Модифицируем оригинальную коллекцию
}
}
Этот подход имеет несколько вариаций в зависимости от типа коллекции:
- Для списков:
new ArrayList<>(originalList) - Для множеств:
new HashSet<>(originalSet) - Для карт:
new HashMap<>(originalMap) - Универсальный способ:
Collections.unmodifiableCollection(collection)— создает немодифицируемое представление
Создание копии — это компромисс между простотой реализации и эффективностью. Этот метод особенно полезен в следующих сценариях:
- Когда коллекция относительно небольшая, и накладные расходы на копирование незначительны
- Когда код должен быть максимально простым и понятным для сопровождения
- В ситуациях, где другие подходы сложно применить из-за ограничений API или архитектуры
- Как быстрое временное решение в критических ситуациях
Однако этот метод имеет существенные недостатки:
- Дополнительное потребление памяти — для больших коллекций это может быть существенно
- Время на копирование — может стать узким местом для больших наборов данных
- Несогласованность между копией и оригиналом — изменения, внесенные в оригинал, не отражаются в копии
При использовании этого подхода важно также учитывать природу объектов в коллекции. Если это сложные объекты с внутренним состоянием, создание копии коллекции не создает глубоких копий самих объектов — это лишь копирование ссылок. В таких случаях модификация объектов через копию коллекции всё равно повлияет на объекты в оригинальной коллекции.
Несмотря на простоту, этот метод требует дисциплины и внимательности, особенно в сложном коде с множеством зависимостей. 📝
Мы рассмотрели основные способы борьбы с ConcurrentModificationException, и каждый из них имеет свои преимущества в определенных сценариях. Iterator.remove() — классический выбор для однопоточных приложений с небольшими коллекциями. Потокобезопасные коллекции незаменимы в многопоточной среде. Stream API и removeIf() предлагают современный декларативный подход. А создание копий — простое решение для нечастых операций. Выбор правильного инструмента зависит от конкретных требований вашего проекта, но теперь вы вооружены знаниями для принятия обоснованного решения. Помните: понимание причин возникновения проблемы часто важнее, чем знание множества способов её решения.