Iterating, and removing while you do
Removing items from a collection while looping over it is one of the first things a beginner tries and one of the first things Java refuses. The refusal is a kindness, and understanding it explains a lot about how collections work.
The exception everybody meets
List<Delivery> deliveries = new ArrayList<>(List.of(
new Delivery(LocalDate.of(2026, 9, 1), "Priya", 0),
new Delivery(LocalDate.of(2026, 9, 2), "Arjun", 5),
new Delivery(LocalDate.of(2026, 9, 3), "Kavita", 3)));
for (Delivery d : deliveries) {
if (d.tiffins() == 0) {
deliveries.remove(d); // ConcurrentModificationException
}
}
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification
"Concurrent" is a misleading name — there is one thread here. It means modified during iteration, not modified by another thread.
It does not always throw, which is worse
Move the zero to the second delivery and the same loop runs cleanly:
// Priya 2, Arjun 0, Kavita 3 — removing Arjun, the second of three
for (Delivery d : deliveries) {
if (d.tiffins() == 0) {
deliveries.remove(d); // no exception at all
}
}
The check happens in next(), and hasNext() is just cursor != size.
Removing the second-to-last element makes size drop to equal cursor, so
hasNext() returns false, the loop exits, and next() is never called again
— so nothing ever checks.
That is the genuinely dangerous case. The same code throws or does not depending on which element you remove, so a test with three items can pass while production with four throws. Do not treat the absence of an exception as evidence that the loop is correct.
Why it happens
An enhanced for loop is an Iterator underneath:
// what you write
for (Delivery d : deliveries) { … }
// what the compiler produces
Iterator<Delivery> it = deliveries.iterator();
while (it.hasNext()) {
Delivery d = it.next();
…
}
ArrayList keeps a modCount — a counter of structural changes. The iterator
records it when created and checks it on every next(). Calling
deliveries.remove(...) bumps modCount without the iterator knowing, so the
next next() sees the mismatch and throws.
This is called fail-fast: the collection would rather stop immediately than carry on with an iterator that may now skip elements or read past the end.
The bug it saves you from
Without that check, removing during a plain indexed loop silently skips elements:
List<String> names = new ArrayList<>(List.of("a", "b", "b", "c"));
for (int i = 0; i < names.size(); i++) {
if (names.get(i).equals("b")) {
names.remove(i);
}
}
// [a, b, c] — one "b" survived
Removing index 1 shifts the second "b" down into index 1, and i has already
moved to 2. That is a real bug with no exception, which is exactly what
fail-fast exists to prevent.
Four ways to remove properly
removeIf — use this one
deliveries.removeIf(d -> d.tiffins() == 0);
One line, correct, and it says what it means. This is the right answer almost every time.
Iterator.remove()
Iterator<Delivery> it = deliveries.iterator();
while (it.hasNext()) {
if (it.next().tiffins() == 0) {
it.remove(); // the iterator knows, so modCount stays in step
}
}
The iterator updates its own bookkeeping, so there is no mismatch. Worth knowing when the removal decision needs more than a predicate — several statements, or side effects.
Collect into a new list
List<Delivery> real = deliveries.stream()
.filter(d -> d.tiffins() > 0)
.toList();
Does not modify the original at all, which is usually better. Note that
Stream.toList() returns an unmodifiable list — if you need to add to it
later, use .collect(Collectors.toCollection(ArrayList::new)).
Iterate backwards
for (int i = deliveries.size() - 1; i >= 0; i--) {
if (deliveries.get(i).tiffins() == 0) {
deliveries.remove(i);
}
}
Correct, because removal only shifts elements you have already passed. Ugly, and only worth it when you need the index for something else.
The trap in removeIf
List<String> names = Arrays.asList("Priya", "", "Arjun");
names.removeIf(n -> n.isEmpty()); // UnsupportedOperationException
Note that the predicate has to actually match something. removeIf on a
fixed-size list where nothing matches returns false quietly, because it
never attempts a removal — so the same call can throw or not depending on the
data.
Arrays.asList returns a fixed-size list backed by the array — an adapter, from
the design patterns module. You can set, but not add or remove. The same
applies to List.of(...), which is fully immutable.
If you need a modifiable list from either:
List<String> names = new ArrayList<>(List.of("Priya", "Arjun"));
Modifying a map while iterating
Same rule, and the same solutions:
Map<String, Integer> counts = new HashMap<>(Map.of("Priya", 2, "Arjun", 0));
// throws
for (String key : counts.keySet()) {
if (counts.get(key) == 0) counts.remove(key);
}
// correct
counts.entrySet().removeIf(e -> e.getValue() == 0);
// also correct, and the way to *change* values rather than remove them
counts.replaceAll((key, value) -> value * 2);
entrySet(), keySet() and values() are views, not copies. Removing from
keySet() removes from the map. Adding to them is not supported. That
view-ness is worth remembering: it is why map.keySet().retainAll(allowed) is a
neat way to filter a map in place.
Iterable and the for-each loop
Anything implementing Iterable<T> works in a for-each loop, including your own
types:
public record DeliveryBatch(List<Delivery> deliveries) implements Iterable<Delivery> {
@Override
public Iterator<Delivery> iterator() {
// The list's own iterator, but through an unmodifiable view — so a
// caller cannot remove from our internals through it.
return List.copyOf(deliveries).iterator();
}
}
for (Delivery d : batch) { … }
That is the whole contract: one method returning an Iterator. Note the
unmodifiable view — handing out a raw iterator over an internal list lets a
caller call remove() on your private state.
Weakly consistent iterators
Not everything is fail-fast. The concurrent collections are weakly consistent:
Map<String, Integer> counts = new ConcurrentHashMap<>();
for (String key : counts.keySet()) {
counts.put("new-" + key, 1); // no exception
}
A weakly consistent iterator never throws ConcurrentModificationException, and
it may or may not reflect changes made after it was created. That is the trade
those collections make: no exception, and no guarantee of a consistent snapshot.
CopyOnWriteArrayList goes further — its iterator is a true snapshot of the
moment it was created, and changes after that are never seen. Which is precisely
why the observer pattern used it.
So "can I modify while iterating?" has three answers depending on the collection: fail-fast throws, weakly consistent allows it with no guarantees, and copy-on-write gives you a frozen snapshot.
Check your work
What "concurrent" means in the exception: modified during iteration, not modified by another thread.
How fail-fast works: the collection counts structural changes and the
iterator checks that count on every next().
The bug it prevents: an indexed removal loop silently skipping elements.
The right default for removal: removeIf.
When Iterator.remove() is better: the decision needs more than a
predicate.
Why Arrays.asList(...).removeIf(...) throws: it is a fixed-size adapter
over an array.
What Stream.toList() returns: an unmodifiable list.
Why keySet() removal affects the map: it is a view, not a copy.
What Iterable requires: one method returning an Iterator — and hand out
an unmodifiable view, or callers can remove from your internals.
The three behaviours: fail-fast throws, weakly consistent allows without guarantees, copy-on-write gives a snapshot.
Practice
- Write the failing loop above and read the full stack trace. Then move the zero to the second of three deliveries and confirm it does not throw.
- Fix it four ways —
removeIf,Iterator.remove, a stream, and a backwards loop. Decide which you would send for review. - Run the indexed-loop example removing
"b"from[a, b, b, c]and confirm one survives. Explain why with the indices. - Call
removeIfon anArrays.asListwith a predicate that matches nothing, and confirm it returnsfalsesilently. Then make it match. - Call
addon the result ofStream.toList(). - Remove a key from a
HashMapthroughkeySet()and confirm the map changed. - Use
map.keySet().retainAll(...)to filter a map in place. - Implement
Iterable<Delivery>on your own type and loop over it. - Hand out
deliveries.iterator()directly instead of a copy, then callremove()on it from outside and inspect your internal list. - Iterate a
ConcurrentHashMapwhile adding to it. Confirm no exception, then say what you can and cannot rely on.
Next: maps.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship