Java / JVM
java.util.ConcurrentModificationException
Written and reviewed by Sahil Srivastav
java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1013)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:967)What this error actually means
Despite the name, this exception usually has nothing to do with concurrency. It means a collection was *structurally modified* — an element added or removed — while an iterator over it was still in use. The overwhelmingly common case is one thread calling `remove` inside a `for (T item : list)` loop.
The mechanism is a fail-fast check. Collections in `java.util` keep an internal `modCount` that increments on every structural change. Each iterator snapshots that value when created and compares it on every `next()` and `hasNext()`. A mismatch means the collection changed underneath the iteration, so the iterator throws immediately rather than returning results that are silently wrong.
This is a feature, and an important one. Without it you would get skipped elements, duplicated elements, or an `ArrayIndexOutOfBoundsException` at a random later point — all far harder to debug than an exception at the exact line of the violation. Note the corollary: the check is best-effort and unsynchronised, so it detects most violations but guarantees nothing. It is a bug detector, not a safety mechanism.
Causes, most common first
- 1Removing or adding inside an enhanced for loop. The `for (T x : coll)` form uses an iterator you cannot see, so calling `coll.remove(x)` inside it modifies the collection behind that iterator. This is the cause in the large majority of cases.
- 2Modifying a map while iterating its keySet or entrySet. Those views are backed by the map, not copies. `map.remove(k)` inside a `for (K k : map.keySet())` loop is the same violation, and `map.put` of a *new* key is too — replacing an existing key’s value is not structural and is allowed.
- 3Two threads, one unsynchronised collection. The real concurrent case. One thread iterates while another mutates. Here the exception is the lucky outcome — the unlucky outcomes are a corrupted collection, an infinite loop, or a wrong answer with no error at all.
- 4Modifying a collection from inside a stream or forEach. Streams over a non-concurrent source and `Collection.forEach` both detect interference. Mutating the source inside the lambda violates the same contract, sometimes surfacing as `ConcurrentModificationException` and sometimes as unspecified behaviour.
- 5A callback that mutates the collection being iterated. The subtlest form: you iterate a listener list and dispatch events; a listener deregisters itself, which removes it from the list you are iterating. The mutation is several stack frames away from the loop, so it does not look like the bug.
When you see it
- Fires deterministically on a specific input — typically the first list where an element actually matches the removal condition
- A single-threaded unit test reproduces it instantly; no concurrency needed
- Disappears when the list has zero or one matching element, which is why it escapes review
- In the genuinely multi-threaded case it is intermittent and load-dependent instead
- The trace always names `checkForComodification` — that frame is the fail-fast check itself
How to diagnose it
Step 1
Read the frame above checkForComodification
The `checkForComodification` and `Itr.next` frames are the detector. The first frame in your own code above them is the loop that was iterating. That is where to look, though not always where the mutation happens.
Step 2
Find the mutation, which may not be in the loop
If the loop body contains no obvious add or remove, the mutation is inside something the loop calls. Search for structural mutations of that collection anywhere in the reachable call graph — listener dispatch and event handlers are the usual culprits.
grep -rn "\.remove(\|\.add(\|\.clear()" src/main/java | grep -i <collectionName>Step 3
Decide whether concurrency is actually involved
Check whether the collection is reachable from more than one thread at all. If a single-threaded test reproduces the failure, concurrency is a red herring and locking would be the wrong fix.
Step 4
Reproduce with the minimal input
Two elements where the second matches the removal condition is usually enough. A one-element list often will not reproduce it, because the iterator finishes before the next comodification check runs — the reason this bug ships.
The fix
For single-threaded removal, use the iterator’s own `remove()`, which updates `modCount` and the iterator’s expectation together. The concise equivalent is `Collection.removeIf(predicate)`, which is clearer and correct by construction — prefer it whenever the removal is a simple condition.
If you need to add while iterating, do not. Collect the additions into a separate list inside the loop and `addAll` after it finishes. There is no iterator-safe insert for the general case.
For maps, remove through `entrySet().iterator()` or use `map.entrySet().removeIf(...)`. Updating the value of an existing entry via `Map.Entry.setValue` is safe and does not count as structural.
For the genuinely concurrent case, understand that synchronising the loop is rarely the right answer — it serialises readers and does not help if the mutator is not also synchronised on the same monitor. Pick a collection built for concurrent access instead: `ConcurrentHashMap` for maps, and for a listener list that is read constantly and written rarely, `CopyOnWriteArrayList`, whose iterators traverse an immutable snapshot and therefore never throw.
For self-deregistering callbacks, iterate a defensive copy so a listener removing itself mutates the live list while you traverse the snapshot. `CopyOnWriteArrayList` gives you this for free and is the standard choice for listener registries.
// Throws on the first element that matches
for (Order o : orders) {
if (o.isCancelled()) orders.remove(o);
}
// Correct, and clearer
orders.removeIf(Order::isCancelled);
// When you need more than a predicate
for (Iterator<Order> it = orders.iterator(); it.hasNext(); ) {
Order o = it.next();
if (o.isCancelled()) {
audit.record(o);
it.remove();
}
}
// Listener registry: read-mostly, self-deregistration safe
private final List<Listener> listeners = new CopyOnWriteArrayList<>();How to stop it coming back
- Default to `removeIf` and `Collection.stream().filter(...)` over manual loops that mutate
- Use `CopyOnWriteArrayList` for any listener or observer registry — self-deregistration during dispatch is normal, not exceptional
- Never hand a mutable internal collection out of a getter; return `List.copyOf(...)` so callers cannot mutate what you iterate
- Write the two-element test case for every filter-and-remove loop; one element hides the bug
- Treat "add synchronized to fix ConcurrentModificationException" as a review smell — it usually means the cause was never identified
FAQ
Why does it not fire when the list has one element?
The check runs on the next `hasNext()`/`next()` call. After removing the only element the iteration has already ended, so nothing checks. This is exactly why the bug survives testing with small fixtures and fails on real data.
Does synchronizing the loop fix it?
Not for the common single-threaded case — there is no race, so a lock changes nothing. Even in the concurrent case it only helps if every mutator holds the same lock, which is usually a bigger change than adopting a concurrent collection.
Is ConcurrentHashMap immune?
Its iterators are weakly consistent: they never throw, and they may or may not reflect mutations made after creation. That removes the exception but not the need to think — you get a view with no snapshot guarantee, which is fine for scanning and wrong for anything requiring a consistent picture.
Is CopyOnWriteArrayList always a safe swap?
Only for read-mostly collections. Every write copies the entire backing array, so it is O(n) per mutation. Excellent for a few dozen listeners read thousands of times a second; a serious performance bug for a large, frequently mutated list.
Related
Other errors engineers hit next to this one
- Text file busy during executable replacement
- set -e script continues after a failed pipeline
- An unquoted variable turns one argument into several
- 502 Bad Gateway from a reverse proxy
- 504 Gateway Timeout
- CORS preflight: missing Access-Control-Allow-Origin
- 413 Payload Too Large
- 429 Too Many Requests and Retry-After