Lesson 04 / 15
Iterators and Concurrent Modification
The iterator's basic promise — NoSuchElementException once exhausted — comes out byte-for-byte identical across three separate implementations. But if the container changes during iteration, three separate answers appear: one that fails fast, one that walks over a snapshot, one that says nothing at all. Fail-fast itself turns out to be a best effort, not a guarantee: the same removal throws an exception at one position while leaving a loop that finishes silently, one short, at the second-to-last element. The bounding measurement shows the iterator's own remove falls outside this rule.
Contents
The previous lesson measured whether a map could find its key, and split the flaw’s source
into three: interface, implementation, caller’s rule. This lesson tests the same trio not on
the container’s resting state but while it is being iterated. Adding a new element to
the same container, or removing one, while walking a list, a set, or a map with for is a
situation all three families share. The library does not give this a single answer.
Iteration always assumes an order: the iterator infers where the next element is from the internal state the previous call left behind. If the container changes in the middle of iteration, a mismatch opens between this internal state and the container’s real state. The problem itself is the same across every implementation; what gets measured is how each one handles this mismatch — silently ignoring it, stopping by name, or being designed so it never arises at all.
The Iterator’s Interface Promise
Before entering the subject of modification, the iterator’s side that does not break is
measured. The Iterator interface defines the hasNext/next pair and also defines what
next will do on an exhausted iterator.
- CO16 — All three containers are filled with the same three elements and walked to
exhaustion; then one more
nextcall is made. The only thing measured is that last call’s result.
// BasicPromise.java — do all three implementations behave the same once next is exhausted
import java.util.*;
public class BasicPromise {
static String attempt(Iterable<String> container) {
Iterator<String> it = container.iterator();
while (it.hasNext()) it.next();
try {
it.next();
return "next succeeded (not expected)";
} catch (NoSuchElementException e) {
return e.getClass().getSimpleName();
}
}
public static void main(String[] args) {
List<String> source = List.of("a", "b", "c");
System.out.println("ArrayList : " + attempt(new ArrayList<>(source)));
System.out.println("HashSet : " + attempt(new HashSet<>(source)));
System.out.println("ArrayDeque : " + attempt(new ArrayDeque<>(source)));
}
}
ArrayList : NoSuchElementException HashSet : NoSuchElementException ArrayDeque : NoSuchElementException
All three families give the same exception, with the same name. This is a promise the
Iterator interface directly defines: the next contract requires throwing this exception
on an exhausted iterator. As long as the container itself does not change — as long as it is
only being iterated — the three implementations cannot be told apart. The distinction begins
once the container itself changes during iteration.
This measurement is the same shape as the previous lessons’ “answer for a missing key” measurement: when the container never changes, only being queried, three implementations gather around a single behavior. The distinction always shows up when the container’s state changes — writing a key in the previous lesson, the container changing while being iterated in this one.
Three Answers: Fail-Fast, Snapshot, Silence
- CO17 — All four containers are filled with the same four elements, and while being walked with a for-each, the moment the second element is processed, a fifth element is added to the container. What is measured is how this addition affects the iteration: an exception, silent continuation, and if it continues, how many elements are seen. The change is made at the same step in all four containers; only the container changes.
// ThreeAnswers.java — three separate answers if the container changes during iteration
import java.util.*;
import java.util.concurrent.*;
public class ThreeAnswers {
static String attempt(Iterable<String> container, Runnable change) {
int counted = 0;
try {
for (String s : container) {
counted++;
if (counted == 2) change.run();
}
return "no exception, counted=" + counted;
} catch (ConcurrentModificationException e) {
return e.getClass().getSimpleName() + ", counted=" + counted;
}
}
public static void main(String[] args) {
List<String> failingList = new ArrayList<>(List.of("a", "b", "c", "d"));
System.out.println("ArrayList (fail-fast) : "
+ attempt(failingList, () -> failingList.add("extra")));
Set<String> failingSet = new HashSet<>(List.of("a", "b", "c", "d"));
System.out.println("HashSet (fail-fast) : "
+ attempt(failingSet, () -> failingSet.add("extra")));
List<String> snapshotBased = new CopyOnWriteArrayList<>(List.of("a", "b", "c", "d"));
System.out.println("CopyOnWriteArrayList (snapshot) : "
+ attempt(snapshotBased, () -> snapshotBased.add("extra")));
Map<String, Integer> staysSilent = new ConcurrentHashMap<>();
staysSilent.put("a", 1); staysSilent.put("b", 2);
staysSilent.put("c", 3); staysSilent.put("d", 4);
System.out.println("ConcurrentHashMap (says nothing) : "
+ attempt(staysSilent.keySet(), () -> staysSilent.put("extra", 5)));
}
}
ArrayList (fail-fast) : ConcurrentModificationException, counted=2 HashSet (fail-fast) : ConcurrentModificationException, counted=2 CopyOnWriteArrayList (snapshot) : no exception, counted=4 ConcurrentHashMap (says nothing) : no exception, counted=5
Four rows show three separate promises, and none of the three is written in the Iterator
interface itself — Iterator never defines what will happen to a modification during
iteration, it leaves the definition to the chosen class. ArrayList‘s and HashSet‘s
iterators check, on every next call, whether the container has changed structurally, and if
it has, they stop immediately, by name: because the addition happened after the second
element, the third next call falls in both. That two separate data structures (an
array-based list, a hash-based set) keep the same counter is not a coincidence — AbstractList
and HashMap (which HashSet internally wraps) share the same modCount mechanism;
fail-fast is not a single class’s trait, it is part of the framework’s design pattern.
CopyOnWriteArrayList, by contrast, has already taken a copy of the array the moment
iteration starts; whatever gets added to the container, that copy has four elements and the
iterator sees all four, without noticing anything. The fourth row is different from both
previous ones: ConcurrentHashMap neither throws an exception nor freezes on a fixed copy —
in this run, the fifth element added also enters the iteration, and a total of five elements
are counted, but this is not a promise this API gives, it is only this run’s result; the
library only guarantees it will not throw an exception, it does not guarantee which
elements it will see.
Fail-Fast Is a Best Effort, Not a Guarantee
ArrayList‘s “fail-fast” behavior shows its own limit in the next measurement. The iterator
does not catch every modification; it only keeps a counter, and only checks that counter when
next is called. If the removed element is the list’s second-to-last, the loop already
finishes without calling next again, and the check never runs at all.
- CO18 — The same five-element list is walked twice; once the middle element (index 2) is removed when iteration reaches it, once the second-to-last element (index 3) is. The only thing that changes is the removed position.
// SilentEnding.java — fail-fast does not always work
import java.util.*;
public class SilentEnding {
static void attempt(String label, int indexToRemove) {
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d", "e"));
List<String> visited = new ArrayList<>();
try {
for (String s : list) {
visited.add(s);
if (visited.size() - 1 == indexToRemove) {
list.remove(indexToRemove);
}
}
System.out.println(label + " -> no exception, visited=" + visited);
} catch (ConcurrentModificationException e) {
System.out.println(label + " -> " + e.getClass().getSimpleName()
+ ", visited=" + visited);
}
}
public static void main(String[] args) {
attempt("if index 2 (middle) is removed", 2);
attempt("if index 3 (second-to-last) is removed", 3);
}
}
if index 2 (middle) is removed -> ConcurrentModificationException, visited=[a, b, c] if index 3 (second-to-last) is removed -> no exception, visited=[a, b, c, d]
Same class, same for-each, same single removal — the result says two separate things. When
the middle element is removed, what is expected happens: the next next call catches the
counter mismatch and the exception falls. When the second-to-last element is removed, though,
the list’s size drops from five to four, and the loop’s internal cursor, instead of moving to
the next element, now equals the list’s new size; hasNext reads this as “iteration is done”
and the loop ends without calling next at all — the last element ("e") is never seen.
No exception at all, but the iteration is incomplete too: four elements were seen, the fifth
was silently skipped.
This is the misleading side of the name “fail-fast.” ArrayList‘s iterator is not an observer
that catches every modification; it is a best-effort check that only does a counter
comparison when next is called. Java’s own documentation defines this behavior not as a
guarantee but as a tool that helps catch bugs early — and this measurement shows a situation
where even that tool itself can be skipped.
The practical consequence of this is that a program’s correctness cannot rest on the sentence “no exception was thrown.” Both runs worked with the same class, the same loop, a single removal operation; one reported the flaw by name, the other lost an element without saying anything. Whether the check runs depends on where the removed position sits in the list — and since this is often information that depends on run-time data, the same source code can look flawless on some inputs and produce an incomplete iteration on others.
Where the check is done makes this best effort’s boundary even clearer. hasNext never
performs a consistency check at all; only next and a few methods like it check.
- CO19 — After one element is read, an element is added to the container; right after
that,
hasNextis called first, thennext. What is measured is whether both throw an exception at the same time, or only one does.
// HasNextCheck.java — the check only runs inside next()
import java.util.*;
public class HasNextCheck {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
Iterator<String> it = list.iterator();
it.next();
list.add("extra");
System.out.println("hasNext() after the change : " + it.hasNext());
try {
it.next();
System.out.println("next() succeeded (not expected)");
} catch (ConcurrentModificationException e) {
System.out.println("next() -> " + e.getClass().getSimpleName());
}
}
}
hasNext() after the change : true next() -> ConcurrentModificationException
hasNext silently returns true even after the change; the check only kicks in when next
is called. This makes concrete where the best effort stops: the counter comparison does not
run on every container operation, only at the moments the iterator advances. hasNext
saying “yes, there is more” is not a guarantee; there is no guarantee at all that the next
step will succeed.
The Bounding Measurement: The Rule Forbids Changing from Outside the Container
The measurements so far have all been the rule “do not change the container while iterating”
being broken. But what exactly does this rule mean? The iterator’s own remove method
changes the container too — so why does it not throw the same exception?
- CO20 — The same five-element list is walked with an open
Iterator, and two elements are removed not directly through the list’sremove, but through the iterator’s ownremove.
// IteratorRemove.java — the iterator's own remove does not break the rule
import java.util.*;
public class IteratorRemove {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d", "e"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next();
if (s.equals("b") || s.equals("d")) {
it.remove();
}
}
System.out.println("result with the iterator's own remove : " + list);
}
}
result with the iterator's own remove : [a, c, e]
No exception falls at all, and the removal completes without a hitch: both elements are
permanently gone. This does not contradict the previous two measurements; it requires reading
the rule more accurately. The rule is not “do not change the collection” — it is “do not
change the collection from outside the iterator.” When Iterator.remove() is called, the
iterator updates the container’s internal counter (modCount) itself, and at the same time
advances its own expected value too; at the next next call the two are still equal, and the
check passes silently. The list’s own remove, by contrast, does not know about this second
counter and only changes the first; the difference surfaces at the next next call. The
fail-fast mechanism does not ask “did the container change,” it asks “did the container change
through a path the iterator does not know about.”
This distinction also explains why the rule exists. An iterator tracks which element of the
container it is sitting on through an internal position (an index in array-based containers, a
node reference in linked structures). When the container changes from outside, this internal
position can become invalid — if an element has been removed, the next next call can land
either on the wrong element or on a position that no longer exists at all. Because
Iterator.remove() updates its own record itself, this danger never arises at all; a remove
coming from outside, though, shifts the position without the iterator’s knowledge and leaves
it inconsistent. Fail-fast is a design decision that prefers stopping by name over silently
landing on the wrong element in the face of this inconsistency.
This confirms, once again, the three lessons’ shared conclusion. The rule does not forbid a
behavior — if it set a blunt prohibition like “do not change the collection,” then
Iterator.remove() itself would always throw an exception too, yet it does not. The rule
separates who is informed: a change coming through the iterator’s own path is an informed
change, a change coming through any other path of the container is not. Two calls that
produce the same result (an element permanently removed) are, from the iterator’s point of
view, two entirely separate events.
All three answers are internally consistent, but none gives the guarantee the other gives.
Fail-fast says “stop rather than work incorrectly”; snapshot says “whatever the iteration
sees, it is shown a consistent view”; silent continuation says “I will never stop, but I make
no promise about the list of what I saw.” All three are legitimate promises, and all three sit
outside the Iterator interface, as the chosen class’s own decision — the interface only
guarantees the hasNext/next pair, it never speaks about what will happen to the container
changing during iteration.
Summary
- The iterator’s basic promise —
NoSuchElementExceptiononcenextis exhausted — came out byte-for-byte identical across all three implementations; this is theIteratorinterface’s direct promise and does not break as long as the container never changes. - When the container changed during iteration, three separate answers were measured across
four implementations:
ArrayListandHashSetfail fast and by name,CopyOnWriteArrayListstays on the copy taken at the moment iteration started and never sees the change,ConcurrentHashMapcontinues without throwing an exception but does not guarantee which elements it will see. - Fail-fast is not a guarantee, it is a best effort: the same removal throws an exception on the middle element, but on the second-to-last element it finishes the loop one element short with no warning at all — the result depends on where the removed position sits in the list.
- Bounding measurement: the iterator’s own
removedoes not break the same rule, because the rule is not “do not change,” it is “do not change from outside the iterator”;Iterator.remove()updates the container’s internal counter and the iterator’s expected value at the same time. - None of the three answers is written in the
Iteratorinterface; which one is chosen is decided only by the chosen class, and all three are internally consistent, independent promises.
Next Step
This lesson measured the container changing during iteration; what happens, though, when the container never changes and only the iteration order is in question? In every measurement so far, a container’s own implementation decided which order its own elements would be given in. The next lesson moves this decision outside the container: the ordering rule becomes an object the caller writes, independent of the container, and whether that object is consistent becomes a separate question that has to be tested before the container itself. The pattern seen in this lesson continues there too: a rule can be written, can compile, can give the correct result on most inputs, and still fall silently into wrong behavior on a particular input.
To keep your progress and take notes, Log in
My notes
Log in to take notes.