Skip to content

Java ConcurrentModificationException: Causes and Fixes

ConcurrentModificationException usually has nothing to do with threads. Here is exactly what modCount does, why it fires, and the four fixes that actually work.

Java java-errors concurrentmodificationexception java-collections java-concurrency core-java jvm java-21
Gopi Gorantala
Reading Progress

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.java

On Java 8 (List.of does not exist there — use Arrays.asList wrapped in an ArrayList):

javac Repro.java && java -cp . Repro

The 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 visited

This 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 -> CME

This 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 21 reversed() views are all fail-fast against the backing collection's modCount.
  • Only in the framework: JPA/Hibernate lazy collections, Spring ApplicationListener lists, and Jackson serializers all iterate collections you may be mutating elsewhere.

3. Version Behaviour Matrix

JDKBehaviour
8Throws. Stack frames have no module prefix. Collection.removeIf and Map.forEach already available (Java 8).
11Same 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.
17Unchanged. 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.
21Unchanged. Sequenced collections (JEP 431) add reversed(), getFirst(), removeFirst() — the reversed views are fail-fast on the same modCount.
25Unchanged (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 unnecessaryremoveIf (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:

  1. next() checks, hasNext() does not. Remove the second-to-last element and size drops to cursor, so hasNext() returns false, the loop exits normally, and checkForComodification() 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."
  2. Iterator.remove() is safe because it re-syncs the snapshot. It calls the collection's remove(int), then does expectedModCount = modCount and fixes cursor/lastRet itself. Nothing is out of step afterwards.
  3. modCount is a plain int, not volatile. 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 stale modCount and 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 reason Collections.synchronizedList documents 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:

SituationFix
Remove by predicate, single collectionremoveIf — one pass, O(n) on ArrayList, no shifting per removal
Need index, or must also mutate something elseexplicit Iterator + it.remove()
Result is consumed downstream, source can stay untouchedstream().filter(...).toList()
Must stay Java 7 or the collection is a Map you mutate by keyiterate a defensive copy: for (String k : new ArrayList<>(map.keySet()))
Concurrent readers and writersConcurrentHashMap, 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 with UnsupportedOperationException — a stack trace that points at the culprit — instead of causing a CME in your iteration much later.
  • Store ConcurrentHashMap, not HashMap, for anything shared, and use compute/merge/computeIfAbsent instead of containsKey + put. A non-atomic check-then-act on a ConcurrentHashMap is a separate bug class worth its own article.
  • CopyOnWriteArrayList only 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 ModifyCollectionInEnhancedForLoop catches the classic case at compile time; -Xlint:all -Werror and 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 from List.copyOf(...); getters returning Collections.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:-TieredCompilation occasionally — different JIT states expose different interleavings.
  • Read the trace, don't guess. ArrayList$Itr → an enhanced-for or 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=profile and inspect jdk.JavaExceptionThrow events, which record the throw site even when the application swallows the exception.
  • Team convention: any for (X x : collection) body containing collection. is a review block. It is a two-character grep.

9. Key Takeaways

  • ConcurrentModificationException is 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 in next(). 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 → explicit Iterator.remove()stream().filter().toList() → iterate a defensive copy. Collections.synchronizedList is not a fix.
  • ConcurrentHashMap iterators are weakly consistent and CopyOnWriteArrayList iterators are snapshots — neither throws, which is why they belong in shared state and HashMap/ArrayList do not.
Javajava-errorsconcurrentmodificationexceptionjava-collectionsjava-concurrencycore-javajvmjava-21

Gopi Gorantala Twitter

I'm Gopi — 15+ years in Java, building Kafka and Flink platforms for banks, where one lost event is a financial discrepancy. I write javahandbook.com because the guides I needed didn't exist. Everything here is tested against a real cluster first.

Comments