On This Page
1. The Error
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1095)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1049)
at Repro.main(Repro.java:7)Note the shape: no message. ConcurrentModificationException almost always arrives with a null detail message, and the only useful information is in the frames. The two JDK frames tell you which collection and which access path detected the problem.
The same bug in a HashMap looks different:
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.HashMap$HashIterator.nextNode(HashMap.java:1605)
at java.base/java.util.HashMap$EntryIterator.next(HashMap.java:1638)
at MapRepro.main(MapRepro.java:6)Through Collection.forEach the iterator frames disappear entirely:
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList.forEach(ArrayList.java:1598)
at ForEachRepro.main(ForEachRepro.java:6)And through a stream, the detection happens in the spliterator:
Exception in thread "main" java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$ArrayListSpliterator.forEachRemaining(ArrayList.java:1714)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
at StreamRepro.main(StreamRepro.java:8)Message wording across versions. The exception message itself has never changed — it is empty. What changed is the stack trace rendering: on Java 8 there is no java.base/ module prefix (at java.util.ArrayList$Itr.next(ArrayList.java:851)); from Java 9 onward every JDK frame is prefixed with its module. Line numbers inside ArrayList.java differ between builds — the traces above are from OpenJDK 21.0.10. Do not match on line numbers; match on the frame names.
The Javadoc is explicit that this is not a threading-only exception:
Note that this exception does not always indicate that an object has been concurrently modified by a different thread. If a single thread issues a sequence of method invocations that violates the contract of an object, the object may throw this exception.
2. How to Reproduce It (step-by-step)
Single file, runs with the source launcher on Java 11+ (JEP 330):
import java.util.ArrayList;
import java.util.List;
public class Repro {
public static void main(String[] args) {
List<String> orders = new ArrayList<>(List.of("A-1", "A-2", "B-1", "B-2"));
for (String id : orders) { // line 7 — the frame in the trace
if (id.startsWith("A")) {
orders.remove(id); // structural modification behind the iterator's back
}
}
System.out.println(orders);
}
}java Repro.javaOn Java 8 (List.of does not exist there — use Arrays.asList wrapped in an ArrayList):
javac Repro.java && java -cp . ReproThe variant that does not throw — and is worse
List<String> l = new ArrayList<>(List.of("a", "b", "c", "d"));
for (String s : l) { if (s.equals("c")) l.remove(s); } // removes the second-to-last
System.out.println(l); // prints [a, b, d] — no exception, "d" was never visitedThis prints no exception, list = [a, b, d]. The loop silently skipped an element. Section 5 explains why, and it is the single strongest argument for never relying on this exception to catch the bug for you.
The multithreaded variant
Collections.synchronizedList does not save you — it synchronizes each method call, not the iteration:
static final List<Integer> shared = Collections.synchronizedList(new ArrayList<>());
// writer thread: shared.add(i); shared.remove(0);
// main thread: for (Integer v : shared) sum += v; // unsynchronized iteration -> CMEThis one is load- and timing-dependent: it may run for thousands of iterations before throwing, and under a different JIT state or core count it may instead return a silently wrong sum. That non-determinism is the whole point of fail-fast being best effort.
Environment-specific triggers to check
- Only under concurrency: an unsynchronized iteration over a shared
ArrayList/HashMap. - Only for certain data: removing the second-to-last element never throws (see above).
- Only through a view:
subList,keySet,entrySet,values, and the Java 21reversed()views are all fail-fast against the backing collection'smodCount. - Only in the framework: JPA/Hibernate lazy collections, Spring
ApplicationListenerlists, and Jackson serializers all iterate collections you may be mutating elsewhere.
3. Version Behaviour Matrix
| JDK | Behaviour |
|---|---|
| 8 | Throws. Stack frames have no module prefix. Collection.removeIf and Map.forEach already available (Java 8). |
| 11 | Same semantics. Module prefix java.base/ in traces since 9. List.of/Map.of immutable factories (JEP 269, Java 9) make the "just don't mutate it" fix easy; Collectors.toUnmodifiableList since Java 10. |
| 17 | Unchanged. Stream.toList() (Java 16) returns an unmodifiable list, which turns a later mutation into UnsupportedOperationException at the mutation site instead of a CME at some distant iteration. |
| 21 | Unchanged. Sequenced collections (JEP 431) add reversed(), getFirst(), removeFirst() — the reversed views are fail-fast on the same modCount. |
| 25 | Unchanged (JDK 25 GA, 16 Sep 2025). No JEP has altered fail-fast semantics. |
ConcurrentModificationException is effectively version-neutral: it has behaved the same since Java 1.2. What has changed across releases is the set of APIs that make the mistake unnecessary — removeIf (8), immutable factories (9), toUnmodifiableList (10), Stream.toList (16). Prefer those over defensive copying.
4. Why It Happens — Surface Level
An enhanced for loop over a Collection is compiled to an Iterator loop. The iterator holds a private cursor into the collection's internal array or node table. When you call orders.remove(id) directly on the collection, the collection shifts its elements and changes its size, but the iterator has no idea — its cursor now points at the wrong slot.
Rather than let the loop read stale or shifted data, the JDK's general-purpose collections detect the mismatch and throw. That is what "fail-fast" means: fail quickly and cleanly, instead of returning wrong answers non-deterministically later.
5. Why It Happens — Under the Hood
AbstractList declares a protected transient int modCount. Every structural modification — one that changes the size, or otherwise perturbs the iteration order — increments it. For HashMap a structural modification is inserting a new key or removing one; replacing the value of an existing key is not, which is why Map.Entry.setValue during iteration is legal.
ArrayList$Itr snapshots that counter at construction:
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if none
int expectedModCount = modCount; // snapshot taken when the iterator is created
public boolean hasNext() { return cursor != size; } // <-- no modCount check
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}Three consequences follow directly from this code:
next()checks,hasNext()does not. Remove the second-to-last element andsizedrops tocursor, sohasNext()returnsfalse, the loop exits normally, andcheckForComodification()never runs. You get a silently truncated iteration. This is exactly why the Javadoc says fail-fast operations throw "on a best-effort basis" and that the exception "should be used only to detect bugs."Iterator.remove()is safe because it re-syncs the snapshot. It calls the collection'sremove(int), then doesexpectedModCount = modCountand fixescursor/lastRetitself. Nothing is out of step afterwards.modCountis a plainint, notvolatile. In the multithreaded case there is no happens-before edge between the writer's increment and the reader's comparison, so the reader may observe a stalemodCountand miss the modification entirely — reading a shifted array instead. Cross-thread CME detection is therefore an accident of memory visibility, not a guarantee. This is the same reasonCollections.synchronizedListdocuments that you must hold the wrapper's monitor for the whole iteration.
The bulk operations use the same counter on a different code path. ArrayList.forEach reads modCount into a local, loops while modCount == expectedModCount && i < size, then throws afterwards if it drifted — which is why its stack trace has no Itr frames. ArrayListSpliterator is late-binding: it captures modCount at first traversal, not at stream() creation, and checks it in forEachRemaining/tryAdvance — hence the AbstractPipeline.copyInto frames when the mutation happens inside a peek or map.
ArrayList$SubList keeps its own modCount snapshot and validates on nearly every operation, so mutating the parent list invalidates the view immediately:
at java.base/java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1497)
at java.base/java.util.ArrayList$SubList.get(ArrayList.java:1232)The concurrent collections take the opposite design. ConcurrentHashMap's iterators are weakly consistent: they traverse the bin table as it exists, reflect some updates and not others, and never throw. CopyOnWriteArrayList's iterator holds a reference to the immutable array snapshot taken at creation — writes replace the array, so the iterator simply keeps reading the old one, and Iterator.remove() throws UnsupportedOperationException.
6. The Fix
Before
for (String id : orders) {
if (id.startsWith("A")) {
orders.remove(id);
}
}After — pick one:
// 1. Explicit iterator: works on Java 5+, the only option when you also need the element.
for (Iterator<String> it = orders.iterator(); it.hasNext(); ) {
if (it.next().startsWith("A")) {
it.remove();
}
}
// 2. removeIf — Java 8+, the default choice for "remove by predicate".
orders.removeIf(id -> id.startsWith("A"));
// 3. Maps: remove through the entrySet view, not the map.
map.entrySet().removeIf(e -> e.getValue() == 1);
// 4. Don't mutate at all — derive a new collection. Java 16+ for toList().
List<String> kept = orders.stream()
.filter(id -> !id.startsWith("A"))
.toList(); // Java 8: .collect(Collectors.toList())When to use which:
| Situation | Fix |
|---|---|
| Remove by predicate, single collection | removeIf — one pass, O(n) on ArrayList, no shifting per removal |
| Need index, or must also mutate something else | explicit Iterator + it.remove() |
| Result is consumed downstream, source can stay untouched | stream().filter(...).toList() |
Must stay Java 7 or the collection is a Map you mutate by key | iterate a defensive copy: for (String k : new ArrayList<>(map.keySet())) |
| Concurrent readers and writers | ConcurrentHashMap, or CopyOnWriteArrayList for read-mostly lists |
Note the performance trap in fix 1: it.remove() on an ArrayList is O(n) per call because of the System.arraycopy shift, so removing half a million elements one at a time is quadratic. removeIf on ArrayList is a single compacting pass. On LinkedList and HashMap the iterator removal is O(1).
Also note what is not a fix: wrapping in Collections.synchronizedList still throws (verified — it is the same ArrayList$Itr underneath), and catching ConcurrentModificationException and retrying hides a real logic bug behind a race.
7. Best Practices & The Better Design
The durable fix is to stop mutating collections while reading them at all. Treat a collection you are iterating as immutable for the duration of the iteration, and express filtering as a transformation rather than an in-place edit.
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public final class OrderBook {
private final Map<String, Integer> openQty = new ConcurrentHashMap<>();
/** Returns a new list; the caller cannot corrupt our state. */
public List<String> settled(List<String> orderIds) {
return orderIds.stream()
.filter(id -> !id.startsWith("A"))
.toList(); // unmodifiable, Java 16+
}
/** Atomic compound update — no check-then-act, no iteration required. */
public void fill(String orderId, int qty) {
openQty.merge(orderId, -qty, (a, b) -> a + b <= 0 ? null : a + b);
}
/** Weakly consistent iteration: never throws CME. */
public Map<String, Integer> snapshot() {
return Map.copyOf(openQty); // Java 10+
}
}Rules that generalise:
- Return unmodifiable collections from APIs.
List.copyOf,Map.copyOf,Stream.toList(Java 16+). A caller who mutates now fails at the mutation withUnsupportedOperationException— a stack trace that points at the culprit — instead of causing a CME in your iteration much later. - Store
ConcurrentHashMap, notHashMap, for anything shared, and usecompute/merge/computeIfAbsentinstead ofcontainsKey+put. A non-atomic check-then-act on aConcurrentHashMapis a separate bug class worth its own article. CopyOnWriteArrayListonly for read-mostly, small lists — listener registries, config snapshots. Every write copies the whole array.- Never expose a mutable internal collection. Defensive copy on the way in and on the way out.
- Model "remove while iterating" as filter + replace, not as mutation. Especially in event-driven code where the same collection is read by a callback.
Related failure modes worth linking from here: broken equals/hashCode on a key type causes lookup failures in the same HashMap you are iterating; IllegalArgumentException: Comparison method violates its general contract! is TimSort's equivalent of a contract violation detected mid-operation; and UnsupportedOperationException from Arrays.asList/List.of is what you get when you apply fix 2 to a fixed-size or immutable list.
8. How to Prevent It Long-Term
- Static analysis in CI. Error Prone's
ModifyCollectionInEnhancedForLoopcatches the classic case at compile time;-Xlint:all -Werrorand SpotBugs cover neighbouring collection misuse. IntelliJ flags it as "Collection is modified during iteration" — turn that inspection into a CI-enforced rule, not a squiggle developers scroll past. - Make it structurally impossible. Fields typed as
List<T>and assigned fromList.copyOf(...); getters returningCollections.unmodifiableList(...). NullAway-style enforcement of "no mutable collection crosses a module boundary" removes the whole class. - Concurrency stress tests. A JUnit test that spawns one writer and several readers over a shared collection and runs for a few hundred thousand iterations will reliably surface the multithreaded variant. For anything subtle, use
jcstress. Run these tests with-XX:-TieredCompilationoccasionally — different JIT states expose different interleavings. - Read the trace, don't guess.
ArrayList$Itr→ an enhanced-foror explicit iterator.ArrayList.forEach→ a lambda that mutated its own source.ArrayListSpliterator.forEachRemaining→ a stream whose pipeline mutated the source.SubList.checkForComodification→ a view outlived its parent's modification. - When it only reproduces in production, capture it with JFR rather than guessing:
jcmd <pid> JFR.start name=cme settings=profileand inspectjdk.JavaExceptionThrowevents, which record the throw site even when the application swallows the exception. - Team convention: any
for (X x : collection)body containingcollection.is a review block. It is a two-character grep.
9. Key Takeaways
ConcurrentModificationExceptionis usually single-threaded — it means "you mutated a collection while iterating it", not "you have a race".- It is driven by one non-volatile
int,modCount, snapshotted by the iterator and compared innext().hasNext()never checks it, so removing the second-to-last element silently truncates the loop instead of throwing. - Fail-fast is best effort. Never write code whose correctness depends on this exception being thrown, and never catch-and-retry it.
- The fixes, in order of preference:
removeIf→ explicitIterator.remove()→stream().filter().toList()→ iterate a defensive copy.Collections.synchronizedListis not a fix. ConcurrentHashMapiterators are weakly consistent andCopyOnWriteArrayListiterators are snapshots — neither throws, which is why they belong in shared state andHashMap/ArrayListdo not.
Gopi Gorantala Newsletter
Join the newsletter to receive the latest updates in your inbox.