Home Java ConcurrentModification: Fix Java Fail-Fast Loops
Intermediate 5 min · September 23, 2026

ConcurrentModification: Fix Java Fail-Fast Loops

Fix ConcurrentModificationException fast: remove with removeIf, snapshot fan-outs, and share via concurrent collections...

N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Production
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
Before you start⏱ 11 min
  • Java collections and loops
  • Thread basics
  • A JDK to compile examples
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
✦ Definition~90s read
What is Java ConcurrentModification Fix?

ConcurrentModificationException is an unchecked exception in java.util thrown when a fail-fast iterator detects its collection changed structurally outside the iterator's own methods. ArrayList, HashMap, HashSet, and friends track a modCount incremented on every structural change (adds, removes, resizes); each iterator snapshots the count at creation and compares on every next().

Imagine counting cash while someone keeps slipping bills in and out of the stack — you lose count and start over.

A mismatch throws immediately rather than risking skipped or duplicated elements. It extends RuntimeException — no forced handling, just a contract to honor.

Structural means shape-changing: add, remove, clear. Set-value operations like List.set or Map value replacement don't trip it because the shape is unchanged. Single-threaded triggers dominate: removing inside enhanced-for, adding during traversal, or calling helpers that mutate the iterated collection.

Multi-threaded triggers need no actual simultaneity — any interleaving between one thread's iteration and another's mutation throws, even seconds apart.

The fixes form a decision tree. Mutating the iterated collection on one thread: Iterator.remove(), removeIf(), or collect-then-remove. Sharing across threads with iteration: ConcurrentHashMap, CopyOnWriteArrayList, or explicit locking around whole iteration-plus-mutation blocks.

The wrong fix is as instructive: synchronized wrappers alone don't save iteration — you must synchronize the traversal too, which is why concurrent collections exist.

Plain-English First

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.

io/thecodeforge/errors/ModRepro.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.ArrayList;
import java.util.List;

public final class ModRepro {
    public static void main(String[] args) {
        List<String> users = new ArrayList<>(List.of("a", "b", "c"));
        try {
            for (String u : users) {
                if (u.equals("b")) {
                    users.remove(u); // structural change: trips the wire
                }
            }
        } catch (Exception e) {
            System.out.println("THROWS: " + e);
        }
        System.out.println("survivors: " + users);
    }
}
// Run: javac ModRepro.java && java ModRepro
📊 Production Insight
A developer insisted the loop was read-only until the repro showed the callback's remove two frames down. Rule: desugar for-each mentally to iterator-plus-next whenever this throws — the mutation is always one call away.
🎯 Key Takeaway
modCount ticks on shape change; iterators compare on every next.
Enhanced-for hides the iterator but not the tripwire.
Set-values pass; adds and removes trip — memorize the split.

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.

io/thecodeforge/errors/SafeRemove.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public final class SafeRemove {
    public static void viaIterator(List<String> users) {
        for (Iterator<String> it = users.iterator(); it.hasNext();) {
            if (it.next().isBlank()) {
                it.remove(); // authorized: resyncs the tripwire
            }
        }
    }

    public static void viaRemoveIf(List<String> users) {
        users.removeIf(String::isBlank); // idiomatic: no iterator exposed
    }

    public static void main(String[] args) {
        List<String> a = new ArrayList<>(List.of("a", "", "c"));
        viaRemoveIf(a);
        System.out.println(a); // [a, c]
    }
}
📊 Production Insight
A codebase-wide sweep replacing body-removes with removeIf deleted 34 tripwire risks in one afternoon — and every replacement was shorter. Rule: removeIf is the default; Iterator.remove is the escape hatch for complex decisions.
🎯 Key Takeaway
Iterator.remove resyncs the wire — next first, once per element.
removeIf is the idiomatic default for filter removal.
Mutations lexically inside for-each are review findings.

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.

io/thecodeforge/errors/TwoPhase.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.ArrayList;
import java.util.List;

public final class TwoPhase {
    public static void collectThenRemove(List<String> users) {
        List<String> doomed = new ArrayList<>();
        for (String u : users) { // phase 1: pure traversal
            if (u.isBlank()) {
                doomed.add(u);
            }
        }
        users.removeAll(doomed); // phase 2: stable mutation
    }

    public static void broadcast(List<String> live, Sender out) {
        for (String s : List.copyOf(live)) { // snapshot: callbacks can't trip us
            out.send(s); // may trigger removals from live: safe now
        }
    }

    interface Sender { void send(String s); }
}
📊 Production Insight
The snapshot fix ended the five-night broadcast outage in one deploy — same callbacks, same removals, zero interleaving. Rule: fan-out over live mutable lists always snapshots; no performance argument survives a 9k-session loss.
🎯 Key Takeaway
Collect targets in phase one, mutate in phase two — no interleaving.
Snapshot fan-outs whose callbacks can mutate the live list.
Choose by authorship: your decision collects, others' mutation snapshots.

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.

io/thecodeforge/errors/SharedState.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;

public final class SharedState {
    private final ConcurrentHashMap<String, Long> hits = new ConcurrentHashMap<>();
    private final CopyOnWriteArrayList<String> subscribers = new CopyOnWriteArrayList<>();

    public void hit(String key) {
        hits.merge(key, 1L, Long::sum); // atomic: no check-then-act race
    }

    public void broadcast(Sender out) {
        for (String s : subscribers) { // snapshot array: never trips
            out.send(s);
        }
    }

    interface Sender { void send(String s); }
}
📊 Production Insight
A hits map built on synchronized HashMap corrupted counts under load while never throwing — check-then-act races don't trip wires, they just lie. Rule: merge and computeIfAbsent replace synchronized read-modify-write everywhere.
🎯 Key Takeaway
ConcurrentHashMap iterates weakly-consistent and never trips.
CopyOnWriteArrayList fits read-heavy shared lists; lock write-heavy ones.
Atomic methods replace synchronized check-then-act patterns.

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.

io/thecodeforge/errors/LockedIterate.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import java.util.ArrayList;
import java.util.List;

public final class LockedIterate {
    private final List<String> items = new ArrayList<>();

    public void add(String s) {
        synchronized (items) {
            items.add(s);
        }
    }

    public List<String> snapshot() {
        synchronized (items) { // whole traversal inside one monitor
            return new ArrayList<>(items);
        }
    }

    public void process() {
        List<String> copy = snapshot(); // brief lock, then lock-free work
        for (String s : copy) {
            System.out.println(s);
        }
    }
}
⚠ Lock the Whole Operation, Not Half
Synchronizing mutation while iteration runs outside the lock still trips the wire. Both must share one monitor — or better, copy under lock and process outside it for safety plus liveness.
📊 Production Insight
A team synchronized every add but left iteration outside the lock — trips continued because the wire fires between lock release and next(). Rule: audit that traversal and mutation share the monitor, or replace both with a concurrent type.
🎯 Key Takeaway
Iteration plus dependent mutation is one atomic unit under one monitor.
Copy under lock, process outside — short exclusion, no deadlocks.
Prefer concurrent types over hand-rolled locking in lambda-heavy code.

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.

📊 Production Insight
A parallel stream accumulating into ArrayList lost 3% of records silently — no throw, just missing data. The throw would have been kinder. Rule: forEach never mutates its source or shared state; collectors own accumulation.
🎯 Key Takeaway
Sources stay stable during traversal in every paradigm, streams included.
forEach-plus-add is the stream spelling of the classic bug.
Collectors accumulate safely where shared mutation corrupts silently.
● Production incidentPOST-MORTEMseverity: high

Listener Mutating Subscriber List Dropped 9k Sessions

Symptom
Nightly broadcast logs showed ConcurrentModificationException mid-iteration for five consecutive nights, each aborting the remaining fan-out. Roughly 9,000 sessions total never received price updates and reconnected stale, spiking support tickets. The loop looked read-only — the mutation hid inside an onDisconnect callback two frames down that removed entries from the same list.
Assumption
The team suspected a threading race because of the exception's name and added synchronized blocks around the loop. Failures continued unchanged — correctly, since everything ran on one event thread. Two days went to thread-dump analysis of a single-threaded bug before someone read the callback chain.
Root cause
The broadcast loop iterated the subscriber ArrayList with enhanced-for while the per-subscriber send path invoked disconnect cleanup on failures, removing entries from that same list. The first failed send of each night tripped modCount, and every remaining subscriber was skipped. Synchronization couldn't help: one thread was both iterating and mutating through reentrant callbacks.
Fix
The loop was switched to iterate over a snapshot copy while removals apply to the live list, ending the failure the same night. The disconnect path was audited for all shared-collection mutations, and a stress test with flaky subscribers now runs in CI. Broadcasts completed fully on night six; missed sessions received a catch-up push.
Key lesson
  • 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.
Production debug guideFive steps that find the mutation hiding behind the iteration.5 entries
Symptom · 01
The trace points at next() but the loop looks read-only
Fix
Read the loop body plus every method it calls: grep -rn '\.remove(\|\.add(\|\.clear(\|\.put(' src/main/java around the iterated collection. The mutation often hides two frames down in a callback. Reproduce single-threaded first: javac ModRepro.java && java ModRepro.
Symptom · 02
You need to prove single-thread versus multi-thread
Fix
Capture stacks during failure: jstack $(pgrep -f app.jar) > /tmp/threads.txt; grep -c 'BroadcastLoop' /tmp/threads.txt. One thread in the loop means sequential self-mutation — fix iteration discipline, not locking. Multiple threads means shared state — reach for concurrent types.
Symptom · 03
Listeners or callbacks may mutate the collection
Fix
List all writers of the collection: grep -rn 'subscribers\.\|sessions\.' src/main/java | grep -E 'remove|add|clear|put'. Audit each for execution during iteration windows. Snapshot the collection before loops that trigger callbacks.
Symptom · 04
The failure tracks a specific deployed build
Fix
Verify the deployed loop: jar tf app.jar | grep 'Broadcaster.class' and javap -c -p com/example/Broadcaster.class | grep -E 'remove|Iterator'. Rebuild with mvn -q clean package or gradle build and rerun the flaky-subscriber scenario before editing.
Symptom · 05
You need a regression lock for the mutation pattern
Fix
Write a test that mutates during traversal the old way and asserts the new code survives: mvn -q -Dtest=BroadcastTest test. Include callback-mutates-list and two-thread cases so both families stay fixed.
ConcurrentModificationException Causes Compared
Root CauseHow to ConfirmFixPrevention
Remove inside enhanced-forremove/add lexically in loop bodyIterator.remove or removeIfReview flags mutations inside for-each
Callback mutates iterated listWriters fire during traversal windowSnapshot copy or collect-then-removeAudit listener paths for structural writes
Thread interleaving on shared stateMultiple threads in stacks around loopConcurrent types or whole-op lockingConcurrent defaults for shared collections
Stream source mutated mid-pipelineforEach adds to its own sourcePipeline filter plus post-step mutationBan source mutation inside pipelines
Synchronized halves (lock split)Mutation locked, iteration outside lockOne monitor for traverse-plus-mutateCopy under lock, process outside
⚙ Quick Reference
5 commands from this guide
FileCommand / CodePurpose
iothecodeforgeerrorsModRepro.javapublic final class ModRepro {Fail-Fast Iterators
iothecodeforgeerrorsSafeRemove.javapublic final class SafeRemove {Iterator.remove and removeIf
iothecodeforgeerrorsTwoPhase.javapublic final class TwoPhase {Collect-Then-Remove for Callbacks and Two-Phase Work
iothecodeforgeerrorsSharedState.javapublic final class SharedState {ConcurrentHashMap and CopyOnWriteArrayList
iothecodeforgeerrorsLockedIterate.javapublic final class LockedIterate {Real Thread Races

Key takeaways

1
Fail-fast iterators trip on unauthorized change, threads or not.
2
removeIf and Iterator.remove are the sanctioned removals.
3
Two-phase and snapshot patterns delete interleaving by design.
4
Concurrent types replace locking for genuinely shared state.
5
Lock whole traverse-plus-mutate units, never halves.
6
Streams obey the same stability rule as loops.

Common mistakes to avoid

6 patterns
×

Assuming the name means threads

Symptom
Days of thread-dump analysis on a single-threaded callback mutation; synchronized changes nothing.
Fix
Prove thread count first with jstack. One thread means iteration discipline — removeIf, snapshots, two-phase — not locking.
×

Removing inside enhanced-for bodies

Symptom
Deterministic throw on the first matching element, every run, in plain sequential code.
Fix
Use removeIf for filters, Iterator.remove for contextual decisions, collect-then-remove for callback-adjacent work.
×

Synchronizing mutation but not iteration

Symptom
Trips continue because the wire fires between lock release and the next next() call.
Fix
Put traversal and dependent mutation under one monitor, or copy under lock and process outside it.
×

Fan-out over live mutable lists

Symptom
First failing subscriber aborts all remaining fan-out; losses scale with failure rate.
Fix
Iterate List.copyOf(live) so callbacks mutate freely without tripping the broadcast.
×

Accumulating into shared lists from parallel streams

Symptom
Silent element loss with no exception — corruption instead of a checkpoint.
Fix
Use collect() with proper collectors. Never forEach-mutate shared state from stream pipelines.
×

Using synchronized wrappers as iteration safety

Symptom
Collections.synchronizedList still trips because iteration itself isn't synchronized.
Fix
Manual sync blocks around full traversal, or better, concurrent collections designed for the access shape.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What triggers ConcurrentModificationException?
Q02JUNIOR
How do you remove during iteration safely?
Q03SENIOR
Why doesn't synchronized fix every case?
Q04SENIOR
ConcurrentHashMap versus synchronized HashMap?
Q05SENIOR
What goes wrong accumulating into ArrayList from parallel streams?
Q01 of 05JUNIOR

What triggers ConcurrentModificationException?

ANSWER
A fail-fast iterator detecting structural change it didn't authorize — removes or adds during traversal, callbacks mutating the list, or thread interleaving. modCount comparison on next() fires the throw. It's usually single-threaded, not a race.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Is it always a threading bug?
02
Why does the read-only-looking loop throw?
03
Does List.set trigger it?
04
CopyOnWriteArrayList for everything shared?
05
Can I catch it and retry?
06
How do I prove threads versus callbacks?
N
Naren Founder & Principal Engineer

20+ years shipping production Java in banking & fintech. Drawn from code that ran under real load.

Follow
Verified
production tested
September 23, 2026
last updated
1,905
articles · all by Naren
🔥

That's Exception Handling. Mark it forged?

5 min read · try the examples if you haven't

Previous
Java NoSuchMethodError Fix
18 / 19 · Exception Handling
Next
Java ArithmeticException Fix