Fail-fast vs fail-safe iterators, the correct way to remove during iteration, ListIterator and Spliterator, and the Stream API concept (map/filter/collect) that builds on top of all of it.
Published September 22, 2026
ConcurrentModificationException is one of the most common runtime exceptions Java developers hit — and most fix it by trial and error rather than understanding why it happens. This lesson fixes that.
Every structurally-modifiable collection (ArrayList, HashMap, HashSet, ...) keeps an internal modCount field, incremented on every structural modification (add/remove — not just replacing a value in place). When you create an iterator, it captures the current modCount. Every next() call checks that the collection's live modCount still matches what the iterator captured — if it doesn't, it throws ConcurrentModificationException immediately.
List<Integer> nums = new ArrayList<>(List.of(1, 2, 3, 4));
for (Integer n : nums) {
if (n == 2) nums.remove(n); // structural modification mid-iteration
} // throws ConcurrentModificationException on the next iterator.next() call
This is called fail-fast specifically because the goal is to surface the bug immediately and loudly, rather than let iteration silently skip elements or behave unpredictably — the exception is a deliberate safety net, not a bug in the collection.
Iterator<Integer> it = nums.iterator();
while (it.hasNext()) {
if (it.next() == 2) it.remove(); // updates the iterator's own modCount tracking too
}
Iterator.remove() is structurally different from Collection.remove() — it updates the iterator's internally-expected modCount at the same time it removes the element, so the next next() call sees a consistent state. The modern, more concise equivalent for simple predicate-based removal:
nums.removeIf(n -> n == 2); // handles the same safely, in one call
removeIf() internally uses the same iterator-based removal mechanism — it's not a shortcut around the fail-fast machinery, just a cleaner API on top of it.
Some concurrent-friendly structures take a different approach rather than throwing on concurrent modification:
CopyOnWriteArrayList: every write (add/remove) copies the entire backing array. An iterator created before the copy keeps iterating over its own snapshot — it never sees the new array, and never throws, because from its perspective nothing changed. This is why it's called fail-safe, not fail-fast.ConcurrentHashMap: iterators are weakly consistent — they're guaranteed not to throw ConcurrentModificationException, and are guaranteed to reflect the state of the map at some point during the iteration, but may or may not reflect modifications made after the iterator was created. Neither guarantee is "never see new data" nor "always see the very latest state" — it's a deliberately looser middle ground that avoids locking the whole map for the duration of an iteration.Why is CopyOnWriteArrayList expensive on writes? Because every single write reallocates and copies the entire array — O(n) per write, regardless of how small the change. This is a terrible tradeoff for a write-heavy structure, but exactly right for something like a listener list: reads (iterating to notify listeners) happen constantly and need to be fast and lock-free, while writes (adding/removing a listener) are rare. The cost is deliberately pushed onto the rare operation.
ListIterator extends Iterator with backward traversal (hasPrevious()/previous()) and in-place mutation during iteration:
ListIterator<String> it = list.listIterator();
while (it.hasNext()) {
String s = it.next();
if (s.isEmpty()) it.set("placeholder"); // replace in place, mid-iteration, no ConcurrentModificationException
}
set() replaces the value without a structural modification (no add/remove, so modCount isn't touched), which is exactly why it's safe where a plain Collection.set()-then-continue pattern via a regular Iterator wouldn't even be possible (plain Iterator has no set()).
Spliterator ("splitable iterator") is what Stream uses internally to divide a data source into chunks that can be processed on separate threads for .parallelStream(). Its trySplit() method attempts to partition the remaining elements into two roughly-equal pieces, recursively, until pieces are small enough to process directly — this is the actual mechanism that makes parallel streams parallel, not magic.
A Stream is a one-time-use pipeline: built from a source (a collection, an array, Stream.of(), ...), zero or more lazy intermediate operations (map, filter, sorted — these don't execute anything by themselves, they just describe a pipeline stage), and exactly one terminal operation (collect, forEach, reduce, count) that actually triggers execution of the whole pipeline in one pass.
List<String> names = people.stream()
.filter(p -> p.getAge() >= 18) // lazy — nothing runs yet
.map(Person::getName) // lazy — nothing runs yet
.collect(Collectors.toList()); // terminal — the entire pipeline runs now, once
Because a stream is one-time-use, calling a second terminal operation on the same stream throws IllegalStateException — this trips people coming from languages where you can iterate a collection repeatedly by design.
Map<String, List<Person>> byCity = people.stream()
.collect(Collectors.groupingBy(Person::getCity));
Groups elements into a Map keyed by the classifier function's result — the standard way to express "group these records by category" without a manual loop building up a Map<K, List<V>> by hand.
Map<String, Integer> nameToAge = people.stream()
.collect(Collectors.toMap(Person::getName, Person::getAge));
Converts a stream into a Map using a key-mapper and value-mapper. The trap: if two elements produce the same key, this throws IllegalStateException by default — you must supply a third, merge-function argument ((existing, replacement) -> existing) to resolve collisions explicitly, otherwise duplicate keys crash the pipeline at runtime rather than silently overwriting.
Q: Is ConcurrentModificationException guaranteed to be thrown on every unsafe modification?
A: No — the Java docs explicitly say fail-fast behavior is "best effort" and should never be relied on for correctness, only used to detect bugs. modCount checks aren't synchronized, so under genuine multi-threaded concurrent modification, the exception might not fire even though the underlying state is now unsafe — fail-fast is a debugging aid, not a concurrency-safety mechanism.
Q: Why does removeIf() not throw ConcurrentModificationException, but a manual for-each + remove() does?
A: removeIf() is implemented internally using the collection's own iterator with Iterator.remove() semantics (or an equivalent internal mechanism), so it correctly keeps modCount tracking consistent — it isn't magic, it's just using the safe pattern for you instead of the unsafe one.
Q: When would you choose CopyOnWriteArrayList over a synchronized ArrayList?
A: When reads vastly outnumber writes and you want iteration to never block or throw regardless of concurrent writers — a listener/observer list is the textbook case. If writes are frequent, the O(n) copy-per-write cost makes it a poor choice; a ConcurrentHashMap-backed structure or explicit locking would fit better.
Q: Are intermediate stream operations guaranteed to run in the order they're written?
A: For a given element, yes — each element flows through the entire pipeline (filter, then map, then the next stage) before the next element starts, rather than running one operation across all elements before moving to the next stage. This is why an infinite stream with filter().map().findFirst() still terminates: it processes element-by-element, not stage-by-stage.