Почему нельзя изменять коллекцию внутри Java Stream и какая ошибка возникает
Что проверяют
Знаете ли вы правило невмешательства в Stream API и понимаете ли, что ConcurrentModificationException тут не гарантия языка, а лучшая попытка конкретной коллекции. Вопрос отсеивает тех, кто воспринимает поток как обычный цикл с красивым синтаксисом.
Как отвечать
Поток не хранит данные, он читает их из источника через Spliterator в момент терминальной операции. Контракт требует: пока терминальная операция не завершилась, источник менять нельзя ни из лямбды, ни из соседнего потока. Иначе поведение по документации не определено, и речь именно о неопределённости, а не о гарантированном исключении.
У ArrayList и HashMap изменение ловится счётчиком modCount, и вы получаете ConcurrentModificationException. Проверка стоит в конце обхода, поэтому исключение прилетает не на том элементе, где случилось изменение, а часть элементов при этом может быть тихо пропущена.
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
nums.stream().forEach(n -> { if (n % 2 == 0) nums.remove(n); }); // CME
nums.removeIf(n -> n % 2 == 0); // менять на месте
var odd = nums.stream().filter(n -> n % 2 != 0).toList(); // собрать новоеВывод простой: поток отвечает за вычисление нового значения, а изменение на месте это работа removeIf, replaceAll или явного Iterator.remove.
Где спотыкаются
Обещают исключение всегда. На CopyOnWriteArrayList и ConcurrentHashMap его не будет вовсе: обход увидит несогласованный срез данных, и баг всплывёт через месяц в проде, а не в тестах.
Ловушка перегрузки: list.remove(2) для списка целых удаляет элемент с индексом два, а list.remove(Integer.valueOf(2)) удаляет значение. Если параметр лямбды объявлен как int, код компилируется и делает не то, что задумано.
Третье: побочные эффекты в map и peek. На параллельном потоке запись результатов в общий ArrayList прямо из лямбды разрушает его без всяких исключений, потому что ArrayList не потокобезопасен. Накопление результата это всегда collect.