How do you remove elements from a collection while iterating it, and what makes an iterator fail-fast?
Structural modification through the collection during a for-each invalidates the iterator, which detects it by comparing a modification counter and throws ConcurrentModificationException. Remove through the iterator, or use removeIf, or pick a collection whose iterator is weakly consistent instead.
What the interviewer is scoring
- Whether the modification counter is named as the mechanism rather than the exception being treated as magic
- Does the candidate know fail-fast is documented as best-effort and can give a case where the exception does not fire
- That the three families of iterator - fail-fast, snapshot, weakly consistent - are separated by what they guarantee about concurrent writes
- Can they distinguish an unmodifiable view from a genuinely immutable copy
- Whether removeIf is preferred over a hand-written iterator loop for the right reason, not just brevity
Answer
What a for-each really is
A for-each loop over a Collection is compiled into an Iterator loop; there is no separate language mechanism. So the question is really about the contract between a collection and the iterator it handed out. That iterator holds a cursor into the collection's internal storage — an index into ArrayList's backing array, a node reference in LinkedList, a bucket position in HashMap. Add or remove an element through the collection itself and that cursor now points somewhere the iterator did not agree to. Skipped elements, elements visited twice, or an ArrayIndexOutOfBoundsException are all reachable outcomes.
Rather than allow that, the java.util collections detect it. Each one keeps an int field, modCount, that is incremented by every structural modification, meaning one that changes the size. The iterator copies modCount when it is created and compares the copy on each next(); a mismatch throws ConcurrentModificationException. Setting an existing element through list.set(i, x) is not structural and does not bump the counter, which is why replacing values mid-iteration is legal while adding one is not.
The name is misleading and the interviewer may be testing whether you notice. Nothing about ConcurrentModificationException requires two threads. The overwhelmingly common cause is a single thread calling list.remove(x) inside its own loop.
Fail-fast is best-effort, and here is where it fails
The Javadoc is explicit that fail-fast behaviour cannot be guaranteed and that the exception must be treated as a bug detector rather than as a correctness mechanism. The demonstration everybody eventually meets is ArrayList: its iterator's hasNext() is simply cursor != size, and the check against modCount lives in next(). Remove the second-to-last element and the size drops to exactly the cursor value, so hasNext() returns false, next() is never called again, and the loop exits normally having silently skipped the final element.
import java.util.ArrayList;
import java.util.List;
public class SkippedTail {
public static void main(String[] args) {
List<String> letters = new ArrayList<>(List.of("a", "b", "c"));
for (String s : letters) {
if (s.equals("b")) {
letters.remove(s); // second-to-last: no throw, "c" is never visited
}
}
System.out.println(letters); // [a, c]
}
}
Relying on the exception to catch this class of bug is therefore not safe. Write the loop so the situation cannot arise.
The three ways to write it correctly
The iterator's own remove() is the sanctioned route: it performs the removal and resynchronises the iterator's expected count, so the cursor and the collection stay in agreement. It removes the element last returned by next(), so it must be called after next() and at most once per element.
import java.util.Iterator;
import java.util.List;
import java.util.Map;
final class Pruning {
static void dropBlanks(List<String> names) {
Iterator<String> it = names.iterator();
while (it.hasNext()) {
if (it.next().isBlank()) {
it.remove(); // updates expectedModCount as well as the list
}
}
}
// A Map is pruned through its entrySet; the entry iterator removes the mapping.
static void dropZeroes(Map<String, Integer> counts) {
Iterator<Map.Entry<String, Integer>> entries = counts.entrySet().iterator();
while (entries.hasNext()) {
if (entries.next().getValue() == 0) {
entries.remove();
}
}
}
}
removeIf, added to Collection in Java 8, is the version to reach for by default. It is not merely shorter: it moves the loop inside the collection, so an implementation can do the work in a way an external iterator cannot. ArrayList overrides it to mark the doomed indices in a bit set and then perform one bulk shift, rather than shifting the tail of the array once per removal, which turns a quadratic loop into a linear one. ConcurrentHashMap and the concurrent maps override it too. Getting this right in an interview means saying "and the implementation can optimise it", not "it is more functional".
The third option is to iterate one collection and build another, which is what a stream filter does. It allocates, but it leaves the input untouched, and for anything shared that is often the honest design rather than a compromise.
Iterators that do not fail fast
java.util.concurrent exists partly because fail-fast is the wrong contract when writes are genuinely concurrent, and its collections take two different positions.
CopyOnWriteArrayList gives you a snapshot iterator. Every mutation replaces the entire backing array, and the iterator holds a reference to the array as it was when iteration began, so it never throws and never sees a later write. That is why its remove() throws UnsupportedOperationException: the iterator is looking at an immutable historical array, and there is nothing there to remove. Reads are cheap and writes copy the whole array, which makes it right for listener lists and wrong for anything write-heavy.
ConcurrentHashMap gives you a weakly consistent iterator. It traverses the live table, never throws ConcurrentModificationException, guarantees each element is returned at most once, and makes no promise about whether modifications made after it started are visible. That last clause is the whole point and the part to say out loud, because it means the iteration is not a consistent view of anything: size(), isEmpty() and containsValue() are approximations too, useful for monitoring and unusable as a decision input.
Unmodifiable is not immutable
The adjacent mistake, and a good one to volunteer, is confusing the two ways of making a collection read-only. Collections.unmodifiableList(list) returns a view. Writes through the view throw, but the original reference still works, so anything holding it can still mutate what your caller believes is fixed — and an iterator over the view will duly throw ConcurrentModificationException when they do. List.copyOf(list), and the List.of factories, both added in Java 9, copy the contents into a genuinely immutable implementation that no other reference can touch. They also reject null elements outright, which is occasionally an unwelcome surprise when migrating from Arrays.asList.
Arrays.asList deserves its own sentence, because it is neither. It returns a fixed-size list backed by the array you passed, so set works and writes through to the array, while add and remove throw UnsupportedOperationException. Wrap it in new ArrayList<>(...) when you intend to modify it.
Removing through the collection while an iterator is open is a bug whether or not you get an exception for it, because fail-fast detection is documented as best-effort. Remove through the iterator, or with
removeIf, and treat anyConcurrentModificationExceptionyou do see as a lucky catch rather than as the safety net.
Likely follow-ups
- Why does removing the second-to-last element of an ArrayList in a for-each sometimes not throw?
- What does the iterator of ConcurrentHashMap guarantee about entries added after iteration begins?
- Why does CopyOnWriteArrayList's iterator not support remove?
- How would you safely remove entries from a Map while iterating it?
Related questions
- How do generators reduce memory use, and when are they the wrong choice?mediumAlso on iterators4 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.mediumSame kind of round: concept4 min
- This form marks invalid fields with red text and a red border. What has to change?mediumSame kind of round: concept4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumSame kind of round: concept4 min
- How would you rewrite a row-by-row pandas transformation, and what is SettingWithCopyWarning telling you?mediumSame kind of round: coding5 min
- Write a query returning the second-highest salary in each department, then tell me what it does when two people tie for the top.mediumSame kind of round: coding3 min
- If you override equals but not hashCode, what breaks and when?mediumSame kind of round: concept4 min