التكرار وConcurrentModificationException
في هذا الدرس 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
ConcurrentModificationExceptioncaused by removing inside a for-each. - Remove elements safely with an
Iteratorandit.remove(). - Use
removeIf(a preview of lambdas) and the "build a new list" approach. - Remove from a
MaporSetsafely. - 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.
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.
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:
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:
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:
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:
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:
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)
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:
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)
// 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)
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:
[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 withit.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.
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.
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.
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:
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")).
- First write the for-each version that removes canceled orders (strings ending in
:canceled, tested withendsWith), run it, and note what happens. - Fix it with an iterator.
- Fix it a second way, with
removeIf. - 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()throwConcurrentModificationException. - 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(), orvalues()iterators orremoveIf; do not add or remove keys inside the loop. removeIfuses 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
- What is a for-each loop shorthand for, on a collection?
- Why does removing inside a for-each usually throw?
- Name two safe ways to remove matching elements.
- Why is it wrong to conclude "my code is correct" because it did not throw?
- What does
it.remove()remove?
أظهر الإجابات
- Getting an iterator and repeating
hasNext()andnext(). - The collection's size changed outside the iterator, and the fail-fast iterator notices on its next
next()call. - An
Iteratorwithit.remove(),removeIf, or building a new collection (a backwards index loop also works for lists). - The exception is best-effort, and some wrong removals, like the second-to-last element, pass silently.
- 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 ownremoveoradd. - Oracle, Java SE 21 API,
Collection. SupportsremoveIf, its default implementation viaIterator.remove, andUnsupportedOperationExceptionfor unmodifiable collections. - Oracle, Java SE 21 API,
Map. Supports thatentrySetis 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
IllegalStateExceptionfor a misplacedit.remove()and the fact thatputon an existing key inside a loop does not throw were checked by running code on JDK 21, not by a documentation page.
التطبيق
طبّق هذا الدرس بمختبر الوحدة وتمارينها واختبارها.
تعليم الدرس كمكتمل اختياري ويمكنك التراجع عنه في أي وقت. يبقى تقدمك محفوظًا في هذا المتصفح.