انتقل إلى المحتوى
أمين أبوزيد
منشورBEG-030-L07

التكرار وConcurrentModificationException

  • الدرس 7 من 9
  • 11 دقيقة قراءة
  • مبتدئ
في هذا الدرس 11 موضوعات

هذا الدرس متاح حاليًا باللغة الإنجليزية فقط، ولم تُنشر نسخته العربية بعد.

Lesson Overview

Looping over a collection is easy. Changing the collection while you loop over it is where programs break. The classic case: you want to remove every negative number from a list, you write a for-each loop with a remove inside, and the program crashes with a ConcurrentModificationException.

This lesson explains why that happens, shows the three safe ways to remove elements (an Iterator, removeIf, or building a new collection), and warns about one nasty detail: sometimes the broken code does not crash, and silently gives a wrong answer.

This lesson builds on Lessons 2 to 4, on exceptions from BEG-029, and on the for-each loop from BEG-015.

Learning Objectives

By the end of this lesson you will be able to:

  • Explain what a for-each loop does behind the scenes on a collection.
  • Reproduce and explain a ConcurrentModificationException caused by removing inside a for-each.
  • Remove elements safely with an Iterator and it.remove().
  • Use removeIf (a preview of lambdas) and the "build a new list" approach.
  • Remove from a Map or Set safely.
  • Explain why the exception is a bug detector, not a guarantee.

Written Explanation

What for-each really does

For a collection, for (String s : list) is shorthand for asking the collection for an iterator, an object that walks the elements, and repeating hasNext() and next() until there is nothing left.

java
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    // your loop body
}

The iterator remembers where it is. If the collection changes behind its back, its memory is wrong.

Why removing inside for-each fails

ArrayList (like most collections in java.util) has fail-fast iterators. The documentation says that if the list is structurally modified after the iterator is created, in any way except through the iterator's own remove or add, the iterator throws a ConcurrentModificationException. "Structurally modified" means the size changed: add or remove. Replacing an element with set is not a structural change.

The name is misleading. The documentation says the exception does not always mean another thread was involved. Here there is only one thread, and the loop itself modified the list it was iterating over.

java
List<Integer> nums = new ArrayList<>(List.of(4, -1, 7, -3, 5));
for (Integer n : nums) {
    if (n < 0) nums.remove(n);   // modifies the list the loop is using
}

The first removal succeeds, and the loop's next call to next() notices the change and throws. The real trace on JDK 21 begins Exception in thread "main" java.util.ConcurrentModificationException and points at the for line, not at the remove line. That is worth remembering when you debug: the crash is reported where the loop continues, not where the damage was done.

The silent version

The exception is raised on a best-effort basis; the documentation says it should be used only to detect bugs and never be relied upon for correctness. A well-known consequence: removing the second-to-last element does not throw, because after the removal hasNext() already says "no more" and next() is never called to check. Example: remove "b" from [a, b, c] inside a for-each and you get [a, c] with no error. Remove "a" from the same list and it crashes. Same bug, different luck. Never conclude "it works" because one test did not throw.

Fix 1: the Iterator and it.remove()

Use the iterator explicitly, and remove through the iterator:

java
Iterator<Integer> it = nums.iterator();
while (it.hasNext()) {
    int n = it.next();
    if (n < 0) it.remove();
}

it.remove() removes the element most recently returned by next() and keeps the iterator's bookkeeping correct. Call next() first, then remove() at most once per next().

Fix 2: removeIf

Collection has a method removeIf that takes a condition and removes every matching element in one call:

java
nums.removeIf(n -> n < 0);

The part n -> n < 0 is a lambda expression: a tiny piece of code passed as a value, read as "for each n, test n < 0". Lambdas are taught properly in BEG-035; here, treat it as a preview and copy the pattern. removeIf works on lists, sets, and queues. By the documentation, its default implementation iterates and calls Iterator.remove, so it is the iterator fix with less typing. It also fails with UnsupportedOperationException on an unmodifiable collection such as List.of(...) when there is a match to remove, and the exception is the same one from Lesson 2.

Fix 3: build a new collection

Often the simplest, clearest option is to leave the original alone and collect what you keep:

java
List<Integer> kept = new ArrayList<>();
for (int n : nums) {
    if (n >= 0) kept.add(n);
}

This costs a little memory but is easy to read and also works when the source is unmodifiable. It is a good default when the original should not change.

Fix 4 (lists only): loop backwards by index

If you loop with an index from the end towards 0, removals only shift elements you have already visited:

java
for (int i = nums.size() - 1; i >= 0; i--) {
    if (nums.get(i) < 0) nums.remove(i);
}

It works, but it is easy to get wrong with List<Integer> (the remove(int) versus remove(Object) trap from Lesson 2), so prefer the iterator or removeIf. A forward index loop is the other broken pattern: removing at index i shifts the next element into i, and the loop then skips it.

Maps and sets

Sets work exactly like lists: use an iterator or removeIf. For a Map, loop over a view and remove through its iterator, or use removeIf on a view:

java
Iterator<Map.Entry<String, Integer>> it = stock.entrySet().iterator();
while (it.hasNext()) {
    if (it.next().getValue() == 0) it.remove();
}
// or: stock.values().removeIf(v -> v == 0);

Changing a map's size (put of a new key, or remove) while iterating over keySet() or entrySet() also throws ConcurrentModificationException. Changing the value of an existing key with put is not a structural change, so it is fine inside the loop. Removal through the view or its iterator writes through to the map, because views are backed by it.

Examples

Example 1: the crash (simple)

java
List<Integer> nums = new ArrayList<>(List.of(4, -1, 7, -3, 5));
for (Integer n : nums) {
    if (n < 0) nums.remove(n);
}
System.out.println(nums);

Output:

text
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 L07a.main(L07a.java:5)

(Class name and line numbers in the trace are those of the scratch file used for this lesson; the JDK lines will vary by version.) Note that nums.remove(n) with an Integer variable removes by value, which is what we wanted; the crash is only about the concurrent change.

Example 2: four safe ways, one answer (practical)

java
// 1. Iterator
Iterator<Integer> it = nums.iterator();
while (it.hasNext()) { int n = it.next(); if (n < 0) it.remove(); }
// 2. removeIf (preview of lambdas)
a.removeIf(n -> n < 0);
// 3. backwards index loop
for (int i = b.size() - 1; i >= 0; i--) if (b.get(i) < 0) b.remove(i);
// 4. build a new list
List<Integer> kept = new ArrayList<>();
for (int n : c) if (n >= 0) kept.add(n);

Each starts from [4, -1, 7, -3, 5]. The first three printed [4, 7, 5] after changing their lists; the fourth printed [4, 7, 5] [4, -1, 7, -3, 5]: the new list holds the survivors and the original is untouched.

Example 3: the silent bug, and cleaning a map (realistic)

java
List<String> l = new ArrayList<>(List.of("a", "b", "c"));
for (String s : l) { if (s.equals("b")) l.remove(s); }
System.out.println(l);

List<String> l2 = new ArrayList<>(List.of("a", "b", "c"));
for (String s : l2) { if (s.equals("a")) l2.remove(s); }   // throws

Map<String, Integer> stock = new HashMap<>(Map.of("pen", 0, "ink", 4, "cap", 0));
Iterator<Map.Entry<String, Integer>> it = stock.entrySet().iterator();
while (it.hasNext()) {
    if (it.next().getValue() == 0) it.remove();
}
System.out.println(stock);

Output:

text
[a, c]
Exception in thread "main" java.util.ConcurrentModificationException
	...
{ink=4}

The first loop looks fine ([a, c]) only because b was second-to-last. The second loop, with the same shape, crashes. The last block removes every out-of-stock item from the map safely.

Backend Insight

This bug appears in real services whenever code "cleans up" a collection while walking it: expiring sessions from a cache map, dropping invalid rows from an import list, removing canceled orders from a basket. Because the failure can depend on which element matches, it may pass in a test and fail in production, or the opposite, and in a multi-threaded server a second thread changing a shared list can produce the same exception for a different reason. Shared mutable collections across threads need real synchronization or concurrent collections, a topic for later levels; the fail-fast exception is a bug detector, not a thread-safety mechanism.

The robust habits: prefer removeIf for "remove everything matching"; prefer building a new collection when the original should not change; and treat a ConcurrentModificationException as a pointer to the loop that was running, then look for any code in the loop body (including methods it calls) that adds or removes from the same collection.

Common Mistakes

Mistake 1: remove inside a for-each

  • Incorrect: for (String s : list) if (bad(s)) list.remove(s);
  • Correct: list.removeIf(s -> bad(s)); or an iterator with it.remove().

Mistake 2: Calling list.remove instead of it.remove while using an iterator

If you hold an iterator and then call the list's remove, the iterator detects the change and throws on its next call. Remove through the iterator.

Mistake 3: Believing "it did not throw, so it is correct"

The exception is best-effort. The second-to-last case shows code that works by accident. Judge the logic, not one run.

Mistake 4: A forward index loop that removes

Removing at i shifts the next element into i, then i++ skips it. For [2, 4, 6, 7] removing evens with a forward loop leaves [4, 7], which still contains an even number.

Mistake 5: Calling it.remove() twice, or before next()

Each next() allows at most one remove(). Otherwise Java throws an IllegalStateException.

Exercises

Try each exercise yourself before opening the solutions.

Level A: Foundation

A1. Name the exception thrown by this loop and say which line the stack trace points to.

java
List<String> l = new ArrayList<>(List.of("a", "b", "c", "d"));
for (String s : l) { if (s.equals("b")) l.add("z"); }
أظهر الحل A1

A1. ConcurrentModificationException (an add is also a structural change). It points to the for line, where the loop's next iteration discovers the change, not the add line.

A2. What does Iterator.remove() remove?

أظهر الحل A2

A2. The element most recently returned by next(), from the collection being iterated.

A3. True or false: if a loop that removes from a list inside for-each ran without an exception, the code is correct.

أظهر الحل A3

A3. False. The exception is only a best-effort detector. Removing the second-to-last element, for instance, passes silently, and the code is still the wrong pattern.

Level B: Application

B1. From List<String> names = new ArrayList<>(List.of("Ann", "", "Bob", "", "", "Cy")) remove all empty strings using an iterator. Show the result.

أظهر الحل B1

B1.

java
Iterator<String> it = names.iterator();
while (it.hasNext()) {
    if (it.next().isEmpty()) it.remove();
}

The result is [Ann, Bob, Cy].

B2. Write the one-line removeIf version of B1. (Use s.isEmpty() as the condition.)

أظهر الحل B2

B2. names.removeIf(s -> s.isEmpty());

B3. Remove from a Map<String, Integer> every entry whose value is below 5, without a ConcurrentModificationException. Write the code.

أظهر الحل B3

B3.

java
Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
    if (it.next().getValue() < 5) it.remove();
}

Or in one line: map.values().removeIf(v -> v < 5);.

Level C: Challenge

C1. Predict the final list, without running it:

java
List<Integer> v = new ArrayList<>(List.of(2, 4, 6, 7));
for (int i = 0; i < v.size(); i++) {
    if (v.get(i) % 2 == 0) v.remove(i);
}

Explain the result, then write a corrected index-based loop.

أظهر الحل C1

C1. The result is [4, 7]. Removing the 2 at index 0 shifts 4 into index 0, but i moves on to 1, so 4 is skipped; then 6 is removed in the same way. Corrected loop: for (int i = 0; i < v.size(); ) { if (v.get(i) % 2 == 0) v.remove(i); else i++; }, which gives [7]. A backwards loop or removeIf also works.

C2. For [a, b, c, d], a for-each removes "c" and runs without an exception. Explain why there is no exception, and what this tells you about relying on the exception to find bugs.

أظهر الحل C2

C2. After c is removed the list has three elements and the iterator's position is 3, so hasNext() returns false, next() is never called, and the check that throws never runs. The result [a, b, d] happens to be right here, but the exception is raised on a best-effort basis, so absence of the exception proves nothing. Use a safe pattern every time.

Mini Challenge

You have List<String> orders = new ArrayList<>(List.of("A1:paid", "A2:canceled", "A3:paid", "A4:canceled", "A5:canceled")).

  1. First write the for-each version that removes canceled orders (strings ending in :canceled, tested with endsWith), run it, and note what happens.
  2. Fix it with an iterator.
  3. Fix it a second way, with removeIf.
  4. Fix it a third way, by building a new list, and print both lists to show the original is untouched.

Check your work: all three fixes give [A1:paid, A3:paid], and you can explain in one sentence why the first version failed.

Summary & Next Lesson

Key takeaways

  • For-each uses an iterator. Changing the collection's size behind the iterator's back makes the next next() throw ConcurrentModificationException.
  • The exception is a best-effort bug detector: some bad removals do not throw, and the code is still wrong.
  • Safe removal: Iterator.remove(), removeIf(...), building a new collection, or a careful backwards index loop.
  • For maps, remove through entrySet(), keySet(), or values() iterators or removeIf; do not add or remove keys inside the loop.
  • removeIf uses a lambda, a preview of BEG-035.

Next lesson

The next lesson, "Demo: word frequency with Map", is a follow-along demo that builds a word counter with a Map, including a planned mistake, and prepares the module lab.

Self-check

  1. What is a for-each loop shorthand for, on a collection?
  2. Why does removing inside a for-each usually throw?
  3. Name two safe ways to remove matching elements.
  4. Why is it wrong to conclude "my code is correct" because it did not throw?
  5. What does it.remove() remove?
أظهر الإجابات
  1. Getting an iterator and repeating hasNext() and next().
  2. The collection's size changed outside the iterator, and the fail-fast iterator notices on its next next() call.
  3. An Iterator with it.remove(), removeIf, or building a new collection (a backwards index loop also works for lists).
  4. The exception is best-effort, and some wrong removals, like the second-to-last element, pass silently.
  5. The element most recently returned by next().

References

  • Oracle, Java SE 21 API, ConcurrentModificationException. Supports that it can occur in a single thread when a collection is modified during iteration, and that fail-fast detection is best-effort and only for detecting bugs.
  • Oracle, Java SE 21 API, ArrayList. Supports that iterators are fail-fast except for changes made through the iterator's own remove or add.
  • Oracle, Java SE 21 API, Collection. Supports removeIf, its default implementation via Iterator.remove, and UnsupportedOperationException for unmodifiable collections.
  • Oracle, Java SE 21 API, Map. Supports that entrySet is a view backed by the map, so removal through it affects the map.
  • Outputs, including the second-to-last case, were produced by compiling and running the examples with JDK 21. The IllegalStateException for a misplaced it.remove() and the fact that put on an existing key inside a loop does not throw were checked by running code on JDK 21, not by a documentation page.

التطبيق

طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.

المختبر والتمارين الاختبار

تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.

اكتب للبحث في المستويات والمراحل والوحدات والدروس.