You were looping over a list or map, removed or added something, and got this:
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 Cleanup.main(Cleanup.java:9)There is no message after the class name; that is normal. The exception means an iterator noticed that its collection was structurally changed (an element added or removed) by something other than the iterator itself while iteration was in progress. Despite the name, there usually is no second thread involved: the most common cause is a single thread calling list.remove(...) inside a for-each loop over that same list. The line numbers inside ArrayList vary between JDK releases; the frame that matters is the one in your code.
Quick fix checklist
- Find the loop at the line in your own code: it is iterating a collection that the loop body (or a method it calls) modifies.
- To delete matching elements, replace the loop with
collection.removeIf(predicate). - If you need the loop body for other work, use an explicit
Iteratorand callit.remove(). - For maps, use
map.entrySet().removeIf(...)ormap.values().removeIf(...). - To add elements, collect them into a separate list and call
addAllafter the loop, or build a new collection. - Only reach for
CopyOnWriteArrayListorConcurrentHashMapwhen several threads genuinely share the collection.
Before you start
You should know how the enhanced for loop works: for (String s : list) is compiled to Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); ... }. Once you see the hidden iterator, the exception makes sense. The examples use ArrayList and HashMap on Java 17 or later, but the rules apply to every collection in java.util that is not designed for concurrency (LinkedList, HashSet, TreeMap and so on).
Why it happens
ArrayList, HashMap and their siblings keep a counter called modCount. Every structural change (add, remove, clear, resizing a map) increments it. When you create an iterator, it records the current value as expectedModCount. Each call to next() compares the two. If they differ, the iterator knows its view of the collection is stale: indexes may have shifted, a bucket may have been rehashed, and continuing could skip or repeat elements. Rather than return wrong results, it throws immediately. This is called fail-fast behaviour.
The iterator’s own remove() method updates both counters, so it is the one sanctioned way to delete during iteration. Replacing the value for an existing key with map.put or calling list.set(i, x) is not a structural change and does not trigger the check.
Two consequences surprise people. First, the check happens in next(), so if the modification makes hasNext() return false, the loop just ends quietly and you get no exception, only wrong results. Second, the Javadoc describes fail-fast behaviour as best effort: with real concurrent access the check can miss a modification, so a missing exception never proves your code is thread-safe.
Step-by-step walkthrough
Step 1: Reproduce it in one thread
import java.util.ArrayList;
import java.util.List;
public class Cleanup {
public static void main(String[] args) {
List<String> users = new ArrayList<>(List.of("ann", "bob", "cat", "dan"));
for (String user : users) {
if (user.startsWith("a")) {
users.remove(user);
}
}
System.out.println(users);
}
}After ann is removed, the list has three elements and modCount has moved on. The loop’s hidden iterator calls next() for the second element, sees the mismatch and throws.
Step 2: Notice the silent variant
Change the condition to remove cat instead, which is the second-to-last element:
if (user.equals("cat")) {
users.remove(user);
}No exception. After the removal the list has three elements, the iterator’s cursor is already at 3, hasNext() returns false and the loop exits without ever visiting dan. Code like this passes a quick test and fails later when the data changes. Treat “it didn’t throw” as no evidence at all.
Step 3: Remove with removeIf or Iterator.remove
When the only job is deletion, removeIf is the clearest and, for ArrayList, the fastest option: it compacts the array in one pass instead of shifting elements for every removal.
users.removeIf(user -> user.startsWith("a"));When the loop does other work as well, use the iterator explicitly:
import java.util.Iterator;
Iterator<String> it = users.iterator();
while (it.hasNext()) {
String user = it.next();
if (user.startsWith("a")) {
audit("removing " + user);
it.remove();
}
}it.remove() removes the element most recently returned by next(). Calling it twice in a row, or before next(), throws IllegalStateException.
Step 4: Handle additions and maps
You cannot add through a plain Iterator. Collect first, then apply:
List<String> extra = new ArrayList<>();
for (String user : users) {
if (user.equals("bob")) {
extra.add("bob-admin");
}
}
users.addAll(extra);Maps behave the same way. This throws from HashMap$HashIterator.nextNode because put with a new key is structural:
Map<String, Integer> scores = new HashMap<>(Map.of("ann", 0, "bob", 3));
for (String name : scores.keySet()) {
scores.put(name + "-copy", 0); // new key: ConcurrentModificationException
}To delete entries, use the views:
scores.entrySet().removeIf(e -> e.getValue() == 0);To transform, build a new map, or use replaceAll when only values change:
scores.replaceAll((name, score) -> score + 1);The same rule applies inside lambdas: modifying a list from within list.forEach(...), or modifying the source of a stream while a terminal operation is running, throws ConcurrentModificationException too.
Step 5: Decide whether threads are really involved
If the stack trace shows a loop in one thread and the modification in another (for example a scheduler thread pruning a list that a request thread is iterating), the bug is unsynchronised sharing. Fixing it with removeIf will not help. That is the case where CopyOnWriteArrayList (many reads, rare writes), ConcurrentHashMap or explicit locking is the right tool.
Worked scenario
A notification service keeps a list of subscribers and drops the ones whose sessions have expired while sending:
class Notifier {
private final List<Subscriber> subscribers = new ArrayList<>();
void broadcast(String message) {
for (Subscriber s : subscribers) {
if (s.isExpired()) {
unsubscribe(s);
} else {
s.send(message);
}
}
}
void unsubscribe(Subscriber s) {
subscribers.remove(s);
}
}In production it threw ConcurrentModificationException a few times a day. The trace pointed at the for-each line. The diagnosis: unsubscribe modifies the list that broadcast is iterating, in the same thread. It only failed when an expired subscriber was not second-to-last, which made it look random.
The fix separates the two concerns: prune first, then send.
void broadcast(String message) {
subscribers.removeIf(Subscriber::isExpired);
for (Subscriber s : subscribers) {
s.send(message);
}
}Later, subscriptions started arriving from a WebSocket thread while broadcasts ran on a scheduler. Now there are two threads, and the right change is the collection itself:
private final List<Subscriber> subscribers = new CopyOnWriteArrayList<>();Its iterators work on a snapshot of the array, so broadcast never throws and never sees a half-updated list. Every write copies the whole array, which is fine for a subscriber list that changes rarely and is read constantly. Note that CopyOnWriteArrayList iterators do not support it.remove() (it throws UnsupportedOperationException); removeIf works.
Common mistake
Switching to CopyOnWriteArrayList to fix a single-threaded loop makes the exception go away but not the confusion. The loop now iterates a snapshot, so it still visits elements you already removed, and every removal copies the entire array, turning an O(n) cleanup into O(n squared). It also tells the next reader the collection is shared between threads when it is not.
Other tempting fixes that go wrong:
- Looping by index and calling
list.remove(i): it avoids the iterator, but every removal shifts later elements left, so the next element is skipped unless you decrementior loop backwards. - Catching
ConcurrentModificationExceptionand carrying on: the iteration state is already wrong. - Wrapping with
Collections.synchronizedList: individual calls are locked, but iteration still has to be done insidesynchronized (list) { ... }, and it does not help when the same thread modifies the list.
Verify the behavior
Pin both the broken pattern and the fix in tests so a refactor cannot bring the loop back:
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.List;
import org.junit.jupiter.api.Test;
class CleanupTest {
@Test
void removingInsideForEachFailsFast() {
List<String> users = new ArrayList<>(List.of("ann", "bob", "cat"));
assertThrows(ConcurrentModificationException.class, () -> {
for (String u : users) {
if (u.startsWith("a")) users.remove(u);
}
});
}
@Test
void removeIfDeletesEveryMatch() {
List<String> users = new ArrayList<>(List.of("ann", "amy", "bob", "abe"));
assertDoesNotThrow(() -> users.removeIf(u -> u.startsWith("a")));
assertEquals(List.of("bob"), users);
}
}Use test data with several adjacent matches, including the last element, so the silent-skip variant would fail the assertion.
Interview exercise
An interviewer shows a for-each loop that removes elements from an ArrayList and says: “This passed our test, but it throws in production. Why, and what is the cleanest fix?”
Answer and reasoning
The enhanced for loop uses the list’s iterator, which checks modCount on every next() call. Removing through list.remove changes modCount behind the iterator’s back. The test probably removed the second-to-last element: after that removal hasNext() returns false, so next() never runs, no check happens, and the last element is silently skipped. Production data hit a different position and threw. The cleanest fix is list.removeIf(predicate), which is correct and linear time; if the loop must do other work, use an explicit iterator and it.remove(). I would also mention that ConcurrentModificationException does not imply multiple threads, and that concurrent collections are the answer only when the list is actually shared.