ConcurrentModification: Fix Java Fail-Fast Loops
Fix ConcurrentModificationException fast: remove with removeIf, snapshot fan-outs, and share via concurrent collections...
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
- ✓Java collections and loops
- ✓Thread basics
- ✓A JDK to compile examples
- ConcurrentModificationException means a fail-fast iterator caught the collection changing under it — usually remove or add inside enhanced-for
- Remove safely with Iterator.remove, Collection.removeIf, or by collecting targets first and removing after the loop
- Share across threads with ConcurrentHashMap or CopyOnWriteArrayList instead of synchronized wrappers
- Single-threaded loops throw this too — threads are only one of several causes
Imagine counting cash while someone keeps slipping bills in and out of the stack — you lose count and start over. Java's iterators count the same way: they snapshot an expected change-count, and when the collection changes without their knowledge, they throw instead of silently miscounting. The fix is changing the stack only through the counter (Iterator.remove), agreeing on a new counting method (concurrent collections), or making your removal list first and acting after the count.
The trace says ConcurrentModificationException at an enhanced-for line that only reads. Nothing in the loop adds or removes — visibly. But a listener fires mid-loop and mutates the list, or the loop body calls a method that removes from the same collection two frames down, or a second thread trims the map while you iterate. The iterator's change-count moved without it, and it threw rather than hand you garbage.
The name misleads half its victims into threading hunts. Most occurrences are single-threaded: structural modification during iteration in plain sequential code. Threads are one cause among several, and reaching for synchronized first fixes nothing when the bug is a remove() inside a for-each on one thread.
This guide covers every shape. You'll learn fail-fast mechanics, Iterator.remove, removeIf, collect-then-remove, CopyOnWriteArrayList and ConcurrentHashMap for shared state, and the genuinely concurrent variants with their own fixes. By the end, iteration plus mutation is a solved pattern choice, not a surprise.
Fail-Fast Iterators: the modCount Tripwire
Every fail-fast collection keeps a modCount that ticks on structural change, and every iterator snapshots it at birth. Each next() compares snapshot against live count; divergence throws immediately. This is deliberate fail-fast design: silently skipping elements after a mid-loop removal corrupts results invisibly, while the throw points at the exact trip. The iterator isn't detecting threads — it's detecting change it didn't authorize, regardless of author.
Enhanced-for hides the iterator, which is why the throw surprises: the line shows no next() call, yet the desugared loop calls it every iteration. Any structural change between iterations — a remove in the body, an add in a callback, a clear from another thread — trips the wire on the following next(). List.set and map value replacement don't tick modCount, so they pass through safely; shape changes always trip.
The repro below triggers it in six lines and is worth running once to feel the mechanics. Keep it as the team's demo: when someone proposes mutating inside for-each, run the repro and watch the tripwire fire. Understanding modCount turns the exception from mystery into checkpoint. Understanding modCount turns the exception from mystery into checkpoint, so demo it whenever someone proposes mid-loop mutation. Keep the repro as the team's demo for why traversal sources must stay stable.
Iterator.remove and removeIf: Sanctioned Removal
Iterator.remove() is the one mutation the tripwire authorizes: it deletes the last-returned element and resyncs the expected count, so iteration continues legally. The rules are tight — call next() first, remove at most once per element — and violations throw IllegalStateException instead. Use it when removal logic needs the element's full context during traversal and fits naturally in the loop.
Collection.removeIf is the better default for filter-style removal: list.removeIf(s -> s.isBlank()) deletes every match internally with no iterator exposed and no wire to trip. It's shorter, faster to read, and immune to the next/remove sequencing rules. Reach for explicit Iterator.remove only when the removal decision spans multiple statements or side effects that don't fit a predicate cleanly.
The snippet shows all three shapes — illegal body-remove, legal iterator-remove, and idiomatic removeIf — so the contrast is reviewable. As a review rule: any remove, add, or clear call lexically inside a for-each over the same collection is a finding. No exceptions, no it-works-today arguments. As a review rule, any remove, add, or clear call lexically inside a for-each over the same collection is a finding. No exceptions and no it-works-today arguments can justify tripping the wire.
Collect-Then-Remove for Callbacks and Two-Phase Work
When removal decisions involve callbacks, I/O, or logic that can't run mid-iteration — like the disconnect cleanup in this article's incident — collect targets during traversal and remove after the loop. The iteration stays pure, the mutation phase runs on a stable collection, and callbacks fire outside the tripwire window. Two phases, zero interleaving, no exception possible by construction.
Snapshots serve the same role when the mutation comes from elsewhere: iterate over List.copyOf(live) while writers update the live list. Broadcasts, event fan-outs, and observer notifications are snapshot territory — readers get a consistent view, writers never block, and slow consumers can't stall mutation. The copy cost is trivial against the debugging cost of interleaved mutation.
The snippet shows both patterns on the incident's shape: collect-then-remove for decision loops, snapshot iteration for callback fan-out. Choose by authorship — collect when your loop decides, snapshot when others mutate during your loop. Either deletes the interleaving that trips the wire. Choose by authorship: collect when your loop decides, snapshot when others mutate during your loop, and document why. Two phases with zero interleaving means no exception is possible by construction.
ConcurrentHashMap and CopyOnWriteArrayList
When threads genuinely share the collection, concurrent types replace iteration discipline with designed-in safety. ConcurrentHashMap's iterators are weakly consistent: they traverse live data without tripwires, tolerating concurrent puts and removes, and never throw this exception. Its atomic methods — computeIfAbsent, merge, compute — fold read-modify-write into single operations that synchronized HashMap code gets wrong. For maps shared across threads, it's the default answer.
CopyOnWriteArrayList suits read-heavy, write-rare lists like subscriber registries and config snapshots: iteration rides an immutable array copy while writes swap in a new array. Iterators never trip because they hold their own snapshot. The price is copy-on-write cost, so write-heavy lists need explicit locking around whole traverse-mutate blocks instead — ArrayList plus synchronized, with both iteration and mutation inside the same monitor.
The snippet shows the map's atomic counter and the list's snapshot iteration. Note what disappears: no synchronized blocks, no manual copies, no tripwire. Match the type to the access shape — concurrent map for shared maps, copy-on-write for read-heavy shared lists, locked blocks for write-heavy shared lists.
Real Thread Races: Locking Whole Operations
Genuine multi-threaded cases need whole-operation atomicity: the iteration plus its dependent mutation must run as one unit no other thread can split. Synchronizing only the mutation while iteration runs outside the lock still trips — the wire fires between the lock release and the next() call. Both must share one monitor, or the structure changes mid-traversal. This is the correct use of synchronized that the incident's team reached for prematurely: right tool, wrong diagnosis.
Keep locked regions short and side-effect-free: copy under lock, process outside it. Long callbacks inside monitors serialize all threads and invite deadlocks when callbacks reenter. The copy-under-lock pattern gives both safety and liveness — brief mutual exclusion for the snapshot, lock-free processing after.
The snippet shows the wrong split lock and the correct whole-operation lock plus the copy-out refinement. For executors and parallel streams touching shared collections, prefer concurrent types over manual locking entirely — hand-rolled monitor discipline across lambdas is where races hide. For executors and parallel streams touching shared collections, prefer concurrent types over manual locking entirely. Hand-rolled monitor discipline across lambdas is exactly where races love to hide.
next(). Rule: audit that traversal and mutation share the monitor, or replace both with a concurrent type.Streams, Filters, and the Modern Traps
Streams don't exempt mutation discipline. Collecting into the source collection inside forEach — list.stream().forEach(list::add) — trips or corrupts exactly like loop-body mutation. Modifying a backing collection during a stream pipeline's execution throws the same exception from the spliterator. The rule crosses paradigms: the data source stays stable while its traversal runs, however the traversal is spelled.
Parallel streams add visibility races on top: side-effect accumulations into ArrayList from multiple threads lose elements silently without ever throwing. Collectors exist precisely to avoid this — collect() partitions and merges safely where forEach-plus-add corrupts. Any forEach that mutates shared state is a bug whether or not the tripwire fires; the throw is the lucky outcome.
Filter-then-act pipelines should stay pipelines: stream().filter(...).toList() produces the survivors, and the original gets replaced or trimmed afterward in one step. The snippet contrasts the corrupting forEach with the clean pipeline plus removeAll. Teach the team that forEach mutating its source is the stream spelling of the 1998 bug. Teach the team that forEach mutating its source is the stream spelling of the classic loop bug from decades past. Filter-then-act pipelines should stay pipelines, with mutation confined to a deliberate post-step.
Listener Mutating Subscriber List Dropped 9k Sessions
- The name says concurrent but the bug is often sequential. Read the loop body and its callbacks before reaching for thread tools.
- Callbacks that mutate iterated collections are invisible writes. Audit listener paths for structural changes to anything the loop traverses.
- Synchronized can't fix self-mutation on one thread. Match the repair to the cause: snapshots or remove-discipline for iteration bugs, concurrent types for real sharing.
next() but the loop looks read-only| File | Command / Code | Purpose |
|---|---|---|
| io | public final class ModRepro { | Fail-Fast Iterators |
| io | public final class SafeRemove { | Iterator.remove and removeIf |
| io | public final class TwoPhase { | Collect-Then-Remove for Callbacks and Two-Phase Work |
| io | public final class SharedState { | ConcurrentHashMap and CopyOnWriteArrayList |
| io | public final class LockedIterate { | Real Thread Races |
Key takeaways
Common mistakes to avoid
6 patternsAssuming the name means threads
Removing inside enhanced-for bodies
Synchronizing mutation but not iteration
next() call.Fan-out over live mutable lists
Accumulating into shared lists from parallel streams
collect() with proper collectors. Never forEach-mutate shared state from stream pipelines.Using synchronized wrappers as iteration safety
Interview Questions on This Topic
What triggers ConcurrentModificationException?
next() fires the throw. It's usually single-threaded, not a race.Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.
That's Exception Handling. Mark it forged?
5 min read · try the examples if you haven't