A billing sweep I wrote spent three weeks leaving exactly one voided line on roughly one invoice in forty. No exception. No log line. Just totals that were a few hundred paise too high and a reconciliation report nobody read until the quarter closed. When I finally reproduced it on my laptop the loop threw ConcurrentModificationException on the first attempt, and that was the good outcome — because at least the throw pointed at a line number.
That is the whole shape of this exception. When it fires it is one of the most honest failures in Java, stopping you at the exact statement that broke the rule. When it doesn't fire, the bug is still there — now as a wrong number in a database rather than a stack trace in a log.
The name is also a lie, or near enough. There was one thread in that sweep. There always is.
Table of Contents
- What the Error Actually Means
- Why It Happens: modCount and expectedModCount
- Cause 1: Removing From a List Inside an Enhanced For-Loop
- Cause 2: Mutating a Map While Iterating Its Views
- Cause 3: One Thread Iterating While Another Writes
- Four Ways to Fix It
- Where This Still Bites You
- How to Prevent It
- Frequently Asked Questions
What the Error Actually Means
ConcurrentModificationException means a collection was structurally modified while an iterator over it was still live. Structural modification is a precise term: it means the number of elements changed. Adding, removing, clearing. Replacing the value at a key you already have is not structural. Neither is calling a setter on an object that happens to be sitting in the list.
"Concurrent" in the class name means concurrent with the iteration, not concurrent across threads. That single word has probably cost the Java community more debugging hours than any other identifier in java.util, because it sends people looking for a race condition in code that has one thread.
The other thing worth knowing up front: it is thrown with no message. Ever. You get java.util.ConcurrentModificationException and a stack trace, and the trace is the entire diagnostic. That is a strange contrast with the rest of modern Java — a NullPointerException on Java 21 tells you which link in a chained call was null in a full English sentence, while a CME will not name the element you removed. Top two frames, JDK internals. Third frame, your bug.
Why It Happens: modCount and expectedModCount
AbstractList has had a field called modCount since Java 1.2. A plain int, incremented by every operation that changes the size of the collection. HashMap keeps its own copy of the same idea. When you ask a collection for an iterator, the iterator snapshots that number and re-checks it every time you advance. Here is ArrayList's, abridged to the parts that matter:
// java.util.ArrayList, inner class Itr (abridged)
private class Itr implements Iterator<E> {
int cursor; // index of the next element to return
int lastRet = -1; // index of the last element returned
int expectedModCount = modCount; // the snapshot, taken at iterator() time
public boolean hasNext() {
return cursor != size; // note: no comodification check here
}
public E next() {
checkForComodification();
// ... return elementData[cursor++] ...
}
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
Read hasNext() again. It compares the cursor to the size and nothing else. The comodification check lives only in next(). Hold that thought — it is the entire explanation for the silent variant of this bug.
Now the enhanced for-loop, which looks like a language feature for iterating a collection and is really syntax sugar over an explicit iterator:
// what you write
for (Line line : lines) {
total += line.amountMinor();
}
// what javac compiles it to
for (Iterator<Line> it = lines.iterator(); it.hasNext(); ) {
Line line = it.next();
total += line.amountMinor();
}
There is an iterator in your for-each loop whether you wrote one or not, and it snapshotted modCount before the first element. Any lines.remove(...) or lines.add(...) in the body bumps the collection's counter without touching the iterator's copy. The next next() notices the gap and throws.
One thing must be said plainly. This mechanism is best-effort, and the JDK documents it as best-effort: the Iterator and ArrayList javadocs both say fail-fast behaviour cannot be guaranteed under unsynchronised concurrent modification, and that it would be wrong to write a program depending on the exception for its correctness. modCount is a non-volatile int, so across threads there is no happens-before edge making the read reliable. Treat CME as a bug detector that sometimes works, never as a control-flow signal.
Cause 1: Removing From a List Inside an Enhanced For-Loop
The canonical case, and the one that produced my three-week billing bug. A sweep that drops voided lines before the invoice is totalled:
package com.acme.billing;
import java.util.ArrayList;
import java.util.List;
public final class InvoiceSweeper {
record Line(String sku, long amountMinor, boolean voided) {}
static void dropVoided(List<Line> lines) {
for (Line line : lines) {
if (line.voided()) {
lines.remove(line); // structural modification, mid-iteration
}
}
}
public static void main(String[] args) {
List<Line> lines = new ArrayList<>(List.of(
new Line("SEAT-STD", 1299L, false),
new Line("SEAT-PRO", 2499L, true),
new Line("SUPPORT", 9900L, false),
new Line("OVERAGE", 450L, false)));
dropVoided(lines);
System.out.println(lines);
}
}
Exception in thread "main" 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)
at com.acme.billing.InvoiceSweeper.dropVoided(InvoiceSweeper.java:11)
at com.acme.billing.InvoiceSweeper.main(InvoiceSweeper.java:24)
Two JDK frames, then dropVoided at line 11 — the for statement, not the remove call, because the throw happens on the next advance rather than at the mutation. The line the trace blames is the line that noticed, not the line that did it.
So far so ordinary. Here is the part that makes this genuinely nasty. Move the voided flag to the second-to-last element and void the last one too:
List<Line> lines = new ArrayList<>(List.of(
new Line("SEAT-STD", 1299L, false),
new Line("SEAT-PRO", 2499L, false),
new Line("SUPPORT", 9900L, true), // index 2 of 4: second-to-last
new Line("OVERAGE", 450L, true))); // also voided
dropVoided(lines);
System.out.println(lines);
[Line[sku=SEAT-STD, amountMinor=1299, voided=false], Line[sku=SEAT-PRO, amountMinor=2499, voided=false], Line[sku=OVERAGE, amountMinor=450, voided=true]]
No exception. Exit code zero. And a voided line still sitting in the invoice.
Trace it against the iterator code above. Size is 4. The third next() returns SUPPORT and leaves the cursor at 3. remove drops size to 3 and bumps modCount. The loop evaluates hasNext(), which is cursor != size, which is 3 != 3, which is false. The loop exits normally. next() is never called again, so checkForComodification() is never called again, so nothing throws. OVERAGE is never even inspected.
Compare removing the last element. There next() leaves the cursor at 4, the removal drops size to 3, hasNext() asks 4 != 3, gets true, calls next(), and you get your CME. Removing the final element throws; removing the one before it does not. That asymmetry is not a rule anyone would guess, and it is exactly why the bug survives review: the reviewer knows the pattern is wrong, tries it, sees an exception, and concludes the failure is always loud.
It is loud for every element except one. That one is the one that ships.
Cause 2: Mutating a Map While Iterating Its Views
keySet(), entrySet() and values() are views, not copies. They allocate nothing meaningful, they hold a reference back to the map, and their iterators check the map's modCount. Iterating a key set is iterating the map.
Which matters because put is two different operations wearing one name. Adding a key the map does not have is structural — it creates a node and bumps modCount. Putting to a key the map already has overwrites a value field and does not touch modCount at all. One of those throws and one is documented as safe.
package com.acme.billing;
import java.util.HashMap;
import java.util.Map;
public final class LedgerRollup {
/** Rounds every balance to the paisa and attaches GST rows for Indian accounts. */
static void normalise(Map<String, Long> balances) {
for (String account : balances.keySet()) {
long minor = balances.get(account);
balances.put(account, roundToPaisa(minor)); // existing key: safe
if (account.startsWith("IN-")) {
balances.put(account + "-GST", gstOn(minor)); // NEW key: structural
}
}
}
static long roundToPaisa(long minor) { return (minor / 100L) * 100L; }
static long gstOn(long minor) { return Math.round(minor * 0.18); }
public static void main(String[] args) {
Map<String, Long> balances = new HashMap<>(Map.of(
"SG-4417", 120_450L,
"IN-9021", 88_300L,
"DE-1180", 402_000L));
normalise(balances);
System.out.println(balances);
}
}
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1597)
at java.base/java.util.HashMap$KeyIterator.next(HashMap.java:1620)
at com.acme.billing.LedgerRollup.normalise(LedgerRollup.java:10)
at com.acme.billing.LedgerRollup.main(LedgerRollup.java:27)
Now think about the test suite. The fixture almost certainly uses accounts called ACC-1 and ACC-2. No key starts with IN-, the branch never runs, the only put that executes is the safe one, and the test is green forever. The method is correct for every input the test exercises and broken for the first Indian customer. Every CME I have seen escape into production had that shape: a structural modification behind a conditional the fixture data never satisfied.
Two safe operations cover most of what people actually want here. entry.setValue(v) while iterating entrySet() is explicitly permitted — it writes through without a structural change. And iterator.remove() on any of the three views removes the mapping and keeps the counters in sync, exactly as it does on a list.
One detail, implementation-specific rather than guaranteed: HashMap's iterator computes its next node before returning the current one, and hasNext() is just next != null. So an insertion during the final iteration may go entirely undetected, and whether the new key gets visited or skipped depends on which bucket it hashes into relative to where the iterator has already been. Same silent exit as the list, different mechanism — and a concrete demonstration of what "best-effort" means in that javadoc sentence.
Cause 3: One Thread Iterating While Another Writes
This is the case the class name was actually named for, and it is the rarest of the three. It is also the only one where a CME is a symptom rather than the disease.
package com.acme.billing;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class SettlementQueue {
private final List<String> pending =
Collections.synchronizedList(new ArrayList<>());
void enqueue(String invoiceId) {
pending.add(invoiceId); // synchronized internally
}
int totalPendingChars() {
int n = 0;
for (String id : pending) { // iteration is NOT covered by that lock
n += id.length();
}
return n;
}
}
Exception in thread "reporter-0" 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)
at com.acme.billing.SettlementQueue.totalPendingChars(SettlementQueue.java:18)
at com.acme.billing.SettlementQueue.lambda$main$0(SettlementQueue.java:31)
at java.base/java.lang.Thread.run(Thread.java:1583)
Collections.synchronizedList makes each individual method call atomic. It does not make a sequence of calls atomic, and an iteration is a sequence. The javadoc says so directly: you must manually synchronise on the returned list when traversing it. The lock is the wrapper object itself — an odd API, but a documented one:
int totalPendingChars() {
synchronized (pending) { // the wrapper is the documented monitor
int n = 0;
for (String id : pending) {
n += id.length();
}
return n;
}
}
Here is the part that should worry you more than the exception. Across threads the CME genuinely may not fire at all. modCount is a non-volatile field read without synchronisation, so the reader may never observe the writer's increment and will happily return a total computed from a half-updated array. That is the outcome to be afraid of: not a crash, a wrong number. If you are hitting CME across threads, the fix is not to silence it — it is that you have no synchronisation strategy, and the exception was kind enough to say so before the numbers went wrong.
An unsynchronised plain HashMap written by two threads is worse still: lost entries, a size() that disagrees with the contents, a get() returning null for a key that was definitely put. No exception for any of it.
Four Ways to Fix It
1. Iterator.remove()
The direct answer. Get the iterator explicitly and let it do the removal, so it can update both the list and its own expectedModCount:
static void dropVoided(List<Line> lines) {
for (Iterator<Line> it = lines.iterator(); it.hasNext(); ) {
if (it.next().voided()) {
it.remove();
}
}
}
Itr.remove() deletes the last element returned, pulls the cursor back to fill the hole, and re-snapshots expectedModCount. Nothing is skipped and nothing throws. Two rules: call next() before every remove(), and never remove() twice for one next() — both give you an IllegalStateException, not a CME.
Traversal is one pass, but on an ArrayList each removal shifts the tail with an arraycopy, so deleting k elements from a list of n costs O(n·k) — quadratic when you are deleting most of the list. On a LinkedList each removal is O(1), because the iterator already holds the node. To replace or insert rather than delete, ListIterator adds set() and add(); an element added through the iterator goes in before the cursor and will not be returned by the following next().
2. removeIf()
lines.removeIf(Line::voided);
One line, no iterator to get wrong, and it returns a boolean telling you whether anything changed. For a plain predicate filter this is the right default and I reach for it without thinking.
It is also faster than the loop above, which surprises people. The Collection default implementation is just an iterator loop, but ArrayList overrides it: one pass evaluates the predicate and records the doomed indices in a long[] bitset, then a second pass compacts the survivors in a single sweep. That collapses the O(n·k) shifting to O(n). The override is an OpenJDK implementation detail rather than a spec guarantee, but it has been there since Java 8, and it is why "just use removeIf" is performance advice and not only style advice.
The catch: the predicate must be side-effect free. ArrayList.removeIf re-checks modCount after the predicate pass and throws a CME if your lambda touched the list. It removes the trap, not the rule.
3. Iterate a copy, or collect then mutate
List<Line> toRefund = new ArrayList<>();
for (Line line : lines) {
if (refundPolicy.isRefundable(line.sku())) { // network call
toRefund.add(line);
}
}
lines.removeAll(toRefund);
ledger.recordRefunds(toRefund);
Two phases: decide while reading, mutate afterwards. Use this when the predicate does I/O, when you need the removed elements for something else, or when you are removing from list A while reading list B. Iterating new ArrayList<>(lines) or List.copyOf(lines) and mutating the original works too, and is sometimes clearer.
The cost is worth stating honestly: an extra array of n references. Nothing for a few hundred invoice lines, something to think about for a few million. And removeAll on an ArrayList is O(n·m) in the general case because it calls contains on its argument, so if the removal set is large, make it a HashSet first.
4. Concurrent collections
private final List<InvoiceListener> listeners = new CopyOnWriteArrayList<>();
void publish(InvoiceEvent event) {
for (InvoiceListener listener : listeners) { // snapshot; never throws CME
listener.onInvoice(event); // a handler may unregister itself
}
}
CopyOnWriteArrayList and ConcurrentHashMap have iterators that are weakly consistent rather than fail-fast. They never throw ConcurrentModificationException. A COW iterator walks the array as it existed when the iterator was created, so later writes are invisible to it, and its remove(), set() and add() throw UnsupportedOperationException. A ConcurrentHashMap iterator reflects some but not necessarily all modifications made since its creation, and size() is an estimate.
The cost of copy-on-write is not subtle: every mutating call allocates a new array and copies the whole thing. Appending n elements to an empty COW list is O(n²). It pays off in exactly one shape — read constantly, write almost never — which is why listener registries are the textbook example and very nearly the only good one.
Do not reach for this to fix cause 1 or cause 2. Swapping an ArrayList for a CopyOnWriteArrayList to stop a single-threaded remove-inside-a-loop from throwing does make the exception go away, and leaves you with a loop reading a stale snapshot while mutating a live list. The bug is still there, and now nothing reports it.
My own ordering: removeIf first, always, when the condition is a pure function of the element. Iterator.remove() when the body has to do more than decide — log each removal, count, hand the element to something. Copy-then-mutate the moment the predicate touches the network or a database, because a slow predicate inside a live iteration holds a lock too long even when it is correct. Concurrent collections only when the collection is genuinely shared, chosen for the sharing and not because something threw.
Where This Still Bites You
Streams move the failure away from the code that caused it. Intermediate operations are lazy, so a side-effecting lambda inside .filter() does not throw when the filter line runs — nothing runs there. The ArrayList spliterator binds to modCount late and checks it as it traverses, so the CME surfaces during the terminal operation and the trace points at the .collect() or .forEach() several lines below the lambda that broke the rule. Non-interference is a documented requirement of the streams API, not a suggestion.
subList() fails in the opposite direction to everything else here, and it catches me every time. The sublist is a view holding its own copy of the parent's modCount. Structurally modify the parent and the child is invalidated: any subsequent operation on the sublist throws a CME, including size(), which is a surprising place to receive one. Mutating through the sublist is fine and propagates upward. Mutating the parent poisons the sublist.
Arrays.asList() and List.of() throw something else entirely, and the two get conflated constantly. remove() on either gives you UnsupportedOperationException, not ConcurrentModificationException. Arrays.asList() is a fixed-size view over the array, so set() works and add/remove do not; List.of() is fully immutable and rejects all three. If your loop threw UOE rather than CME, none of the four fixes above apply — you needed new ArrayList<>(...) around the source in the first place.
And forEach() is not an escape hatch. ArrayList.forEach snapshots modCount, tests it in the loop condition on every element, and throws a CME after the loop if it changed. So does HashMap.forEach. Rewriting a broken for-each as list.forEach(x -> ...) changes the syntax and nothing else.
How to Prevent It
Stop mutating during iteration as a habit, not as a fix applied after a stack trace. The pattern that never fails is building a new collection instead of editing the old one: lines.stream().filter(l -> !l.voided()).toList() reads better than any removal loop, produces an unmodifiable list, and cannot express the bug. A method that takes a collection and returns a collection has nothing to get wrong.
When you must mutate in place, keep the decision and the mutation in different loops. That is the discipline behind fix 3, and it generalises past this exception: a loop that reads and a loop that writes are each easy to reason about, and one loop doing both is where the interesting bugs live.
Make collection fields final and copy at the boundary. A getter that hands out the live internal list invites a caller to iterate it while you mutate it, and no amount of care inside your class prevents that. Return List.copyOf(lines) and they cannot break you. The copy is cheap; the alternative is a CME thrown from a frame in someone else's module.
And pick the collection for its concurrency contract where you declare the field, not after the first incident. Shared and written often is ConcurrentHashMap or a queue from java.util.concurrent. Shared, read constantly, written rarely is CopyOnWriteArrayList. Confined to one thread is a plain ArrayList, and it should stay confined. Getting that decision wrong usually does not produce an exception at all, which is exactly why it deserves the thought up front.
Author's note: The behaviour here reflects the OpenJDK implementations of ArrayList and HashMap in current LTS releases, and the samples are written to produce exactly what is shown. Line numbers inside java.base frames shift between builds, so match on frame names rather than numbers. Where something is an implementation detail rather than a specification guarantee — the removeIf bitset, the second-to-last quirk, the map iterator's lookahead — I have said so in the sentence. Do not depend on any of it.
Frequently Asked Questions
Is ConcurrentModificationException only about threads?
No, and the name is the single biggest reason this exception confuses people. The overwhelming majority of them are thrown by one thread, on one collection, in a program with no concurrency anywhere. "Concurrent" here means concurrent with an iteration, not concurrent across threads. A single-threaded loop that calls list.remove(...) inside a for-each over that same list is the canonical case. Threads are only one of several ways to structurally modify a collection while an iterator over it is still live.
Why does removing the second-to-last element of an ArrayList not throw?
Because hasNext() is implemented as cursor != size and performs no comodification check. After next() returns the second-to-last element the cursor sits at size - 1. Removing that element drops size to exactly the cursor value, so hasNext() returns false, the loop exits normally, and next() — where the check actually lives — is never called again. The result is silent: the last element is never visited and nothing is thrown. Removing the last element does throw, because there the cursor ends up greater than size and hasNext() stays true.
Can I just catch ConcurrentModificationException?
No. It reports a bug in your code, not a condition in the world, and catching it leaves the collection in whatever half-processed state the loop abandoned. The javadoc is explicit that fail-fast behaviour cannot be guaranteed and that it would be wrong to write a program depending on this exception for its correctness — and catching it is depending on it. Retrying the loop is worse, because the same code will either throw again or land on the silent variant where elements are skipped and nothing is reported.
Is CopyOnWriteArrayList the right general fix for ConcurrentModificationException?
Only for genuinely shared, read-dominated collections such as listener registries. Every mutating operation copies the entire backing array, so a frequently written list turns O(1) appends into O(n) ones and building one from empty costs O(n²). Its iterators work on a snapshot, so they never throw but also never see writes made after the iterator was created. Using it to silence a single-threaded remove-inside-a-loop bug hides the bug instead of fixing it.
Can removeIf() itself throw ConcurrentModificationException?
Yes, if the predicate has side effects. ArrayList.removeIf evaluates the predicate across the whole range first, then re-checks modCount before it compacts the array, and throws if your lambda modified the list along the way. The same applies to Map.replaceAll and to forEach on both lists and maps. The safe rule is unchanged by any of these methods: the lambda you hand to a collection must not structurally modify that collection.