Удаление элементов из коллекции в Java: как избежать ConcurrentModificationException

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

«Хочу удалить элемент из списка в цикле — и получаю ConcurrentModificationException». Знакомо?

Если вы хоть раз писали на Java и пытались удалить элемент из ArrayList или HashMap во время итерации (например, в цикле for-each), то наверняка видели это страшное красное исключение. Оно вылезает в самый неподходящий момент, словно Java говорит вам: «Эй, ты что творишь? Не смей трогать коллекцию, пока я по ней иду!».

На Stack Overflow этот вопрос собрал тысячи просмотров. И это не случайно: проблема кажется простой, но под капотом скрывается целый пласт оптимизаций и нюансов многопоточности. В этой статье мы разберем как правильно удалять объекты из коллекции в цикле на Java, не вызывая ConcurrentModificationException, и при этом не потерять в производительности.

Поехали!

Почему возникает ConcurrentModificationException?

Короткий ответ: fail-fast итераторы. Большинство коллекций из Java Collections Framework (ArrayList, HashSet, HashMap и др.) используют внутренний счетчик изменений (modCount). Когда вы создаете итератор, он запоминает текущее значение modCount. При каждом вызове next() итератор проверяет, не изменилась ли коллекция извне (через методы add() или remove() самой коллекции). Если счетчик разошелся — летит исключение.

Пример кода, который гарантированно упадет:

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
for (String item : list) {
    if ("B".equals(item)) {
        list.remove(item); // ConcurrentModificationException!
    }
}

Почему? Потому что for-each неявно использует итератор, а list.remove() меняет modCount напрямую, минуя итератор. Итератор этого не прощает.

Способ №1: Используем Iterator.remove() — классика

Самый простой и правильный путь — взять итератор в руки явно и вызвать его метод remove(). Этот метод синхронизирует внутренний счетчик итератора с коллекцией.

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
Iterator<String> iterator = list.iterator();
while (iterator.hasNext()) {
    String item = iterator.next();
    if ("B".equals(item)) {
        iterator.remove(); // Безопасно!
    }
}
System.out.println(list); // [A, C, D]

Плюсы: безопасно, работает для всех коллекций, поддерживающих итераторы. Минусы: немного многословно. Для простых фильтраций можно использовать более современные подходы.

Способ №2: Collection.removeIf() — современный и элегантный

Начиная с Java 8, у большинства коллекций появился метод removeIf(Predicate filter). Это самый лаконичный способ удалить элементы по условию.

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
list.removeIf(item -> "B".equals(item));
System.out.println(list); // [A, C, D]

Внутри removeIf использует итератор (или прямой проход с сохранением индексов), так что никакого ConcurrentModificationException не будет. Это рекомендованный способ для простых случаев.

Производительность: Для ArrayList метод removeIf работает за O(n), но делает это эффективно — он сдвигает элементы за один проход, а не удаляет по одному (что было бы O(n²)).

Способ №3: Обратный проход по индексу (для List)

Если вы по какой-то причине не можете использовать итератор (например, вам нужно менять несколько элементов одновременно или вы работаете со старым кодом), можно пройтись по списку в обратном порядке, используя обычный for с индексом.

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
for (int i = list.size() - 1; i >= 0; i--) {
    if ("B".equals(list.get(i))) {
        list.remove(i);
    }
}
System.out.println(list); // [A, C, D]

Почему с конца? Потому что при удалении элемента сдвигаются только последующие индексы. Если идти с конца, вы никогда не столкнетесь с ситуацией, когда пропустите элемент или выйдете за границы массива.

Минусы: работает только для List (имеет произвольный доступ по индексу). Для LinkedList это будет медленно, так как get(i) — O(n) на каждом шаге.

Способ №4: Копирование и фильтрация (Stream API)

Иногда проще не удалять элементы, а создать новую коллекцию, содержащую только то, что нужно. Это особенно актуально, если исходная коллекция неизменяема или если вы работаете с неизменяемыми структурами данных.

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
List<String> filteredList = list.stream()
        .filter(item -> !"B".equals(item))
        .collect(Collectors.toList());
System.out.println(filteredList); // [A, C, D]

Плюсы: иммутабельность, легко читается, можно параллелить (если коллекция большая и потокобезопасность не нужна). Минусы: создает новый объект, что увеличивает потребление памяти. Для больших коллекций это может быть критично.

Способ №5: ListIterator — когда нужно не только удалять, но и вставлять

Интерфейс ListIterator расширяет обычный Iterator и добавляет методы add() и set(). Если вам нужно, например, заменить элемент на другой прямо во время итерации — это ваш выбор.

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D"));
ListIterator<String> listIterator = list.listIterator();
while (listIterator.hasNext()) {
    String item = listIterator.next();
    if ("B".equals(item)) {
        listIterator.remove(); // безопасно
        listIterator.add("NewElement"); // вставляем после удаления
    }
}
System.out.println(list); // [A, NewElement, C, D]

Обратите внимание: add() в ListIterator вставляет элемент перед текущей позицией итератора. Это удобно, когда нужно модифицировать список «на лету».

Оптимизация: что быстрее?

Давайте посмотрим на производительность разных подходов для ArrayList с 1 миллионом элементов (удаляем каждый второй).

  • Iterator.remove() — O(n) по времени, так как каждый вызов remove()

Блоги

Рекомендаций по книгам