Как избежать ConcurrentModificationException при работе с коллекциями
Для кого эта статья:
- Java-разработчики, особенно начинающие и средние по опыту
- Специалисты, работающие с многопоточными приложениями
Люди, заинтересованные в улучшении своих навыков работы с коллекциями в Java
Если вы хоть раз видели зловещее исключение
ConcurrentModificationExceptionпри работе с коллекциями в Java, то знаете это чувство — код внезапно ломается в самый неподходящий момент, и вы теряете часы на отладку. Эта ошибка часто становится головной болью даже для опытных разработчиков, особенно в многопоточных приложениях. Но есть хорошая новость — зная правильные техники и инструменты, можно не только избежать этого исключения, но и сделать работу с коллекциями по-настоящему элегантной и безопасной. 💡
Путаетесь в особенностях работы с коллекциями? Не понимаете, почему выскакивает
ConcurrentModificationException? На Курсе Java-разработки от Skypro вы не просто изучите теорию, но и научитесь виртуозно применять правильные решения. Наши студенты получают практические навыки работы с многопоточностью и коллекциями под руководством практикующих разработчиков, а проблемы сConcurrentModificationExceptionостанутся в прошлом.
Что вызывает ConcurrentModificationException в Java
ConcurrentModificationException — это RuntimeException, который возникает при попытке модифицировать коллекцию во время итерации по ней. Этот механизм реализован как защитная мера в коллекциях Java. Давайте рассмотрим, как именно это работает.
Когда мы создаем итератор для коллекции, он запоминает текущее состояние коллекции через счетчик модификаций (modification count). Каждый раз, когда коллекция изменяется (добавление, удаление элементов), этот счетчик увеличивается. При каждой итерации итератор проверяет, совпадает ли запомненное значение счетчика с текущим. Если нет — выбрасывается ConcurrentModificationException.
Александр Петров, Tech Lead Java-разработки Помню случай в финтех-проекте, когда один из разработчиков потратил два дня на поиск причины случайных падений микросервиса. Приложение работало стабильно на тестовом окружении, но под высокой нагрузкой в бою периодически падало с
ConcurrentModificationException. Оказалось, что проблема была в неправильной работе с кэшем, реализованном черезHashMap. Один поток итерировался по кэшу для получения статистики, а другие потоки одновременно обновляли его. После замены наConcurrentHashMapпроблема была решена. Ключевым было понимание, что обычные коллекции не являются потокобезопасными — урок, который стоил нам нескольких инцидентов.
Вот типичный пример кода, вызывающего эту ошибку:
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Python");
list.add("C++");
for (String language : list) {
if (language.equals("Python")) {
list.remove(language); // Вот здесь будет выброшено исключение
}
}
Основные причины возникновения ConcurrentModificationException:
- Модификация коллекции в цикле for-each — цикл for-each под капотом использует
Iterator, но при этом вы обращаетесь напрямую к методам коллекции. - Неправильное использование итератора — вызов методов
add/removeнапрямую у коллекции, а не у итератора. - Параллельный доступ — модификация коллекции из разных потоков без синхронизации.
- Вложенные итерации — когда внутренний цикл модифицирует коллекцию, по которой итерируется внешний цикл.
| Операция | Вызывает исключение? | Причина |
|---|---|---|
list.remove() внутри for-each | Да | Модификация напрямую во время работы итератора |
iterator.remove() | Нет | Безопасная модификация через API итератора |
| Параллельная модификация из разных потоков | Возможно | Зависит от типа коллекции |
| Создание копии перед модификацией | Нет | Оригинальная коллекция не меняется во время итерации |

Пять способов безопасной модификации коллекций
Существует несколько проверенных методов работы с коллекциями, которые помогут вам избежать ConcurrentModificationException. Рассмотрим пять наиболее эффективных подходов.
- Использование методов
Iterator
Вместо прямой модификации коллекции используйте методы итератора:
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
String language = iterator.next();
if (language.equals("Python")) {
iterator.remove(); // Безопасное удаление
}
}
- Создание копии коллекции
Если вам нужно сохранить оригинальную коллекцию, создайте копию для итерации:
List<String> copyList = new ArrayList<>(list);
for (String language : copyList) {
if (language.equals("Python")) {
list.remove(language); // Работает, так как итерируемся по копии
}
}
- Использование потокобезопасных коллекций
Для многопоточной среды используйте специальные коллекции из пакета java.util.concurrent:
List<String> safeList = new CopyOnWriteArrayList<>();
safeList.add("Java");
safeList.add("Python");
safeList.add("C++");
for (String language : safeList) {
safeList.remove("Python"); // Не вызовет исключение
}
removeIf()для коллекций (Java 8+)
Современнй и элегантный способ удаления элементов по условию:
list.removeIf(language -> language.equals("Python"));
- Отложенное удаление
Собирайте элементы для удаления в отдельный список, а затем удаляйте их после завершения итерации:
List<String> toRemove = new ArrayList<>();
for (String language : list) {
if (language.equals("Python")) {
toRemove.add(language);
}
}
list.removeAll(toRemove);
Марина Соколова, Java-разработчик Я работала над системой обработки финансовых транзакций, где производительность была критически важна. Мы столкнулись с проблемой: нужно было фильтровать и модифицировать большие списки данных, но классические подходы с созданием временных коллекций для отложенного удаления сильно снижали производительность. Решение пришло, когда мы переработали алгоритм с использованием Stream API. Замена кода вроде
list.removeIf(predicate)на стримы с фильтрацией позволила не только избежатьConcurrentModificationException, но и сократить время обработки на 30%. Этот опыт научил меня, что нередко решение проблемы лежит не в обходе исключения, а в пересмотре общего подхода к обработке данных.
Каждый из этих методов имеет свои преимущества и ограничения. Выбор зависит от конкретной задачи, требований к производительности и особенностей вашего приложения.
Iterator.remove()
Iterator в Java предоставляет не только возможность перебора элементов, но и безопасные методы для модификации коллекций. Рассмотрим их подробнее.
Основное преимущество методов итератора в том, что они обновляют счетчик модификаций корректно, сохраняя внутреннюю консистентность коллекции и итератора.
Iterator.remove() — самый базовый метод, который удаляет текущий элемент, на который указывает итератор:
Iterator<Integer> iterator = numbers.iterator();
while (iterator.hasNext()) {
Integer number = iterator.next();
if (number % 2 == 0) {
iterator.remove(); // Удаляем четные числа
}
}
Важно помнить, что перед вызовом remove() необходимо вызвать next(). Также метод remove() можно вызвать только один раз для каждого вызова next().
С Java 8 некоторые итераторы получили дополнительные методы:
ListIterator.add() — позволяет добавить элемент в список во время итерации:
ListIterator<String> listIterator = stringList.listIterator();
while (listIterator.hasNext()) {
String current = listIterator.next();
if (current.equals("Java")) {
listIterator.add("Java Script"); // Добавляем после "Java"
}
}
ListIterator.set() — заменяет последний элемент, возвращенный next() или previous():
ListIterator<String> listIterator = stringList.listIterator();
while (listIterator.hasNext()) {
String current = listIterator.next();
if (current.equals("Java")) {
listIterator.set("Java SE"); // Заменяем "Java" на "Java SE"
}
}
Для коллекций, которые не поддерживают ListIterator, Java 8 добавила методы default в Collection:
Collection.removeIf(Predicate) — удобный способ удаления элементов, удовлетворяющих предикату:
list.removeIf(item -> item.startsWith("temp_"));
Этот метод внутренне использует Iterator.remove() и является атомарным для многих коллекций.
Преимущества Iterator API для модификации:
- Безопасность — не вызывает
ConcurrentModificationException - Читаемость — явно показывает, что происходит модификация
- Совместимость — работает со всеми коллекциями, поддерживающими итератор
Но есть и ограничения. Например, Iterator.remove() позволяет удалять только текущий элемент. Если нужны более сложные манипуляции, рассмотрите другие подходы.
| Метод | Действие | Ограничения | Доступность |
|---|---|---|---|
iterator.remove() | Удаляет текущий элемент | Требует предварительного вызова next() | Все коллекции |
listIterator.add(E) | Добавляет элемент в текущую позицию | Доступно только для списков | List интерфейс |
listIterator.set(E) | Заменяет последний возвращенный элемент | Доступно только для списков | List интерфейс |
collection.removeIf() | Удаляет элементы по условию | Может быть не потокобезопасен | Java 8+ |
Потокобезопасные коллекции в конкурентной среде
В многопоточных приложениях проблема ConcurrentModificationException становится еще острее, так как разные потоки могут конкурировать за доступ к коллекциям. Стандартные коллекции из java.util не являются потокобезопасными по умолчанию. Java предлагает специализированные структуры данных для таких сценариев.
Пакет java.util.concurrent предоставляет несколько потокобезопасных альтернатив стандартным коллекциям:
🔒 ConcurrentHashMap — потокобезопасная альтернатива HashMap, оптимизированная для высокой конкурентности:
Map<String, Integer> scores = new ConcurrentHashMap<>();
// Безопасно использовать из разных потоков
scores.put("Player1", 100);
scores.put("Player2", 85);
В отличие от Collections.synchronizedMap(), ConcurrentHashMap использует сегментирование для уменьшения конкуренции между потоками.
🔒 CopyOnWriteArrayList — потокобезопасный список, создающий новую копию внутреннего массива при каждой модификации:
List<String> safeList = new CopyOnWriteArrayList<>();
safeList.add("Item 1");
// Это не вызовет исключение
for (String item : safeList) {
safeList.remove("Item 1");
}
CopyOnWriteArrayList особенно эффективен в сценариях, где чтение происходит гораздо чаще, чем запись.
🔒 CopyOnWriteArraySet — потокобезопасный набор, построенный по тому же принципу, что и CopyOnWriteArrayList:
Set<String> safeSet = new CopyOnWriteArraySet<>();
safeSet.add("Element 1");
// Безопасное итерирование даже при модификациях
for (String element : safeSet) {
safeSet.add("New Element");
}
🔒 ConcurrentSkipListMap и ConcurrentSkipListSet — потокобезопасные отсортированные коллекции, аналоги TreeMap и TreeSet:
NavigableMap<String, Integer> map = new ConcurrentSkipListMap<>();
map.put("Z", 1);
map.put("A", 2);
// Элементы всегда отсортированы по ключу
Эти коллекции особенно полезны, когда требуется потокобезопасная навигация по отсортированным данным.
🔒 BlockingQueue — интерфейс для очередей, поддерживающих операции блокировки при добавлении в полную очередь или извлечении из пустой:
BlockingQueue<Task> taskQueue = new LinkedBlockingQueue<>(100);
// Producer
taskQueue.put(new Task()); // Блокируется, если очередь полна
// Consumer
Task task = taskQueue.take(); // Блокируется, если очередь пуста
Реализации включают ArrayBlockingQueue, LinkedBlockingQueue и PriorityBlockingQueue.
Важные особенности потокобезопасных коллекций:
- Атомарные операции — многие методы реализованы как атомарные, что снижает необходимость в ручной синхронизации.
- Fail-safe итераторы — итераторы не выбрасывают
ConcurrentModificationException, работая с "снимком" данных на момент создания. - Компромисс производительности — потокобезопасность часто достигается ценой снижения производительности в однопоточных сценариях.
Выбор потокобезопасной коллекции должен основываться на конкретных паттернах доступа вашего приложения:
- Для частого чтения и редкой записи —
CopyOnWriteArrayList - Для высококонкурентного доступа к ассоциативному массиву —
ConcurrentHashMap - Для потокобезопасных очередей с блокировкой —
BlockingQueue - Для отсортированных наборов и карт —
ConcurrentSkipListMap/Set
Практические решения для разных сценариев разработки
Рассмотрим конкретные сценарии, с которыми часто сталкиваются Java-разработчики, и наиболее эффективные решения для них.
Сценарий 1: Фильтрация элементов в коллекции
Задача: Удалить элементы, соответствующие определенному условию.
Традиционное решение, вызывающее ConcurrentModificationException:
List<Product> products = getProducts();
for (Product product : products) {
if (product.isExpired()) {
products.remove(product); // Ошибка!
}
}
Оптимальные решения:
Для Java 8+:
// Вариант 1: Функциональный подход
products.removeIf(Product::isExpired);
// Вариант 2: Stream API
products = products.stream()
.filter(p -> !p.isExpired())
.collect(Collectors.toList());
Для Java < 8:
// Вариант 1: Итератор
Iterator<Product> iterator = products.iterator();
while (iterator.hasNext()) {
if (iterator.next().isExpired()) {
iterator.remove();
}
}
// Вариант 2: Отложенное удаление
List<Product> toRemove = new ArrayList<>();
for (Product product : products) {
if (product.isExpired()) {
toRemove.add(product);
}
}
products.removeAll(toRemove);
Сценарий 2: Многопоточная обработка кэша
Задача: Реализовать кэш, доступный из множества потоков, с возможностью итерации и модификации.
// Плохое решение
Map<String, CacheEntry> cache = new HashMap<>();
// Использование в разных потоках приведет к проблемам
// Правильное решение
Map<String, CacheEntry> concurrentCache = new ConcurrentHashMap<>();
// Безопасно для многопоточного доступа
// Для частого чтения и редкой записи
Map<String, CacheEntry> cowCache = new ConcurrentHashMap<>();
// Или для списков
List<CacheEntry> cowCacheList = new CopyOnWriteArrayList<>();
Сценарий 3: Обработка событий в UI-приложении
Задача: Обработка и фильтрация списка слушателей событий в UI-потоке.
// Проблемное решение:
for (EventListener listener : listeners) {
if (!listener.isActive()) {
listeners.remove(listener); // ConcurrentModificationException
}
listener.processEvent(event);
}
// Безопасное решение:
// 1. Использование CopyOnWriteArrayList
List<EventListener> safeListeners = new CopyOnWriteArrayList<>(listeners);
for (EventListener listener : safeListeners) {
if (!listener.isActive()) {
safeListeners.remove(listener); // Безопасно
}
listener.processEvent(event);
}
// 2. Создание копии для итерации
List<EventListener> listenersCopy = new ArrayList<>(listeners);
for (EventListener listener : listenersCopy) {
if (!listener.isActive()) {
listeners.remove(listener); // Безопасно, т.к. итерируемся по копии
}
listener.processEvent(event);
}
Сценарий 4: Обработка больших наборов данных
Задача: Эффективная обработка и фильтрация больших коллекций.
List<DataRecord> hugeDataSet = loadHugeDataSet();
// Неэффективное решение с дополнительным списком
List<DataRecord> toRemove = new ArrayList<>();
for (DataRecord record : hugeDataSet) {
if (shouldFilter(record)) {
toRemove.add(record);
}
}
hugeDataSet.removeAll(toRemove); // Может быть медленным для больших списков
// Эффективное решение со Stream API
hugeDataSet = hugeDataSet.stream()
.filter(record -> !shouldFilter(record))
.collect(Collectors.toList());
// Или с параллельной обработкой для многоядерных систем
hugeDataSet = hugeDataSet.parallelStream()
.filter(record -> !shouldFilter(record))
.collect(Collectors.toList());
Общие рекомендации по выбору решения:
- Производительность критична? Используйте итераторы или Stream API вместо создания временных коллекций.
- Многопоточный доступ? Выбирайте соответствующие коллекции из
java.util.concurrent. - Частое чтение, редкая запись?
CopyOnWriteколлекции будут оптимальным выбором. - Используете Java 8+? Отдавайте предпочтение функциональным подходам (
removeIf, Stream API). - Нужна сортировка?
ConcurrentSkipListMap/Setобеспечат потокобезопасный доступ к отсортированным данным.
Работа с коллекциями в Java — это баланс между безопасностью, удобством и производительностью. Правильное понимание природы
ConcurrentModificationExceptionпозволяет выбрать оптимальную стратегию для каждого конкретного случая. Будь то использование специализированных итераторов, потокобезопасных структур данных или функциональных подходов — главное помнить, что модификация коллекций должна происходить контролируемо. Применяя описанные техники, вы не только избежите ошибок, но и создадите более элегантный, поддерживаемый и производительный код. 🚀